mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-29 07:56:37 +08:00
Compare commits
69 Commits
2.1.9-beta2
..
2.1.9
| Author | SHA1 | Date | |
|---|---|---|---|
| 701b4011cb | |||
| d128d2f657 | |||
| 400a40fe80 | |||
| 103290ed35 | |||
| 363e714603 | |||
| afd1258fcd | |||
| e69082a596 | |||
| d30363d164 | |||
| 8e1a87bf5a | |||
| 6180b5a198 | |||
| 61d95ab5d5 | |||
| c27be19915 | |||
| f62a35c3f9 | |||
| 2a1caf32c4 | |||
| fdcc30a493 | |||
| efaffb0475 | |||
| 4954526cbc | |||
| 9d50071915 | |||
| ceceee6ebd | |||
| 11051f5517 | |||
| ff2c7c4959 | |||
| 6364b96935 | |||
| 409f0a232a | |||
| f79994e0e0 | |||
| 9fdb16d035 | |||
| 53b632a6f7 | |||
| bf7b2a0740 | |||
| 8475bc27bb | |||
| 5d01572eff | |||
| a353faaa71 | |||
| e7b25004ba | |||
| 3826cb02c0 | |||
| a1fee8e432 | |||
| aafdb78482 | |||
| 16b545d8cd | |||
| 45065178b8 | |||
| 8ebde9dca9 | |||
| 0a1ec60750 | |||
| 9ec35d2f2f | |||
| c914040b7d | |||
| 822362c44c | |||
| 80f5935b76 | |||
| 433c8aab13 | |||
| 949dfcd42d | |||
| 1580e4ee10 | |||
| 32e4f0f514 | |||
| ca3a643ef7 | |||
| ce2b234843 | |||
| ac3506847c | |||
| 6e3d604618 | |||
| 4417ece7cd | |||
| bab4371ba7 | |||
| 960c97cee4 | |||
| 9d05d75fd6 | |||
| c137bdcc63 | |||
| b7065f6e99 | |||
| bd4e1f66cb | |||
| 9f0670f4d0 | |||
| 32e338d295 | |||
| f6a753baa3 | |||
| 322a10bb9d | |||
| 27c13d6c47 | |||
| f45f96063a | |||
| 1780be73b9 | |||
| addf8e2089 | |||
| 75cd60ea3e | |||
| fe42a77409 | |||
| ce9abf457f | |||
| 3c57a5ac84 |
@@ -1,9 +1,9 @@
|
|||||||
# PROJECT KNOWLEDGE BASE
|
# PROJECT KNOWLEDGE BASE
|
||||||
|
|
||||||
**Generated:** Thu Mar 19 2026
|
**Generated:** Tue Mar 24 2026
|
||||||
**Commit:** 6458b5a
|
**Commit:** 8ebde9d
|
||||||
**Branch:** main
|
**Branch:** main
|
||||||
**Tag:** 2.1.9-alpha5
|
**Tag:** 2.1.9-rc10
|
||||||
|
|
||||||
## OVERVIEW
|
## 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.
|
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) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
|
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
|
||||||
|
|
||||||
面板端(以 2.1.0 为例):
|
面板端(以 2.1.9-beta6 为例):
|
||||||
```bash
|
```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
|
```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)
|
#### PostgreSQL 部署(Docker Compose)
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# GO BACKEND KNOWLEDGE BASE
|
# GO BACKEND KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
|
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).
|
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go-backend/internal/monitoring"
|
"go-backend/internal/monitoring"
|
||||||
@@ -20,26 +21,47 @@ type nodeCommander interface {
|
|||||||
SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error)
|
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 {
|
type Checker struct {
|
||||||
repo *repo.Repository
|
repo *repo.Repository
|
||||||
commander nodeCommander
|
commander nodeCommander
|
||||||
lastRun map[int64]int64
|
lastRun map[int64]int64
|
||||||
inFlight map[int64]struct{}
|
inFlight map[int64]struct{}
|
||||||
|
|
||||||
mu sync.RWMutex
|
// In-memory latest result per monitor (for real-time API reads)
|
||||||
cancel context.CancelFunc
|
latestResults map[int64]*model.ServiceMonitorResult
|
||||||
wg sync.WaitGroup
|
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 {
|
func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker {
|
||||||
return &Checker{
|
return &Checker{
|
||||||
repo: repo,
|
repo: repo,
|
||||||
commander: commander,
|
commander: commander,
|
||||||
lastRun: make(map[int64]int64),
|
lastRun: make(map[int64]int64),
|
||||||
inFlight: make(map[int64]struct{}),
|
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) {
|
func (c *Checker) Start(ctx context.Context) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
ctx, cancel := context.WithCancel(ctx)
|
ctx, cancel := context.WithCancel(ctx)
|
||||||
@@ -52,7 +74,7 @@ func (c *Checker) Start(ctx context.Context) {
|
|||||||
limits := c.loadServiceMonitorLimits()
|
limits := c.loadServiceMonitorLimits()
|
||||||
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
|
scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second
|
||||||
if scanInterval <= 0 {
|
if scanInterval <= 0 {
|
||||||
scanInterval = 30 * time.Second
|
scanInterval = 1 * time.Second
|
||||||
}
|
}
|
||||||
|
|
||||||
timer := time.NewTimer(scanInterval)
|
timer := time.NewTimer(scanInterval)
|
||||||
@@ -87,6 +109,12 @@ func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult,
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Checker) runChecks(ctx context.Context) {
|
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 {
|
if c == nil || c.repo == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -174,6 +202,8 @@ func (c *Checker) runChecks(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
close(jobs)
|
close(jobs)
|
||||||
|
|
||||||
|
reportIntervalMs := int64(serviceMonitorReportInterval / time.Millisecond)
|
||||||
|
|
||||||
for i := 0; i < workerLimit; i++ {
|
for i := 0; i < workerLimit; i++ {
|
||||||
c.wg.Add(1)
|
c.wg.Add(1)
|
||||||
go func() {
|
go func() {
|
||||||
@@ -188,14 +218,26 @@ func (c *Checker) runChecks(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
ts := time.Now().UnixMilli()
|
ts := time.Now().UnixMilli()
|
||||||
result := c.executeCheck(&m, ts, limits)
|
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.mu.Lock()
|
||||||
|
c.latestResults[m.ID] = result
|
||||||
c.lastRun[m.ID] = result.Timestamp
|
c.lastRun[m.ID] = result.Timestamp
|
||||||
delete(c.inFlight, m.ID)
|
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()
|
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
|
# 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
|
## OVERVIEW
|
||||||
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
HTTP request handlers for FLVX Admin API. Core business logic layer.
|
||||||
|
|||||||
@@ -280,6 +280,16 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
|||||||
for _, fp := range ports {
|
for _, fp := range ports {
|
||||||
if limiterID != nil && speed != nil {
|
if limiterID != nil && speed != nil {
|
||||||
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
|
||||||
|
// If the limiter push fails because the node is offline, skip it with a warning
|
||||||
|
if isNodeOfflineOrTimeoutError(err) {
|
||||||
|
node, _ := h.getNodeRecord(fp.NodeID)
|
||||||
|
nodeName := fmt.Sprintf("%d", fp.NodeID)
|
||||||
|
if node != nil && strings.TrimSpace(node.Name) != "" {
|
||||||
|
nodeName = strings.TrimSpace(node.Name)
|
||||||
|
}
|
||||||
|
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", nodeName))
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -308,6 +318,12 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
|
|||||||
warnings = append(warnings, warning)
|
warnings = append(warnings, warning)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// When a node is offline, skip it with a warning instead of failing.
|
||||||
|
// This lets users modify forward rules even when some entry nodes are down.
|
||||||
|
if err != nil && isNodeOfflineOrTimeoutError(err) {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("节点 %s 不在线,已跳过下发", node.Name))
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
return warnings, fmt.Errorf("节点 %s 下发失败: %w", node.Name, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -345,21 +345,39 @@ func TestSelectTunnelDialHost_V6Only_PreferV4Fallback(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectTunnelDialHost_Incompatible(t *testing.T) {
|
func TestSelectTunnelDialHost_CrossVersion_V4ToV6(t *testing.T) {
|
||||||
|
// v4-only -> v6-only: 跨版本支持,应成功返回 v6 地址
|
||||||
from := v4OnlyNode("from", "10.0.0.1")
|
from := v4OnlyNode("from", "10.0.0.1")
|
||||||
to := v6OnlyNode("to", "2001:db8::2")
|
to := v6OnlyNode("to", "2001:db8::2")
|
||||||
_, err := selectTunnelDialHost(from, to, "", "")
|
host, err := selectTunnelDialHost(from, to, "", "")
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatal("expected error for incompatible nodes (v4-only -> v6-only)")
|
t.Fatalf("unexpected error for cross-version (v4-only -> v6-only): %v", err)
|
||||||
|
}
|
||||||
|
if host != "2001:db8::2" {
|
||||||
|
t.Fatalf("expected v6 address for cross-version, got %q", host)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestSelectTunnelDialHost_Incompatible_Reverse(t *testing.T) {
|
func TestSelectTunnelDialHost_CrossVersion_V6ToV4(t *testing.T) {
|
||||||
|
// v6-only -> v4-only: 跨版本支持,应成功返回 v4 地址
|
||||||
from := v6OnlyNode("from", "2001:db8::1")
|
from := v6OnlyNode("from", "2001:db8::1")
|
||||||
to := v4OnlyNode("to", "10.0.0.2")
|
to := v4OnlyNode("to", "10.0.0.2")
|
||||||
|
host, err := selectTunnelDialHost(from, to, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error for cross-version (v6-only -> v4-only): %v", err)
|
||||||
|
}
|
||||||
|
if host != "10.0.0.2" {
|
||||||
|
t.Fatalf("expected v4 address for cross-version, got %q", host)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectTunnelDialHost_TrulyIncompatible(t *testing.T) {
|
||||||
|
// 真正不兼容:两个节点都没有任何 IP
|
||||||
|
from := &nodeRecord{Name: "empty-from", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
|
||||||
|
to := &nodeRecord{Name: "empty-to", ServerIPv4: "", ServerIPv6: "", ServerIP: ""}
|
||||||
_, err := selectTunnelDialHost(from, to, "", "")
|
_, err := selectTunnelDialHost(from, to, "", "")
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Fatal("expected error for incompatible nodes (v6-only -> v4-only)")
|
t.Fatal("expected error for nodes with no IP addresses")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,12 @@ type Handler struct {
|
|||||||
|
|
||||||
upgradeMu sync.Mutex
|
upgradeMu sync.Mutex
|
||||||
pendingUpgradeRedeploy map[int64]struct{}
|
pendingUpgradeRedeploy map[int64]struct{}
|
||||||
|
|
||||||
|
qualityProber *tunnelQualityProber
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const monitorTunnelQualityEnabledConfigKey = "monitor_tunnel_quality_enabled"
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -93,6 +97,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
|||||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||||
}
|
}
|
||||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||||
|
h.qualityProber = newTunnelQualityProber(h)
|
||||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||||
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
||||||
metricInfo := metrics.SystemInfo{
|
metricInfo := metrics.SystemInfo{
|
||||||
@@ -229,6 +234,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
|||||||
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
||||||
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
||||||
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
|
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/tunnels/", h.monitorTunnelMetrics)
|
||||||
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
||||||
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
||||||
@@ -882,11 +888,32 @@ func normalizeAndValidateConfigValue(key, value string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return pngDataURLPrefix + payload, nil
|
return pngDataURLPrefix + payload, nil
|
||||||
|
case monitorTunnelQualityEnabledConfigKey:
|
||||||
|
normalized := strings.TrimSpace(strings.ToLower(value))
|
||||||
|
switch normalized {
|
||||||
|
case "true", "false":
|
||||||
|
return normalized, nil
|
||||||
|
default:
|
||||||
|
return "", fmt.Errorf("隧道质量检测开关配置值无效")
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return value, nil
|
return value, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) isTunnelQualityMonitoringEnabled() bool {
|
||||||
|
if h == nil || h.repo == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := h.repo.GetConfigByName(monitorTunnelQualityEnabledConfigKey)
|
||||||
|
if err != nil || cfg == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(strings.ToLower(cfg.Value)) != "false"
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) userPackage(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
h.jobsCancel = cancel
|
h.jobsCancel = cancel
|
||||||
h.jobsStarted = true
|
h.jobsStarted = true
|
||||||
h.jobsWG.Add(5)
|
h.jobsWG.Add(6)
|
||||||
h.jobsMu.Unlock()
|
h.jobsMu.Unlock()
|
||||||
|
|
||||||
go h.runHourlyStatsLoop(ctx)
|
go h.runHourlyStatsLoop(ctx)
|
||||||
@@ -26,6 +26,7 @@ func (h *Handler) StartBackgroundJobs() {
|
|||||||
go h.runNodeRenewalCycleLoop(ctx)
|
go h.runNodeRenewalCycleLoop(ctx)
|
||||||
go h.runMetricsIngestion(ctx)
|
go h.runMetricsIngestion(ctx)
|
||||||
go h.runHealthChecks(ctx)
|
go h.runHealthChecks(ctx)
|
||||||
|
go h.runTunnelQualityProber(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) StopBackgroundJobs() {
|
func (h *Handler) StopBackgroundJobs() {
|
||||||
@@ -63,6 +64,15 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||||
|
defer h.jobsWG.Done()
|
||||||
|
if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.qualityProber.Start(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||||
defer h.jobsWG.Done()
|
defer h.jobsWG.Done()
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ type monitorNodeListItem struct {
|
|||||||
Inx int `json:"inx"`
|
Inx int `json:"inx"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
|
Version string `json:"version"`
|
||||||
UpdatedTime int64 `json:"updatedTime"`
|
UpdatedTime int64 `json:"updatedTime"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,6 +101,7 @@ func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request)
|
|||||||
Inx: n.Inx,
|
Inx: n.Inx,
|
||||||
Name: n.Name,
|
Name: n.Name,
|
||||||
Status: n.Status,
|
Status: n.Status,
|
||||||
|
Version: n.Version.String,
|
||||||
UpdatedTime: updated,
|
UpdatedTime: updated,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -207,6 +209,98 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
|
|||||||
response.WriteJSON(w, response.OK(metric))
|
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) {
|
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodGet {
|
if r.Method != http.MethodGet {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
@@ -216,7 +310,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
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)
|
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||||
if err != nil || tunnelID <= 0 {
|
if err != nil || tunnelID <= 0 {
|
||||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||||
@@ -587,6 +697,30 @@ func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
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
|
limit := 100
|
||||||
if l := r.URL.Query().Get("limit"); l != "" {
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
|
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
|
||||||
@@ -612,6 +746,16 @@ func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *h
|
|||||||
return
|
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()
|
results, err := h.repo.GetLatestServiceMonitorResults()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
|
|||||||
@@ -1102,24 +1102,113 @@ func (h *Handler) syncTunnelForwardsEntryPorts(tunnelID int64, entryNodeIDs []in
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
port := pickForwardPortFromRecords(oldPorts)
|
referencePort := pickForwardPortFromRecords(oldPorts)
|
||||||
if port <= 0 {
|
if referencePort <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var entries []forwardPortReplaceEntry
|
// Build a map of existing node → port/inIP from old records.
|
||||||
if allowInIP {
|
oldPortByNode := make(map[int64]forwardPortRecord)
|
||||||
entries = buildForwardPortEntriesWithPreservedInIP(entryNodeIDs, oldPorts, port)
|
for _, fp := range oldPorts {
|
||||||
} else {
|
if fp.NodeID > 0 {
|
||||||
entries = make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
oldPortByNode[fp.NodeID] = fp
|
||||||
for _, nid := range entryNodeIDs {
|
|
||||||
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: ""})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entries := make([]forwardPortReplaceEntry, 0, len(entryNodeIDs))
|
||||||
|
for _, nid := range entryNodeIDs {
|
||||||
|
if existing, ok := oldPortByNode[nid]; ok && existing.Port > 0 {
|
||||||
|
// Existing entry node: keep its current port.
|
||||||
|
inIP := existing.InIP
|
||||||
|
if !allowInIP {
|
||||||
|
inIP = ""
|
||||||
|
}
|
||||||
|
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: existing.Port, InIP: inIP})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// New entry node: try to follow the reference port.
|
||||||
|
port := h.resolvePortForNewEntryNode(nid, referencePort, f.ID)
|
||||||
|
inIP := ""
|
||||||
|
if allowInIP {
|
||||||
|
// For single-entry tunnels, try to preserve inIP from old records.
|
||||||
|
for _, fp := range oldPorts {
|
||||||
|
if strings.TrimSpace(fp.InIP) != "" {
|
||||||
|
inIP = fp.InIP
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
entries = append(entries, forwardPortReplaceEntry{NodeID: nid, Port: port, InIP: inIP})
|
||||||
|
}
|
||||||
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
|
_ = h.repo.ReplaceForwardPorts(f.ID, entries)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolvePortForNewEntryNode determines the port for a forward on a newly added
|
||||||
|
// entry node. It tries to reuse referencePort (from existing entries); if that
|
||||||
|
// port is out of range or already occupied, it picks a random available port
|
||||||
|
// for this specific node.
|
||||||
|
func (h *Handler) resolvePortForNewEntryNode(nodeID int64, referencePort int, forwardID int64) int {
|
||||||
|
node, err := h.getNodeRecord(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return referencePort
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if referencePort is within the node's allowed range.
|
||||||
|
if validateLocalNodePort(node, referencePort) == nil &&
|
||||||
|
validateRemoteNodePort(node, referencePort) == nil {
|
||||||
|
// In range — check availability.
|
||||||
|
occupied, occErr := h.repo.HasOtherForwardOnNodePort(nodeID, referencePort, forwardID)
|
||||||
|
if occErr == nil && !occupied {
|
||||||
|
return referencePort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// referencePort doesn't work for this node; pick a random one.
|
||||||
|
newPort := h.pickRandomPortForNode(nodeID)
|
||||||
|
if newPort > 0 {
|
||||||
|
return newPort
|
||||||
|
}
|
||||||
|
return referencePort // last resort fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickRandomPortForNode picks a random available port from a single node's
|
||||||
|
// port range, excluding ports already occupied by other forwards or chains.
|
||||||
|
func (h *Handler) pickRandomPortForNode(nodeID int64) int {
|
||||||
|
portRange, err := h.repo.GetNodePortRange(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if portRange == "" {
|
||||||
|
portRange = "1000-65535"
|
||||||
|
}
|
||||||
|
|
||||||
|
nodePorts, err := parsePorts(portRange)
|
||||||
|
if err != nil || len(nodePorts) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
used, err := h.getUsedPorts(nodeID)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
var available []int
|
||||||
|
for _, p := range nodePorts {
|
||||||
|
if !used[p] {
|
||||||
|
available = append(available, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(available) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
idx, _ := rand.Int(rand.Reader, big.NewInt(int64(len(available))))
|
||||||
|
return available[idx.Int64()]
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) tunnelDelete(w http.ResponseWriter, r *http.Request) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||||
@@ -1468,6 +1557,12 @@ func (h *Handler) userTunnelRemove(w http.ResponseWriter, r *http.Request) {
|
|||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
userID, tunnelID, lookupErr := h.repo.GetUserTunnelUserAndTunnel(id)
|
||||||
|
if lookupErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, lookupErr.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.cleanupForwardsForUserTunnel(userID, tunnelID)
|
||||||
if err := h.repo.DeleteUserTunnel(id); err != nil {
|
if err := h.repo.DeleteUserTunnel(id); err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
@@ -2394,14 +2489,18 @@ func (h *Handler) groupUserAssign(w http.ResponseWriter, r *http.Request) {
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs); err != nil {
|
revokedPairs, revokeErr := h.repo.RevokeGroupGrantsForRemovedUsersTx(tx, req.GroupID, previousUserIDs, req.UserIDs)
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
if revokeErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := tx.Commit().Error; err != nil {
|
if err := tx.Commit().Error; err != nil {
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for _, pair := range revokedPairs {
|
||||||
|
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
|
||||||
|
}
|
||||||
_ = h.syncPermissionsByUserGroup(req.GroupID)
|
_ = h.syncPermissionsByUserGroup(req.GroupID)
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
@@ -2445,9 +2544,12 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var revokedPairs []repo.RevokedUserTunnelPair
|
||||||
if exists {
|
if exists {
|
||||||
if err := h.repo.RevokeGroupPermissionPairTx(tx, ug, tg); err != nil {
|
var revokeErr error
|
||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
revokedPairs, revokeErr = h.repo.RevokeGroupPermissionPairTx(tx, ug, tg)
|
||||||
|
if revokeErr != nil {
|
||||||
|
response.WriteJSON(w, response.Err(-2, revokeErr.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2456,6 +2558,9 @@ func (h *Handler) groupPermissionRemove(w http.ResponseWriter, r *http.Request)
|
|||||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
for _, pair := range revokedPairs {
|
||||||
|
h.cleanupForwardsForUserTunnel(pair.UserID, pair.TunnelID)
|
||||||
|
}
|
||||||
response.WriteJSON(w, response.OKEmpty())
|
response.WriteJSON(w, response.OKEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2675,6 +2780,22 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When updating an existing tunnel (excludeTunnelID > 0), build a set of
|
||||||
|
// node IDs that already belong to the tunnel so we can tolerate offline
|
||||||
|
// nodes that the user is keeping or removing, while still rejecting newly
|
||||||
|
// added offline nodes.
|
||||||
|
existingNodeIDs := make(map[int64]struct{})
|
||||||
|
if excludeTunnelID > 0 {
|
||||||
|
var existIDs []int64
|
||||||
|
if err := tx.Model(&model.ChainTunnel{}).
|
||||||
|
Where("tunnel_id = ?", excludeTunnelID).
|
||||||
|
Pluck("node_id", &existIDs).Error; err == nil {
|
||||||
|
for _, eid := range existIDs {
|
||||||
|
existingNodeIDs[eid] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
seen := make(map[int64]struct{}, len(nodeIDs))
|
seen := make(map[int64]struct{}, len(nodeIDs))
|
||||||
for _, nodeID := range nodeIDs {
|
for _, nodeID := range nodeIDs {
|
||||||
if _, ok := seen[nodeID]; ok {
|
if _, ok := seen[nodeID]; ok {
|
||||||
@@ -2693,7 +2814,12 @@ func (h *Handler) prepareTunnelCreateState(tx *gorm.DB, req map[string]interface
|
|||||||
return nil, errors.New("节点不存在")
|
return nil, errors.New("节点不存在")
|
||||||
}
|
}
|
||||||
if node.IsRemote != 1 && node.Status != 1 {
|
if node.IsRemote != 1 && node.Status != 1 {
|
||||||
return nil, errors.New("部分节点不在线")
|
// For tunnel updates, allow offline nodes that already belong to the
|
||||||
|
// tunnel (user may be removing them). Only reject genuinely new offline nodes.
|
||||||
|
_, isExisting := existingNodeIDs[nodeID]
|
||||||
|
if excludeTunnelID <= 0 || !isExisting {
|
||||||
|
return nil, errors.New("部分节点不在线")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
state.Nodes[nodeID] = node
|
state.Nodes[nodeID] = node
|
||||||
}
|
}
|
||||||
@@ -3093,7 +3219,6 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, inNode := range state.InNodes {
|
for _, inNode := range state.InNodes {
|
||||||
node := state.Nodes[inNode.NodeID]
|
|
||||||
targets := state.OutNodes
|
targets := state.OutNodes
|
||||||
if len(state.ChainHops) > 0 {
|
if len(state.ChainHops) > 0 {
|
||||||
targets = state.ChainHops[0]
|
targets = state.ChainHops[0]
|
||||||
@@ -3103,7 +3228,7 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
return createdChains, createdServices, err
|
return createdChains, createdServices, err
|
||||||
}
|
}
|
||||||
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
if _, err := h.sendNodeCommand(inNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||||
if node != nil && node.IsRemote == 1 && shouldDeferTunnelRuntimeApplyError(err) {
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("入口节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[inNode.NodeID]), err)
|
||||||
@@ -3117,7 +3242,8 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
nextTargets = state.ChainHops[i+1]
|
nextTargets = state.ChainHops[i+1]
|
||||||
}
|
}
|
||||||
for _, chainNode := range hop {
|
for _, chainNode := range hop {
|
||||||
if node := state.Nodes[chainNode.NodeID]; node != nil && node.IsRemote == 1 {
|
node := state.Nodes[chainNode.NodeID]
|
||||||
|
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
chainData, err := buildTunnelChainConfig(state.TunnelID, chainNode.NodeID, nextTargets, state.Nodes, state.IPPreference)
|
||||||
@@ -3125,12 +3251,18 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
return createdChains, createdServices, err
|
return createdChains, createdServices, err
|
||||||
}
|
}
|
||||||
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
if _, err := h.sendNodeCommand(chainNode.NodeID, "AddChains", chainData, true, false); err != nil {
|
||||||
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发转发链失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdChains = append(createdChains, chainNode.NodeID)
|
createdChains = append(createdChains, chainNode.NodeID)
|
||||||
|
|
||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, chainNode, state.Nodes[chainNode.NodeID], len(nextTargets))
|
||||||
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
if err := h.addTunnelServiceOnNode(chainNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||||
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("转发链节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[chainNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdServices = append(createdServices, chainNode.NodeID)
|
createdServices = append(createdServices, chainNode.NodeID)
|
||||||
@@ -3138,11 +3270,15 @@ func (h *Handler) applyTunnelRuntime(state *tunnelCreateState) ([]int64, []int64
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, outNode := range state.OutNodes {
|
for _, outNode := range state.OutNodes {
|
||||||
if node := state.Nodes[outNode.NodeID]; node != nil && node.IsRemote == 1 {
|
node := state.Nodes[outNode.NodeID]
|
||||||
|
if node != nil && (node.IsRemote == 1 || node.Status != 1) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
serviceData := buildTunnelChainServiceConfig(state.TunnelID, outNode, state.Nodes[outNode.NodeID], 1)
|
||||||
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
if err := h.addTunnelServiceOnNode(outNode.NodeID, state.TunnelID, serviceData); err != nil {
|
||||||
|
if shouldDeferTunnelRuntimeApplyError(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
return createdChains, createdServices, fmt.Errorf("出口节点 %s 下发服务失败: %w", nodeDisplayName(state.Nodes[outNode.NodeID]), err)
|
||||||
}
|
}
|
||||||
createdServices = append(createdServices, outNode.NodeID)
|
createdServices = append(createdServices, outNode.NodeID)
|
||||||
@@ -3233,6 +3369,13 @@ func shouldDeferTunnelRuntimeApplyError(err error) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isNodeOfflineOrTimeoutError returns true when the error indicates a node
|
||||||
|
// is unreachable (offline or timed out), matching the same patterns used by
|
||||||
|
// shouldDeferTunnelRuntimeApplyError.
|
||||||
|
func isNodeOfflineOrTimeoutError(err error) bool {
|
||||||
|
return shouldDeferTunnelRuntimeApplyError(err)
|
||||||
|
}
|
||||||
|
|
||||||
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
|
func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRuntimeNode, nodes map[int64]*nodeRecord, ipPreference string) (map[string]interface{}, error) {
|
||||||
fromNode := nodes[fromNodeID]
|
fromNode := nodes[fromNodeID]
|
||||||
if fromNode == nil {
|
if fromNode == nil {
|
||||||
@@ -3359,6 +3502,7 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
// 同版本优先
|
||||||
if fromV4 && toV4 {
|
if fromV4 && toV4 {
|
||||||
if host := pickNodeAddressV4(toNode); host != "" {
|
if host := pickNodeAddressV4(toNode); host != "" {
|
||||||
return host, nil
|
return host, nil
|
||||||
@@ -3369,6 +3513,17 @@ func selectTunnelDialHost(fromNode, toNode *nodeRecord, ipPreference string, con
|
|||||||
return host, nil
|
return host, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 跨版本支持:v6入v4出 / v4入v6出
|
||||||
|
if fromV6 && toV4 {
|
||||||
|
if host := pickNodeAddressV4(toNode); host != "" {
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if fromV4 && toV6 {
|
||||||
|
if host := pickNodeAddressV6(toNode); host != "" {
|
||||||
|
return host, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
|
return "", fmt.Errorf("节点链路不兼容:%s(v4=%t,v6=%t) -> %s(v4=%t,v6=%t)", nodeDisplayName(fromNode), fromV4, fromV6, nodeDisplayName(toNode), toV4, toV6)
|
||||||
}
|
}
|
||||||
@@ -3911,6 +4066,27 @@ func (h *Handler) syncUserTunnelForwards(userID, tunnelID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cleanupForwardsForUserTunnel deletes all forwarding rules belonging to a
|
||||||
|
// specific user+tunnel pair. It notifies nodes to remove the runtime services
|
||||||
|
// first, then deletes the DB records. This is best-effort: individual failures
|
||||||
|
// do not abort the overall cleanup so that remaining forwards are still cleaned.
|
||||||
|
func (h *Handler) cleanupForwardsForUserTunnel(userID, tunnelID int64) {
|
||||||
|
if userID <= 0 || tunnelID <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
forwards, err := h.repo.ListForwardsByUserAndTunnel(userID, tunnelID)
|
||||||
|
if err != nil || len(forwards) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for i := range forwards {
|
||||||
|
f := &forwards[i]
|
||||||
|
if f.Status == 1 {
|
||||||
|
_ = h.controlForwardServices(f, "DeleteService", true)
|
||||||
|
}
|
||||||
|
_ = h.deleteForwardByID(f.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
func (h *Handler) normalizeSpeedLimitReference(speedID *int64) (*int64, error) {
|
||||||
if speedID == nil {
|
if speedID == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
|||||||
}
|
}
|
||||||
|
|
||||||
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
forwardDeltas := make(map[int64]tunnelTrafficDelta)
|
||||||
|
var skippedParse, skippedZero int
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
name := strings.TrimSpace(item.N)
|
name := strings.TrimSpace(item.N)
|
||||||
if name == "" || name == "web_api" {
|
if name == "" || name == "web_api" {
|
||||||
@@ -42,9 +43,11 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
|||||||
}
|
}
|
||||||
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
forwardID, _, _, ok := parseFlowServiceIDs(name)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
skippedParse++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if item.D == 0 && item.U == 0 {
|
if item.D == 0 && item.U == 0 {
|
||||||
|
skippedZero++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
d := forwardDeltas[forwardID]
|
d := forwardDeltas[forwardID]
|
||||||
@@ -53,6 +56,9 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
|||||||
forwardDeltas[forwardID] = d
|
forwardDeltas[forwardID] = d
|
||||||
}
|
}
|
||||||
if len(forwardDeltas) == 0 {
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +73,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
if len(forwardTunnelMap) == 0 {
|
if len(forwardTunnelMap) == 0 {
|
||||||
|
log.Printf("monitoring debug op=tunnel_metric.no_tunnel_map node_id=%d forward_ids=%v", nodeID, forwardIDs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,5 +114,7 @@ func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowIte
|
|||||||
|
|
||||||
if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil {
|
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)
|
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,381 @@
|
|||||||
|
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 {
|
||||||
|
return &tunnelQualityProber{
|
||||||
|
handler: h,
|
||||||
|
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() {
|
||||||
|
if p == nil || p.cancel == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *tunnelQualityProber) isEnabled() bool {
|
||||||
|
if p == nil || p.handler == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.handler.isTunnelQualityMonitoringEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
|
||||||
|
func (p *tunnelQualityProber) maybePrune() {
|
||||||
|
if !p.isEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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() {
|
||||||
|
if !p.isEnabled() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,7 +36,7 @@ func TestRecordNodeMetric(t *testing.T) {
|
|||||||
svc.RecordNodeMetric(1, info)
|
svc.RecordNodeMetric(1, info)
|
||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -86,7 +86,7 @@ func TestRecordNodeMetricAutoFlush(t *testing.T) {
|
|||||||
|
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -123,7 +123,7 @@ func TestIngestionServiceStart(t *testing.T) {
|
|||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -198,7 +198,7 @@ func TestGetMetricsWithTimeRange(t *testing.T) {
|
|||||||
|
|
||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
metrics, err := svc.GetMetrics(1, 0, now+1000)
|
metrics, err := svc.GetMetrics(1, now-60000, now+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -224,7 +224,7 @@ func TestPruneMetrics(t *testing.T) {
|
|||||||
|
|
||||||
svc.pruneMetrics()
|
svc.pruneMetrics()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
@@ -255,7 +255,7 @@ func TestMultipleNodes(t *testing.T) {
|
|||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
for nodeID := int64(1); nodeID <= 3; nodeID++ {
|
for nodeID := int64(1); nodeID <= 3; nodeID++ {
|
||||||
metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(nodeID, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics for node %d: %v", nodeID, err)
|
t.Fatalf("get metrics for node %d: %v", nodeID, err)
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ func TestZeroValues(t *testing.T) {
|
|||||||
svc.RecordNodeMetric(1, info)
|
svc.RecordNodeMetric(1, info)
|
||||||
svc.flushNodeMetrics()
|
svc.flushNodeMetrics()
|
||||||
|
|
||||||
metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000)
|
metrics, err := r.GetNodeMetrics(1, time.Now().UnixMilli()-60000, time.Now().UnixMilli()+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ const (
|
|||||||
|
|
||||||
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
|
func DefaultServiceMonitorLimits() ServiceMonitorLimits {
|
||||||
return ServiceMonitorLimits{
|
return ServiceMonitorLimits{
|
||||||
CheckerScanIntervalSec: 30,
|
CheckerScanIntervalSec: 1,
|
||||||
WorkerLimit: 5,
|
WorkerLimit: 20,
|
||||||
MinIntervalSec: 30,
|
MinIntervalSec: 1,
|
||||||
DefaultIntervalSec: 60,
|
DefaultIntervalSec: 1,
|
||||||
MinTimeoutSec: 1,
|
MinTimeoutSec: 1,
|
||||||
DefaultTimeoutSec: 5,
|
DefaultTimeoutSec: 5,
|
||||||
MaxTimeoutSec: 60,
|
MaxTimeoutSec: 60,
|
||||||
|
|||||||
@@ -675,9 +675,9 @@ func (NodeMetric) TableName() string { return "node_metric" }
|
|||||||
|
|
||||||
type TunnelMetric struct {
|
type TunnelMetric struct {
|
||||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
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"`
|
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;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"`
|
NodeID int64 `gorm:"column:node_id;not null;uniqueIndex: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"`
|
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"`
|
BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"`
|
||||||
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
|
BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"`
|
||||||
Connections int64 `gorm:"column:connections" json:"connections"`
|
Connections int64 `gorm:"column:connections" json:"connections"`
|
||||||
@@ -714,3 +714,20 @@ type ServiceMonitorResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
|
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" }
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -51,6 +53,7 @@ type NodeMetric = model.NodeMetric
|
|||||||
type TunnelMetric = model.TunnelMetric
|
type TunnelMetric = model.TunnelMetric
|
||||||
type ServiceMonitor = model.ServiceMonitor
|
type ServiceMonitor = model.ServiceMonitor
|
||||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||||
|
type TunnelQuality = model.TunnelQuality
|
||||||
|
|
||||||
// ─── Repository ──────────────────────────────────────────────────────
|
// ─── Repository ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -191,6 +194,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
|||||||
&model.TunnelMetric{},
|
&model.TunnelMetric{},
|
||||||
&model.ServiceMonitor{},
|
&model.ServiceMonitor{},
|
||||||
&model.ServiceMonitorResult{},
|
&model.ServiceMonitorResult{},
|
||||||
|
&model.TunnelQuality{},
|
||||||
}
|
}
|
||||||
|
|
||||||
if db.Dialector.Name() != "sqlite" {
|
if db.Dialector.Name() != "sqlite" {
|
||||||
@@ -675,9 +679,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
|
|||||||
"version": nullableString(n.Version),
|
"version": nullableString(n.Version),
|
||||||
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
|
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
|
||||||
"status": n.Status, "isRemote": n.IsRemote,
|
"status": n.Status, "isRemote": n.IsRemote,
|
||||||
"remoteUrl": nullableString(n.RemoteURL),
|
"remoteUrl": nullableString(n.RemoteURL),
|
||||||
"remoteToken": nullableString(n.RemoteToken),
|
"remoteToken": nullableString(n.RemoteToken),
|
||||||
"remoteConfig": nullableString(n.RemoteConfig),
|
"remoteConfig": nullableString(n.RemoteConfig),
|
||||||
|
"expiryReminderDismissed": n.ExpiryReminderDismissed,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return items, nil
|
return items, nil
|
||||||
@@ -3238,7 +3243,7 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ip != "" {
|
if ip != "" {
|
||||||
pair := fmt.Sprintf("%s:%d", ip, row.Port.Int64)
|
pair := formatForwardIngressAddress(ip, row.Port.Int64)
|
||||||
if _, ok := seenPairs[pair]; !ok {
|
if _, ok := seenPairs[pair]; !ok {
|
||||||
seenPairs[pair] = struct{}{}
|
seenPairs[pair] = struct{}{}
|
||||||
entries = append(entries, pair)
|
entries = append(entries, pair)
|
||||||
@@ -3255,6 +3260,17 @@ func resolveForwardIngress(db *gorm.DB, forwardID int64, tunnelID int64) (string
|
|||||||
return strings.Join(entries, ","), inPort, nil
|
return strings.Join(entries, ","), inPort, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func formatForwardIngressAddress(host string, port int64) string {
|
||||||
|
host = strings.TrimSpace(host)
|
||||||
|
if host == "" || port <= 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(host, "[") && strings.HasSuffix(host, "]") {
|
||||||
|
host = strings.TrimPrefix(strings.TrimSuffix(host, "]"), "[")
|
||||||
|
}
|
||||||
|
return net.JoinHostPort(host, strconv.FormatInt(port, 10))
|
||||||
|
}
|
||||||
|
|
||||||
func nullableString(v sql.NullString) interface{} {
|
func nullableString(v sql.NullString) interface{} {
|
||||||
if v.Valid {
|
if v.Valid {
|
||||||
return v.String
|
return v.String
|
||||||
@@ -3317,15 +3333,60 @@ func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model
|
|||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
rangeMs := endMs - startMs
|
||||||
|
const maxRawRangeMs = int64(60 * 60 * 1000) // 1 hour — return raw data for short ranges
|
||||||
|
const targetPoints = 500 // target number of chart points for downsampled data
|
||||||
|
|
||||||
|
// For short ranges, return raw data (full resolution).
|
||||||
|
if rangeMs <= maxRawRangeMs {
|
||||||
|
var metrics []model.NodeMetric
|
||||||
|
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||||
|
Order("timestamp ASC").
|
||||||
|
Limit(5000).
|
||||||
|
Find(&metrics).Error
|
||||||
|
return metrics, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// For longer ranges, downsample via SQL aggregation to keep the response small and fast.
|
||||||
|
bucketMs := rangeMs / targetPoints
|
||||||
|
if bucketMs < 1000 {
|
||||||
|
bucketMs = 1000 // minimum 1-second buckets
|
||||||
|
}
|
||||||
|
|
||||||
|
bucketExpr := fmt.Sprintf("(timestamp / %d * %d)", bucketMs, bucketMs)
|
||||||
|
groupExpr := fmt.Sprintf("timestamp / %d", bucketMs)
|
||||||
|
|
||||||
var metrics []model.NodeMetric
|
var metrics []model.NodeMetric
|
||||||
err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
err := r.db.Model(&model.NodeMetric{}).
|
||||||
Order("timestamp DESC").
|
Select(
|
||||||
Limit(5000).
|
fmt.Sprintf(
|
||||||
Find(&metrics).Error
|
"%d AS node_id, "+
|
||||||
if len(metrics) > 1 {
|
"CAST(%s AS BIGINT) AS timestamp, "+
|
||||||
for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 {
|
"AVG(cpu_usage) AS cpu_usage, "+
|
||||||
metrics[i], metrics[j] = metrics[j], metrics[i]
|
"AVG(mem_usage) AS mem_usage, "+
|
||||||
}
|
"AVG(disk_usage) AS disk_usage, "+
|
||||||
|
"CAST(AVG(net_in_bytes) AS BIGINT) AS net_in_bytes, "+
|
||||||
|
"CAST(AVG(net_out_bytes) AS BIGINT) AS net_out_bytes, "+
|
||||||
|
"CAST(AVG(net_in_speed) AS BIGINT) AS net_in_speed, "+
|
||||||
|
"CAST(AVG(net_out_speed) AS BIGINT) AS net_out_speed, "+
|
||||||
|
"AVG(load1) AS load1, "+
|
||||||
|
"AVG(load5) AS load5, "+
|
||||||
|
"AVG(load15) AS load15, "+
|
||||||
|
"CAST(AVG(tcp_conns) AS BIGINT) AS tcp_conns, "+
|
||||||
|
"CAST(AVG(udp_conns) AS BIGINT) AS udp_conns, "+
|
||||||
|
"CAST(MAX(uptime) AS BIGINT) AS uptime",
|
||||||
|
nodeID, bucketExpr,
|
||||||
|
),
|
||||||
|
).
|
||||||
|
Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs).
|
||||||
|
Group(groupExpr).
|
||||||
|
Order("timestamp ASC").
|
||||||
|
Limit(targetPoints + 100). // safety margin
|
||||||
|
Scan(&metrics).Error
|
||||||
|
|
||||||
|
if metrics == nil {
|
||||||
|
metrics = make([]model.NodeMetric, 0)
|
||||||
}
|
}
|
||||||
return metrics, err
|
return metrics, err
|
||||||
}
|
}
|
||||||
@@ -3419,10 +3480,10 @@ func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) er
|
|||||||
return r.db.Clauses(clause.OnConflict{
|
return r.db.Clauses(clause.OnConflict{
|
||||||
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}},
|
||||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||||
"bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"),
|
"bytes_in": gorm.Expr("tunnel_metric.bytes_in + excluded.bytes_in"),
|
||||||
"bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"),
|
"bytes_out": gorm.Expr("tunnel_metric.bytes_out + excluded.bytes_out"),
|
||||||
"connections": gorm.Expr("connections + excluded.connections"),
|
"connections": gorm.Expr("tunnel_metric.connections + excluded.connections"),
|
||||||
"errors": gorm.Expr("errors + excluded.errors"),
|
"errors": gorm.Expr("tunnel_metric.errors + excluded.errors"),
|
||||||
// avg_latency_ms is not additive; keep the existing bucket value.
|
// avg_latency_ms is not additive; keep the existing bucket value.
|
||||||
}),
|
}),
|
||||||
}).CreateInBatches(rows, 100).Error
|
}).CreateInBatches(rows, 100).Error
|
||||||
@@ -3566,6 +3627,25 @@ func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]mod
|
|||||||
return results, err
|
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.
|
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
|
||||||
// This is intended for list rendering (avoid N+1 queries).
|
// This is intended for list rendering (avoid N+1 queries).
|
||||||
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
|
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
|
||||||
|
|||||||
@@ -80,6 +80,37 @@ func (r *Repository) ListActiveForwardsByUserTunnel(userID, tunnelID int64) ([]m
|
|||||||
return rows, nil
|
return rows, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *Repository) ListForwardsByUserAndTunnel(userID, tunnelID int64) ([]model.ForwardRecord, error) {
|
||||||
|
if r == nil || r.db == nil {
|
||||||
|
return nil, errors.New("repository not initialized")
|
||||||
|
}
|
||||||
|
var forwards []model.Forward
|
||||||
|
err := r.db.Where("user_id = ? AND tunnel_id = ?", userID, tunnelID).Order("id ASC").Find(&forwards).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows := make([]model.ForwardRecord, 0, len(forwards))
|
||||||
|
for _, f := range forwards {
|
||||||
|
rows = append(rows, model.ForwardRecord{
|
||||||
|
ID: f.ID,
|
||||||
|
UserID: f.UserID,
|
||||||
|
UserName: f.UserName,
|
||||||
|
Name: f.Name,
|
||||||
|
TunnelID: f.TunnelID,
|
||||||
|
RemoteAddr: f.RemoteAddr,
|
||||||
|
Strategy: f.Strategy,
|
||||||
|
Status: f.Status,
|
||||||
|
SpeedID: f.SpeedID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for i := range rows {
|
||||||
|
if strings.TrimSpace(rows[i].Strategy) == "" {
|
||||||
|
rows[i].Strategy = "fifo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
|
func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, error) {
|
||||||
if r == nil || r.db == nil {
|
if r == nil || r.db == nil {
|
||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
|
|||||||
@@ -11,7 +11,8 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
|
|||||||
return nil, errors.New("repository not initialized")
|
return nil, errors.New("repository not initialized")
|
||||||
}
|
}
|
||||||
var nodes []model.Node
|
var nodes []model.Node
|
||||||
err := r.db.Select("id", "inx", "name", "status", "updated_time").
|
err := r.db.Select("id", "inx", "name", "status", "version", "updated_time").
|
||||||
|
Where("is_remote = ?", 0).
|
||||||
Order("inx ASC, id ASC").
|
Order("inx ASC, id ASC").
|
||||||
Find(&nodes).Error
|
Find(&nodes).Error
|
||||||
return nodes, err
|
return nodes, err
|
||||||
|
|||||||
@@ -997,9 +997,16 @@ func (r *Repository) DeleteGroupPermissionByIDTx(tx *gorm.DB, id int64) error {
|
|||||||
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
|
return tx.Where("id = ?", id).Delete(&model.GroupPermission{}).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) error {
|
// RevokedUserTunnelPair holds the (userID, tunnelID) of a deleted user_tunnel row,
|
||||||
|
// so the handler layer can clean up associated forwarding rules.
|
||||||
|
type RevokedUserTunnelPair struct {
|
||||||
|
UserID int64
|
||||||
|
TunnelID int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID int64, previousUserIDs, currentUserIDs []int64) ([]RevokedUserTunnelPair, error) {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return errors.New("database unavailable")
|
return nil, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
currentSet := make(map[int64]struct{}, len(currentUserIDs))
|
||||||
for _, uid := range currentUserIDs {
|
for _, uid := range currentUserIDs {
|
||||||
@@ -1018,7 +1025,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(removedUserIDs) == 0 {
|
if len(removedUserIDs) == 0 {
|
||||||
return nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
type grantRow struct {
|
type grantRow struct {
|
||||||
@@ -1026,6 +1033,8 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
|||||||
CreatedByGroup int
|
CreatedByGroup int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var revoked []RevokedUserTunnelPair
|
||||||
|
|
||||||
for _, userID := range removedUserIDs {
|
for _, userID := range removedUserIDs {
|
||||||
var rows []grantRow
|
var rows []grantRow
|
||||||
if err := tx.Model(&model.GroupPermissionGrant{}).
|
if err := tx.Model(&model.GroupPermissionGrant{}).
|
||||||
@@ -1033,7 +1042,7 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
|||||||
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
|
Joins("JOIN user_tunnel ON user_tunnel.id = group_permission_grant.user_tunnel_id").
|
||||||
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
|
Where("group_permission_grant.user_group_id = ? AND user_tunnel.user_id = ?", userGroupID, userID).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return err
|
return revoked, err
|
||||||
}
|
}
|
||||||
|
|
||||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||||
@@ -1046,28 +1055,32 @@ func (r *Repository) RevokeGroupGrantsForRemovedUsersTx(tx *gorm.DB, userGroupID
|
|||||||
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
|
userTunnelIDs := tx.Model(&model.UserTunnel{}).Select("id").Where("user_id = ?", userID)
|
||||||
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
|
if err := tx.Where("user_group_id = ? AND user_tunnel_id IN (?)", userGroupID, userTunnelIDs).
|
||||||
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
||||||
return err
|
return revoked, err
|
||||||
}
|
}
|
||||||
|
|
||||||
for userTunnelID := range groupCreatedTunnelIDs {
|
for userTunnelID := range groupCreatedTunnelIDs {
|
||||||
var remaining int64
|
var remaining int64
|
||||||
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
||||||
return err
|
return revoked, err
|
||||||
}
|
}
|
||||||
if remaining == 0 {
|
if remaining == 0 {
|
||||||
|
var ut model.UserTunnel
|
||||||
|
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
|
||||||
|
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
|
||||||
|
}
|
||||||
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||||
return err
|
return revoked, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return revoked, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) error {
|
func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunnelGroupID int64) ([]RevokedUserTunnelPair, error) {
|
||||||
if tx == nil {
|
if tx == nil {
|
||||||
return errors.New("database unavailable")
|
return nil, errors.New("database unavailable")
|
||||||
}
|
}
|
||||||
|
|
||||||
type grantRow struct {
|
type grantRow struct {
|
||||||
@@ -1080,7 +1093,7 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
|
|||||||
Select("user_tunnel_id, created_by_group").
|
Select("user_tunnel_id, created_by_group").
|
||||||
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
||||||
Find(&rows).Error; err != nil {
|
Find(&rows).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
groupCreatedTunnelIDs := make(map[int64]struct{})
|
groupCreatedTunnelIDs := make(map[int64]struct{})
|
||||||
@@ -1092,22 +1105,27 @@ func (r *Repository) RevokeGroupPermissionPairTx(tx *gorm.DB, userGroupID, tunne
|
|||||||
|
|
||||||
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
if err := tx.Where("user_group_id = ? AND tunnel_group_id = ?", userGroupID, tunnelGroupID).
|
||||||
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
Delete(&model.GroupPermissionGrant{}).Error; err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var revoked []RevokedUserTunnelPair
|
||||||
for userTunnelID := range groupCreatedTunnelIDs {
|
for userTunnelID := range groupCreatedTunnelIDs {
|
||||||
var remaining int64
|
var remaining int64
|
||||||
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
if err := tx.Model(&model.GroupPermissionGrant{}).Where("user_tunnel_id = ?", userTunnelID).Count(&remaining).Error; err != nil {
|
||||||
return err
|
return revoked, err
|
||||||
}
|
}
|
||||||
if remaining == 0 {
|
if remaining == 0 {
|
||||||
|
var ut model.UserTunnel
|
||||||
|
if lookupErr := tx.Select("user_id", "tunnel_id").Where("id = ?", userTunnelID).First(&ut).Error; lookupErr == nil {
|
||||||
|
revoked = append(revoked, RevokedUserTunnelPair{UserID: ut.UserID, TunnelID: ut.TunnelID})
|
||||||
|
}
|
||||||
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
if err := tx.Where("id = ?", userTunnelID).Delete(&model.UserTunnel{}).Error; err != nil {
|
||||||
return err
|
return revoked, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return revoked, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
|
func (r *Repository) ReplaceFederationTunnelBindingsTx(tx *gorm.DB, tunnelID int64, bindings []FederationTunnelBinding) error {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIssue349_ForwardListFormatsIPv6EntryAddressesContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
adminToken := mustAdminToken(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "issue349-tunnel", 1.0, 1, "tcp", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, "issue349-tunnel")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "issue349-entry-node-a", "entry-secret-a", "2001:db8::10", "", "2001:db8::10", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node a: %v", err)
|
||||||
|
}
|
||||||
|
nodeAID := mustLastInsertID(t, repo, "issue349-entry-node-a")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "issue349-entry-node-b", "entry-secret-b", "2001:db8::30", "", "2001:db8::30", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 1).Error; err != nil {
|
||||||
|
t.Fatalf("insert node b: %v", err)
|
||||||
|
}
|
||||||
|
nodeBID := mustLastInsertID(t, repo, "issue349-entry-node-b")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, ?)
|
||||||
|
`, 1, "admin_user", "issue349-forward", tunnelID, "1.1.1.1:443", "fifo", now, now, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, repo, "issue349-forward")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardID, nodeAID, 32001).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port a: %v", err)
|
||||||
|
}
|
||||||
|
if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port, in_ip) VALUES(?, ?, ?, ?)`, forwardID, nodeBID, 32002, "2001:db8::20").Error; err != nil {
|
||||||
|
t.Fatalf("insert forward_port b: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := requestContractEnvelope(t, router, adminToken, "/api/v1/forward/list", nil)
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("forward list failed: code=%d msg=%q", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows := mustContractSlice(t, out.Data, "forward list data")
|
||||||
|
var target map[string]interface{}
|
||||||
|
for _, row := range rows {
|
||||||
|
item, ok := row.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if contractValueAsInt64(item["id"]) == forwardID {
|
||||||
|
target = item
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if target == nil {
|
||||||
|
t.Fatalf("target forward %d not found in /forward/list response", forwardID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := contractValueAsString(target["inIp"]); got != "[2001:db8::10]:32001,[2001:db8::20]:32002" {
|
||||||
|
t.Fatalf("expected bracketed IPv6 entry list, got %q", got)
|
||||||
|
}
|
||||||
|
if got := contractValueAsInt64(target["inPort"]); got != 32001 {
|
||||||
|
t.Fatalf("expected first entry port 32001, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1175,7 +1175,7 @@ func TestMetricBatchInsert(t *testing.T) {
|
|||||||
t.Fatalf("batch insert: %v", err)
|
t.Fatalf("batch insert: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
retrieved, err := repo.GetNodeMetrics(1, 0, now+1000)
|
retrieved, err := repo.GetNodeMetrics(1, now-10000, now+1000)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("get metrics: %v", err)
|
t.Fatalf("get metrics: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,9 @@
|
|||||||
# GO-GOST SERVICE KNOWLEDGE BASE
|
# 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
|
## OVERVIEW
|
||||||
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
|
||||||
|
|||||||
+3
-1
@@ -119,7 +119,9 @@ func main() {
|
|||||||
log := xlogger.NewLogger()
|
log := xlogger.NewLogger()
|
||||||
logger.SetDefault(log)
|
logger.SetDefault(log)
|
||||||
|
|
||||||
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, version)
|
distro := socket.DetectDistro()
|
||||||
|
fullVersion := fmt.Sprintf("%s (%s/%s)", version, distro, runtime.GOARCH)
|
||||||
|
wsReporter := socket.StartWebSocketReporterWithConfig(config.Addr, config.Secret, config.Http, config.Tls, config.Socks, fullVersion)
|
||||||
defer wsReporter.Stop()
|
defer wsReporter.Stop()
|
||||||
service.SetHTTPReportURL(config.Addr, config.Secret)
|
service.SetHTTPReportURL(config.Addr, config.Secret)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# GO-GOST/X KNOWLEDGE BASE
|
# GO-GOST/X KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## 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.
|
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
|
# GO-GOST/X API KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Gin-based management API for reading/writing config and controlling services at runtime.
|
Gin-based management API for reading/writing config and controlling services at runtime.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
# GO-GOST/X CONFIG KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## 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.
|
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
|
# GOST CONNECTOR KNOWLEDGE BASE
|
||||||
|
|
||||||
**Generated:** Fri Feb 13 2026
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Connection initiators (clients) for various protocols in GOST forwarding.
|
Connection initiators (clients) for various protocols in GOST forwarding.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
# GO-GOST/X DIALERS KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
Outbound dialers (client-side connection establishment) used by connectors/handlers.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
# GO-GOST/X HANDLERS KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
Protocol handlers (server-side request handling) used by services defined in the GOST config.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
# GO-GOST/X LISTENERS KNOWLEDGE BASE
|
||||||
|
|
||||||
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# GO-GOST REGISTRY KNOWLEDGE BASE
|
# 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
|
## OVERVIEW
|
||||||
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
|
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# GOST SOCKET KNOWLEDGE BASE
|
# GOST SOCKET KNOWLEDGE BASE
|
||||||
|
|
||||||
**Generated:** Sun Feb 15 2026
|
**Generated:** Fri Mar 20 2026
|
||||||
|
**Commit:** f45f960
|
||||||
|
**Branch:** main
|
||||||
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
WebSocket reporter and socket utilities for panel integration.
|
WebSocket reporter and socket utilities for panel integration.
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package socket
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/shirou/gopsutil/v3/host"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectDistro returns the Linux distribution name (e.g. "ubuntu", "centos",
|
||||||
|
// "debian"). Falls back to "linux" when detection fails.
|
||||||
|
func DetectDistro() string {
|
||||||
|
info, err := host.Info()
|
||||||
|
if err != nil || info == nil {
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
platform := strings.ToLower(strings.TrimSpace(info.Platform))
|
||||||
|
if platform == "" {
|
||||||
|
return "linux"
|
||||||
|
}
|
||||||
|
return platform
|
||||||
|
}
|
||||||
@@ -190,7 +190,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
|
|||||||
return &WebSocketReporter{
|
return &WebSocketReporter{
|
||||||
url: serverURL,
|
url: serverURL,
|
||||||
curBackoff: initialBackoff, // 当前退避间隔
|
curBackoff: initialBackoff, // 当前退避间隔
|
||||||
pingInterval: 5 * time.Second, // 指标上报间隔
|
pingInterval: 1 * time.Second, // 指标上报间隔(每秒采集)
|
||||||
configInterval: 10 * time.Minute, // 配置上报间隔
|
configInterval: 10 * time.Minute, // 配置上报间隔
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
cancel: cancel,
|
cancel: cancel,
|
||||||
@@ -348,7 +348,7 @@ func buildWebSocketCandidates(addr string, secret string, version string, http i
|
|||||||
normalizedAddr = strings.TrimSpace(addr)
|
normalizedAddr = strings.TrimSpace(addr)
|
||||||
}
|
}
|
||||||
|
|
||||||
query := "/system-info?type=1&secret=" + secret + "&version=" + version +
|
query := "/system-info?type=1&secret=" + url.QueryEscape(secret) + "&version=" + url.QueryEscape(version) +
|
||||||
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
"&http=" + strconv.Itoa(http) + "&tls=" + strconv.Itoa(tls) + "&socks=" + strconv.Itoa(socks)
|
||||||
|
|
||||||
schemes := []string{"wss", "ws"}
|
schemes := []string{"wss", "ws"}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
# 013 - 隧道入口端口校验不严格修复
|
||||||
|
|
||||||
|
## Issue
|
||||||
|
- GitHub Issue: [#373](https://github.com/Sagit-chu/flvx/issues/373)
|
||||||
|
|
||||||
|
## 修复方案
|
||||||
|
|
||||||
|
在 `syncTunnelForwardsEntryPorts` 中实现逐节点端口分配:
|
||||||
|
|
||||||
|
- **旧入口节点**:保留原端口不变
|
||||||
|
- **新入口节点**:通过 `resolvePortForNewEntryNode` 决策:
|
||||||
|
- 参考端口在范围内且未被占用 → 跟随设置一样的端口
|
||||||
|
- 参考端口超出范围或被占用 → 通过 `pickRandomPortForNode` 为该节点单独随机分配
|
||||||
|
|
||||||
|
## 任务清单
|
||||||
|
|
||||||
|
- [x] 1. 实现 `pickRandomPortForNode` 辅助方法(单节点端口随机分配)
|
||||||
|
- [x] 2. 实现 `resolvePortForNewEntryNode` 方法(端口决策逻辑)
|
||||||
|
- [x] 3. 重写 `syncTunnelForwardsEntryPorts` 为逐节点分配
|
||||||
|
- [x] 4. 移除不再需要的 `isPortValidForAllEntryNodes`
|
||||||
|
- [x] 5. 构建通过 + 全量测试通过
|
||||||
@@ -0,0 +1,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秒上报"
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Plan 061: Node Logo By OS Type (Linux Distro)
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Display different logos for nodes in the 'Monitor - Node - Card/List View' based on their Linux distribution (Ubuntu, Debian, CentOS, Alpine, etc.).
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Agent: Use `gopsutil/v3/host.Info().Platform` to detect the Linux distro and include it in the version string (`distro.go`).
|
||||||
|
- [x] Agent: Update `main.go` to call `socket.DetectDistro()` instead of `runtime.GOOS`.
|
||||||
|
- [x] Agent: Ensure version parameter is URL-escaped since it now includes distro info with special chars.
|
||||||
|
- [x] Backend: Select `version` column in `ListMonitorNodes`.
|
||||||
|
- [x] Backend: Include `version` in `monitorNodeListItem` JSON response.
|
||||||
|
- [x] Frontend: Add `version` to TypeScript interfaces (`MonitorNodeApiItem`, `MonitorNode`, `MonitorViewProps`).
|
||||||
|
- [x] Frontend: Create `distro-icon.tsx` component with SVG logos for Ubuntu, Debian, CentOS/Rocky/Alma, Alpine, Fedora, Arch/Manjaro, and a default Linux (Tux) fallback.
|
||||||
|
- [x] Frontend: Use `DistroIcon` in `ServerCard` (card view) and list view name column with branded colors per distro.
|
||||||
|
- [x] All three projects compile cleanly (`go build`, `tsc --noEmit`).
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
# Plan 062: Commit, PR, Merge and Tag
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This plan outlines the steps to commit all changes, create a PR, merge it, and then publish a new tag for the FLVX project.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [ ] Check current git status for any unexpected changes
|
||||||
|
- [ ] Create a feature branch `feat-node-os-logo-release`
|
||||||
|
- [ ] Stage and commit all modifications and untracked files
|
||||||
|
- [ ] Push the feature branch to origin
|
||||||
|
- [ ] Create a Pull Request (PR) from the feature branch to `main`
|
||||||
|
- [ ] Merge the PR to `main`
|
||||||
|
- [ ] Update `AGENTS.md` with the new tag and commit hash
|
||||||
|
- [ ] Create and push new tag `2.1.9-beta14`
|
||||||
|
|
||||||
|
## Detailed Steps
|
||||||
|
|
||||||
|
### 1. Create Feature Branch
|
||||||
|
```bash
|
||||||
|
git checkout -b feat-node-os-logo-release
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Commit all changes
|
||||||
|
Add all modified and untracked files:
|
||||||
|
```bash
|
||||||
|
git add .
|
||||||
|
git commit -m "feat: node OS logo support, UI rate overlap fix and tunnel monitoring updates"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Push and PR
|
||||||
|
Push to `origin`:
|
||||||
|
```bash
|
||||||
|
git push origin feat-node-os-logo-release
|
||||||
|
```
|
||||||
|
Create PR via `gh pr create` if possible.
|
||||||
|
|
||||||
|
### 4. Merge to Main
|
||||||
|
```bash
|
||||||
|
git checkout main
|
||||||
|
git merge feat-node-os-logo-release
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Create Tag
|
||||||
|
Increment the current tag `2.1.9-beta13` to `2.1.9-beta14`.
|
||||||
|
```bash
|
||||||
|
git tag 2.1.9-beta14
|
||||||
|
git push origin 2.1.9-beta14
|
||||||
|
```
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Plan 063: Release 2.1.9-rc6
|
||||||
|
|
||||||
|
Sync all changes, bump version to `2.1.9-rc6`, create PR, merge, and publish tag.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc6`) and today's date (`Tue Mar 24 2026`).
|
||||||
|
- [x] Commit all changes to branch `chore/rc6-bump`.
|
||||||
|
- [x] Push branch to remote.
|
||||||
|
- [x] Create Pull Request using `gh`.
|
||||||
|
- [x] Merge Pull Request using `gh`.
|
||||||
|
- [x] Create and push tag `2.1.9-rc6`.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Plan 064: Release 2.1.9-rc7
|
||||||
|
|
||||||
|
Sync all changes, bump version to `2.1.9-rc7`, create PR, merge, and publish tag.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
- [x] Update `AGENTS.md` with new tag (`2.1.9-rc7`) and today's date (`Tue Mar 24 2026`).
|
||||||
|
- [x] Commit all changes to branch `chore/rc7-bump`.
|
||||||
|
- [x] Push branch to remote.
|
||||||
|
- [x] Create Pull Request using `gh`.
|
||||||
|
- [x] Merge Pull Request using `gh`.
|
||||||
|
- [x] Create and push tag `2.1.9-rc7`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Fix Node Metrics PostgreSQL Type Encoding
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Fix the PostgreSQL type encoding error (`failed to encode args[0]: unable to encode 5 into text format for text (OID 25)`) and `integer out of range` error when querying node metrics for time ranges greater than 1 hour.
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
- [x] Identify the problematic downsampled SQL aggregation in `GetNodeMetrics`.
|
||||||
|
- [x] Fix the `? AS node_id` placeholder which confused PostgreSQL's type inference by directly embedding the `nodeID` using `fmt.Sprintf("%d AS node_id")`.
|
||||||
|
- [x] Change all `CAST(X AS INTEGER)` to `CAST(X AS BIGINT)` to prevent 32-bit integer overflow on Unix millisecond timestamps in PostgreSQL.
|
||||||
|
- [x] Verify the build and tests pass.
|
||||||
|
- [ ] Commit all changes, create a new branch, push, create a Pull Request, merge the PR into `main`, and publish a new tag `2.1.9-rc9`.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 066 - Issue 349 IPv6 Entry Format
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
- 修复规则入口 IPv6 地址在列表/复制场景下缺少方括号的问题,确保 API 返回与前端展示都能直接使用 `[IPv6]:port` 格式。
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
- [x] 定位规则入口地址的生成链路,确认问题来自后端入口地址拼接格式。
|
||||||
|
- [x] 修复 IPv6 入口地址拼接逻辑,统一输出可直接复制的标准格式。
|
||||||
|
- [x] 增加回归验证,覆盖 `/api/v1/forward/list` 的 IPv6 入口地址格式。
|
||||||
|
- [x] 运行相关测试并确认通过。
|
||||||
|
- [ ] 提交修复分支、创建 PR,并合并到 `main`。
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 067 - Issue #342: Allow Tunnel Edit with Offline Nodes
|
||||||
|
|
||||||
|
**Issue:** https://github.com/Sagit-chu/flvx/issues/342
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
When a node goes offline, users cannot edit tunnel configurations at all — including removing the faulty offline node. This creates a deadlock where users must wait for the offline node to recover or manually edit the database.
|
||||||
|
|
||||||
|
## Changes Required
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
- [x] 1. **`prepareTunnelCreateState`** (`mutations.go:2800`): Split the offline check into two modes:
|
||||||
|
- **Create (excludeTunnelID == 0)**: Keep current behavior — reject any offline non-remote node.
|
||||||
|
- **Update (excludeTunnelID > 0)**: Only reject **newly added** offline non-remote nodes. Allow existing offline nodes to remain (they'll be removed or kept). Query existing chain_tunnel records to determine which nodes are "old".
|
||||||
|
|
||||||
|
- [x] 2. **`syncForwardServicesWithWarnings`** (`control_plane.go:231`): When a node is offline (sendNodeCommand fails with "节点不在线"), skip it and add a warning instead of returning a hard error. This allows forward rule modifications to succeed partially.
|
||||||
|
|
||||||
|
- [x] 3. **`applyTunnelRuntime`** (`mutations.go:3190`): For non-remote local entry nodes, treat offline errors as deferrable (like remote nodes) so tunnel updates don't fail entirely when some nodes are offline.
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
- [x] 4. **`validateTunnelForm`** (`tunnel/form.ts`): Change validation to only block adding NEW offline nodes. When editing, offline nodes that are being removed should not block submission. Add isEdit parameter to distinguish create vs. edit.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@flvx/skill-api",
|
"name": "@flvx/skill-api",
|
||||||
"version": "2.1.5",
|
"version": "2.1.9-rc4",
|
||||||
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
|
"description": "Skill for AI assistants to operate FLVX panel via REST API. Supports OpenCode, OpenClaw, Claude Code.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"opencode",
|
"opencode",
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
# VITE FRONTEND KNOWLEDGE BASE
|
# VITE FRONTEND KNOWLEDGE BASE
|
||||||
|
|
||||||
**Generated:** Thu Feb 26 2026
|
**Generated:** Fri Mar 20 2026
|
||||||
**Commit:** 21008cc
|
**Commit:** f45f960
|
||||||
**Branch:** main
|
**Branch:** main
|
||||||
**Tag:** 2.1.5-rc15
|
**Tag:** 2.1.9-beta6
|
||||||
|
|
||||||
## OVERVIEW
|
## OVERVIEW
|
||||||
Web management console for FLVX.
|
Web management console for FLVX.
|
||||||
|
|||||||
@@ -85,11 +85,22 @@
|
|||||||
shortcutIconLink.removeAttribute('type');
|
shortcutIconLink.removeAttribute('type');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 立即检测系统主题并应用
|
// 立即检测主题并应用(优先读取 localStorage 中的用户偏好)
|
||||||
const isDarkMode = window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches;
|
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.classList.add('dark');
|
||||||
document.documentElement.style.colorScheme = 'dark';
|
document.documentElement.style.colorScheme = 'dark';
|
||||||
} else {
|
} else {
|
||||||
@@ -97,9 +108,13 @@
|
|||||||
document.documentElement.style.colorScheme = 'light';
|
document.documentElement.style.colorScheme = 'light';
|
||||||
}
|
}
|
||||||
|
|
||||||
// 监听系统主题变化
|
// 仅在"跟随系统"模式下监听系统主题变化
|
||||||
if (window.matchMedia) {
|
if ((!savedMode || savedMode === 'system') && window.matchMedia) {
|
||||||
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function(e) {
|
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) {
|
if (e.matches) {
|
||||||
document.documentElement.classList.add('dark');
|
document.documentElement.classList.add('dark');
|
||||||
document.documentElement.style.colorScheme = 'dark';
|
document.documentElement.style.colorScheme = 'dark';
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import type {
|
|||||||
MonitorTunnelApiItem,
|
MonitorTunnelApiItem,
|
||||||
MonitorPermissionApiItem,
|
MonitorPermissionApiItem,
|
||||||
MonitorAccessApiData,
|
MonitorAccessApiData,
|
||||||
|
TunnelQualityApiItem,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
@@ -426,6 +427,7 @@ export const getNodeMetrics = (
|
|||||||
return Network.get<NodeMetricApiItem[]>(
|
return Network.get<NodeMetricApiItem[]>(
|
||||||
`/monitor/nodes/${nodeId}/metrics`,
|
`/monitor/nodes/${nodeId}/metrics`,
|
||||||
params,
|
params,
|
||||||
|
{ timeout: 60_000 },
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -451,6 +453,25 @@ export const getTunnelMetrics = (
|
|||||||
export const getMonitorTunnels = () =>
|
export const getMonitorTunnels = () =>
|
||||||
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
|
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 = () =>
|
export const getServiceMonitorList = () =>
|
||||||
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
|
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
|
||||||
|
|
||||||
@@ -466,11 +487,22 @@ export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) =>
|
|||||||
export const deleteServiceMonitor = (id: number) =>
|
export const deleteServiceMonitor = (id: number) =>
|
||||||
Network.post("/monitor/services/delete", { id });
|
Network.post("/monitor/services/delete", { id });
|
||||||
|
|
||||||
export const getServiceMonitorResults = (monitorId: number, limit = 100) =>
|
export const getServiceMonitorResults = (
|
||||||
Network.get<ServiceMonitorResultApiItem[]>(
|
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`,
|
`/monitor/services/${monitorId}/results`,
|
||||||
{ limit: String(limit) },
|
params,
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const getServiceMonitorLatestResults = () =>
|
export const getServiceMonitorLatestResults = () =>
|
||||||
Network.get<ServiceMonitorResultApiItem[]>(
|
Network.get<ServiceMonitorResultApiItem[]>(
|
||||||
|
|||||||
@@ -466,6 +466,7 @@ export interface MonitorNodeApiItem {
|
|||||||
inx: number;
|
inx: number;
|
||||||
name: string;
|
name: string;
|
||||||
status: number;
|
status: number;
|
||||||
|
version?: string;
|
||||||
updatedTime: number;
|
updatedTime: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,3 +488,14 @@ export interface MonitorAccessApiData {
|
|||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TunnelQualityApiItem {
|
||||||
|
tunnelId: number;
|
||||||
|
entryToExitLatency: number;
|
||||||
|
exitToBingLatency: number;
|
||||||
|
entryToExitLoss: number;
|
||||||
|
exitToBingLoss: number;
|
||||||
|
success: boolean;
|
||||||
|
errorMessage?: string;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 {
|
interface ThemeProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||||
const { theme, setTheme } = useTheme();
|
return <ThemeContextProvider>{children}</ThemeContextProvider>;
|
||||||
|
|
||||||
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}</>;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
type AnnouncementData,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { BackIcon, SettingsIcon } from "@/components/icons";
|
import { BackIcon, SettingsIcon } from "@/components/icons";
|
||||||
|
import { ThemeSettings } from "@/components/theme-settings";
|
||||||
import { isAdmin } from "@/utils/auth";
|
import { isAdmin } from "@/utils/auth";
|
||||||
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
import { getCachedConfigs, configCache, updateSiteConfig } from "@/config/site";
|
||||||
import {
|
import {
|
||||||
@@ -123,6 +124,12 @@ const CONFIG_ITEMS: ConfigItem[] = [
|
|||||||
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
|
description: "开启后,规则页面列表使用 2.1.6-alpha8 样式(全局配置)",
|
||||||
type: "switch",
|
type: "switch",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "monitor_tunnel_quality_enabled",
|
||||||
|
label: "实时隧道质量检测",
|
||||||
|
description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
|
||||||
|
type: "switch",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "captcha_enabled",
|
key: "captcha_enabled",
|
||||||
label: "启用验证码",
|
label: "启用验证码",
|
||||||
@@ -174,6 +181,7 @@ const getInitialConfigs = (): Record<string, string> => {
|
|||||||
"cloudflare_site_key",
|
"cloudflare_site_key",
|
||||||
"cloudflare_secret_key",
|
"cloudflare_secret_key",
|
||||||
"forward_compact_mode",
|
"forward_compact_mode",
|
||||||
|
"monitor_tunnel_quality_enabled",
|
||||||
"ip",
|
"ip",
|
||||||
"panel_domain",
|
"panel_domain",
|
||||||
"app_logo",
|
"app_logo",
|
||||||
@@ -406,6 +414,15 @@ export default function ConfigPage() {
|
|||||||
detail: { changedKeys },
|
detail: { changedKeys },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// 如果隧道质量检测开关变更,通知 tunnel-monitor-view
|
||||||
|
if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
|
||||||
|
window.dispatchEvent(
|
||||||
|
new CustomEvent("monitorTunnelQualityEnabledChanged", {
|
||||||
|
detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
toast.error("保存配置失败: " + response.msg);
|
toast.error("保存配置失败: " + response.msg);
|
||||||
}
|
}
|
||||||
@@ -979,6 +996,11 @@ export default function ConfigPage() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* 主题设置 */}
|
||||||
|
<div className="mt-6">
|
||||||
|
<ThemeSettings />
|
||||||
|
</div>
|
||||||
|
|
||||||
{hasChanges && (
|
{hasChanges && (
|
||||||
<Card className="mt-4 bg-warning-50 dark:bg-warning-900/20 border-warning-200 dark:border-warning-800 shadow-sm overflow-hidden">
|
<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">
|
<div className="h-10 flex items-center justify-center gap-2 text-warning-700 dark:text-warning-300">
|
||||||
|
|||||||
@@ -18,6 +18,15 @@ import {
|
|||||||
ModalFooter,
|
ModalFooter,
|
||||||
} from "@/shadcn-bridge/heroui/modal";
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
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 {
|
import {
|
||||||
createSpeedLimit,
|
createSpeedLimit,
|
||||||
getSpeedLimitList,
|
getSpeedLimitList,
|
||||||
@@ -51,6 +60,10 @@ export default function LimitPage() {
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
||||||
|
"limit-view-mode",
|
||||||
|
"grid",
|
||||||
|
);
|
||||||
|
|
||||||
const filteredRules = useMemo(() => {
|
const filteredRules = useMemo(() => {
|
||||||
if (!searchKeyword.trim()) return rules;
|
if (!searchKeyword.trim()) return rules;
|
||||||
@@ -231,13 +244,85 @@ export default function LimitPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
|
<div className="flex items-center gap-2">
|
||||||
新增
|
<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" variant="flat" onPress={handleAdd}>
|
||||||
|
新增
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 统一卡片网格 */}
|
{/* 统一卡片网格 */}
|
||||||
{filteredRules.length > 0 ? (
|
{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">
|
<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) => (
|
{filteredRules.map((rule) => (
|
||||||
<StaggerItem key={rule.id}>
|
<StaggerItem key={rule.id}>
|
||||||
@@ -324,6 +409,7 @@ export default function LimitPage() {
|
|||||||
</StaggerItem>
|
</StaggerItem>
|
||||||
))}
|
))}
|
||||||
</StaggerList>
|
</StaggerList>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type MonitorNode = {
|
|||||||
id: number;
|
id: number;
|
||||||
name: string;
|
name: string;
|
||||||
connectionStatus: "online" | "offline";
|
connectionStatus: "online" | "offline";
|
||||||
|
version?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type MonitorTab = "nodes" | "tunnels";
|
type MonitorTab = "nodes" | "tunnels";
|
||||||
@@ -73,6 +74,7 @@ export default function MonitorPage() {
|
|||||||
id: Number(n.id),
|
id: Number(n.id),
|
||||||
name: String(n.name ?? ""),
|
name: String(n.name ?? ""),
|
||||||
connectionStatus: n.status === 1 ? "online" : "offline",
|
connectionStatus: n.status === 1 ? "online" : "offline",
|
||||||
|
version: n.version,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
|
return new Map<number, MonitorNode>(list.map((n) => [n.id, n]));
|
||||||
|
|||||||
@@ -609,6 +609,8 @@ export default function NodePage() {
|
|||||||
|
|
||||||
if (!metric || typeof metric !== "object") return node;
|
if (!metric || typeof metric !== "object") return node;
|
||||||
|
|
||||||
|
const incomingUptime = metric.uptime ?? 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...node,
|
...node,
|
||||||
connectionStatus: "online",
|
connectionStatus: "online",
|
||||||
@@ -620,7 +622,7 @@ export default function NodePage() {
|
|||||||
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
|
downloadTraffic: metric.netInBytes ?? metric.bytes_received ?? 0,
|
||||||
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
|
uploadSpeed: metric.netOutSpeed ?? metric.net_out_speed ?? 0,
|
||||||
downloadSpeed: metric.netInSpeed ?? metric.net_in_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,
|
diskUsage: metric.diskUsage ?? metric.disk_usage,
|
||||||
load1: metric.load1,
|
load1: metric.load1,
|
||||||
load5: metric.load5,
|
load5: metric.load5,
|
||||||
@@ -1407,13 +1409,6 @@ export default function NodePage() {
|
|||||||
if (!nodeList || nodeList.length === 0) return [];
|
if (!nodeList || nodeList.length === 0) return [];
|
||||||
|
|
||||||
const sortedByDb = [...nodeList].sort((a, b) => {
|
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 aInx = a.inx ?? 0;
|
||||||
const bInx = b.inx ?? 0;
|
const bInx = b.inx ?? 0;
|
||||||
|
|
||||||
@@ -1768,7 +1763,15 @@ export default function NodePage() {
|
|||||||
/>
|
/>
|
||||||
) : viewMode === "list" ? (
|
) : viewMode === "list" ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
|
<Table
|
||||||
|
aria-label="节点列表"
|
||||||
|
className="overflow-x-auto min-w-full"
|
||||||
|
classNames={{
|
||||||
|
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||||
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
|
tr: "hover:bg-default-50/50 transition-colors",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -1946,7 +1949,7 @@ export default function NodePage() {
|
|||||||
{(listeners) => (
|
{(listeners) => (
|
||||||
<Card
|
<Card
|
||||||
key={node.id}
|
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"
|
data-node-card="true"
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3 md:pb-3">
|
<CardHeader className="pb-3 md:pb-3">
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
ServiceMonitorLimitsApiData,
|
ServiceMonitorLimitsApiData,
|
||||||
} from "@/api/types";
|
} from "@/api/types";
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
LineChart,
|
LineChart,
|
||||||
Line,
|
Line,
|
||||||
@@ -27,9 +27,13 @@ import {
|
|||||||
Server,
|
Server,
|
||||||
Clock,
|
Clock,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
Eye,
|
Eye,
|
||||||
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
|
import { DistroIcon, parseDistroFromVersion, getDistroColor } from "@/components/distro-icon";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
getNodeMetrics,
|
getNodeMetrics,
|
||||||
@@ -73,7 +77,7 @@ import { Progress } from "@/shadcn-bridge/heroui/progress";
|
|||||||
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
import { useNodeRealtime } from "@/pages/node/use-node-realtime";
|
||||||
|
|
||||||
interface MonitorViewProps {
|
interface MonitorViewProps {
|
||||||
nodeMap: Map<number, { id: number; name: string; connectionStatus: string }>;
|
nodeMap: Map<number, { id: number; name: string; connectionStatus: string; version?: string }>;
|
||||||
viewMode?: "list" | "grid";
|
viewMode?: "list" | "grid";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,6 +169,8 @@ const getColorByUsage = (usage?: number) => {
|
|||||||
|
|
||||||
function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) {
|
function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNodeMetric | null; onPress?: () => void }) {
|
||||||
const isOnline = node.connectionStatus === "online";
|
const isOnline = node.connectionStatus === "online";
|
||||||
|
const distro = parseDistroFromVersion(node.version);
|
||||||
|
const distroColor = getDistroColor(distro);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
@@ -181,7 +187,7 @@ function ServerCard({ node, metric, onPress }: { node: any; metric: RealtimeNode
|
|||||||
<div className="flex items-center gap-3 min-w-0">
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
<div className="relative flex-shrink-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">
|
<div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
|
||||||
<Server className={`w-5 h-5 ${isOnline ? "text-success" : "text-danger"}`} />
|
<DistroIcon distro={distro} className="w-5 h-5" style={{ color: isOnline ? distroColor : undefined }} />
|
||||||
</div>
|
</div>
|
||||||
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
|
<span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||||
</div>
|
</div>
|
||||||
@@ -263,10 +269,147 @@ type MetricType =
|
|||||||
|
|
||||||
const METRICS_MAX_ROWS = 5000;
|
const METRICS_MAX_ROWS = 5000;
|
||||||
|
|
||||||
|
/* ─── Memoized Node Metrics Chart sub-component ─────────────────── */
|
||||||
|
|
||||||
|
interface NodeMetricsChartCardProps {
|
||||||
|
rangeMs: number;
|
||||||
|
onRangeChange: (v: number) => void;
|
||||||
|
activeMetricType: MetricType;
|
||||||
|
onMetricTypeChange: (t: MetricType) => void;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
truncated: boolean;
|
||||||
|
maxRows: number;
|
||||||
|
data: Array<Record<string, unknown>>;
|
||||||
|
nodeId: number | null;
|
||||||
|
onRefresh: (id: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const METRIC_TYPE_BUTTONS: { key: MetricType; label: string }[] = [
|
||||||
|
{ key: "cpu", label: "CPU" },
|
||||||
|
{ key: "memory", label: "内存" },
|
||||||
|
{ key: "disk", label: "磁盘" },
|
||||||
|
{ key: "network", label: "网络" },
|
||||||
|
{ key: "load", label: "负载" },
|
||||||
|
{ key: "connections", label: "连接" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const NodeMetricsChartCard = React.memo(function NodeMetricsChartCard({
|
||||||
|
rangeMs, onRangeChange, activeMetricType, onMetricTypeChange,
|
||||||
|
loading, error, truncated, maxRows, data, nodeId, onRefresh,
|
||||||
|
}: NodeMetricsChartCardProps) {
|
||||||
|
const chartConfig = (() => {
|
||||||
|
switch (activeMetricType) {
|
||||||
|
case "cpu": return { lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }], yAxisLabel: "使用率 (%)" };
|
||||||
|
case "memory": return { lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }], yAxisLabel: "使用率 (%)" };
|
||||||
|
case "disk": return { lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }], yAxisLabel: "使用率 (%)" };
|
||||||
|
case "network": return { lines: [{ dataKey: "netIn", color: "#10b981", name: "入站速度" }, { dataKey: "netOut", color: "#ef4444", name: "出站速度" }], yAxisLabel: "速度 (bytes/s)" };
|
||||||
|
case "load": return { lines: [{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" }, { dataKey: "load5", color: "#8b5cf6", name: "负载 5m" }, { dataKey: "load15", color: "#f59e0b", name: "负载 15m" }], yAxisLabel: "负载值" };
|
||||||
|
case "connections": return { lines: [{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" }, { dataKey: "udp", color: "#10b981", name: "UDP 连接" }], yAxisLabel: "连接数" };
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
const yAxisTickFormatter = (value: unknown) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "";
|
||||||
|
switch (activeMetricType) {
|
||||||
|
case "network": return formatBytesPerSecond(n);
|
||||||
|
case "cpu": case "memory": case "disk": return `${n.toFixed(0)}%`;
|
||||||
|
case "load": return n.toFixed(1);
|
||||||
|
case "connections": return String(Math.round(n));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const tooltipFormatter = (value: unknown) => {
|
||||||
|
const n = Number(value);
|
||||||
|
if (!Number.isFinite(n)) return "-";
|
||||||
|
switch (activeMetricType) {
|
||||||
|
case "network": return formatBytesPerSecond(n);
|
||||||
|
case "cpu": case "memory": case "disk": return `${n.toFixed(1)}%`;
|
||||||
|
case "load": return n.toFixed(2);
|
||||||
|
case "connections": return String(Math.round(n));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
|
<h3 className="text-lg font-semibold">节点指标图表</h3>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Select
|
||||||
|
className="w-36"
|
||||||
|
selectedKeys={[String(rangeMs)]}
|
||||||
|
onSelectionChange={(keys) => {
|
||||||
|
const v = Number(Array.from(keys)[0]);
|
||||||
|
if (v > 0) onRangeChange(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
|
||||||
|
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
|
||||||
|
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
|
||||||
|
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
|
||||||
|
</Select>
|
||||||
|
<Button isLoading={loading} size="sm" variant="flat" onPress={() => nodeId && onRefresh(nodeId)}>
|
||||||
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardBody className="space-y-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{METRIC_TYPE_BUTTONS.map((item) => (
|
||||||
|
<Button
|
||||||
|
key={item.key}
|
||||||
|
color={activeMetricType === item.key ? "primary" : "default"}
|
||||||
|
size="sm"
|
||||||
|
variant={activeMetricType === item.key ? "solid" : "flat"}
|
||||||
|
onPress={() => onMetricTypeChange(item.key)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||||
|
) : error ? (
|
||||||
|
<div className="text-center py-8 text-danger text-sm">{error}</div>
|
||||||
|
) : data.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="h-64">
|
||||||
|
<ResponsiveContainer height="100%" width="100%">
|
||||||
|
<LineChart data={data}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis dataKey="time" fontSize={12} />
|
||||||
|
<YAxis fontSize={12} tickFormatter={yAxisTickFormatter} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||||
|
labelStyle={{ color: "#fff" }}
|
||||||
|
formatter={tooltipFormatter}
|
||||||
|
/>
|
||||||
|
{chartConfig.lines.map((line) => (
|
||||||
|
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{truncated && (
|
||||||
|
<div className="text-xs text-default-500">数据点过多,已截断为最近 {maxRows} 条,建议缩小时间范围。</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
||||||
|
)}
|
||||||
|
</CardBody>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
|
const DEFAULT_SERVICE_MONITOR_LIMITS: ServiceMonitorLimitsApiData = {
|
||||||
checkerScanIntervalSec: 30,
|
checkerScanIntervalSec: 1,
|
||||||
minIntervalSec: 30,
|
minIntervalSec: 1,
|
||||||
defaultIntervalSec: 60,
|
defaultIntervalSec: 1,
|
||||||
minTimeoutSec: 1,
|
minTimeoutSec: 1,
|
||||||
defaultTimeoutSec: 5,
|
defaultTimeoutSec: 5,
|
||||||
maxTimeoutSec: 60,
|
maxTimeoutSec: 60,
|
||||||
@@ -314,7 +457,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
});
|
});
|
||||||
const [submitLoading, setSubmitLoading] = useState(false);
|
const [submitLoading, setSubmitLoading] = useState(false);
|
||||||
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
|
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
|
||||||
const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50);
|
const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000);
|
||||||
|
|
||||||
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||||
const [resultsModalOpen, setResultsModalOpen] = useState(false);
|
const [resultsModalOpen, setResultsModalOpen] = useState(false);
|
||||||
@@ -386,27 +529,31 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
|
|
||||||
const metric = raw as Record<string, unknown>;
|
const metric = raw as Record<string, unknown>;
|
||||||
const receivedAt = Date.now();
|
const receivedAt = Date.now();
|
||||||
const normalized: RealtimeNodeMetric = {
|
const incomingUptime = Number(metric.uptime ?? 0);
|
||||||
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),
|
|
||||||
};
|
|
||||||
|
|
||||||
setRealtimeNodeMetrics((prev) => ({
|
setRealtimeNodeMetrics((prev) => {
|
||||||
...prev,
|
const normalized: RealtimeNodeMetric = {
|
||||||
[nodeId]: normalized,
|
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) => ({
|
setRealtimeNodeStatus((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[nodeId]: "online",
|
[nodeId]: "online",
|
||||||
@@ -465,8 +612,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMetricsTruncated(false);
|
setMetricsTruncated(false);
|
||||||
setMetricsError(response.msg || "加载指标失败");
|
const msg = response.msg || "加载指标失败";
|
||||||
if (!silent) toast.error(response.msg || "加载指标失败");
|
const isTimeout = msg.toLowerCase().includes("timeout");
|
||||||
|
const friendlyMsg = isTimeout ? "加载指标超时,请缩小时间范围后重试" : msg;
|
||||||
|
setMetricsError(friendlyMsg);
|
||||||
|
if (!silent) toast.error(friendlyMsg);
|
||||||
} catch {
|
} catch {
|
||||||
setMetricsTruncated(false);
|
setMetricsTruncated(false);
|
||||||
if (!silent) setMetricsError("加载指标失败");
|
if (!silent) setMetricsError("加载指标失败");
|
||||||
@@ -526,9 +676,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadMonitorResults = useCallback(
|
const loadMonitorResults = useCallback(
|
||||||
async (monitorId: number, limit = 20) => {
|
async (monitorId: number, options?: { rangeMs?: number; limit?: number }) => {
|
||||||
try {
|
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) {
|
if (response.code === 0 && response.data) {
|
||||||
setMonitorResults((prev) => ({
|
setMonitorResults((prev) => ({
|
||||||
@@ -600,7 +753,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
if (!resultsMonitorId) return;
|
if (!resultsMonitorId) return;
|
||||||
setResultsLoading(true);
|
setResultsLoading(true);
|
||||||
try {
|
try {
|
||||||
await loadMonitorResults(resultsMonitorId, resultsLimit);
|
await loadMonitorResults(resultsMonitorId, { limit: resultsLimit });
|
||||||
} finally {
|
} finally {
|
||||||
setResultsLoading(false);
|
setResultsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -616,7 +769,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
const timer = window.setInterval(() => {
|
const timer = window.setInterval(() => {
|
||||||
void loadServiceMonitors({ silent: true });
|
void loadServiceMonitors({ silent: true });
|
||||||
void loadLatestMonitorResults();
|
void loadLatestMonitorResults();
|
||||||
}, 30_000);
|
}, 5_000);
|
||||||
|
|
||||||
return () => window.clearInterval(timer);
|
return () => window.clearInterval(timer);
|
||||||
}, [loadLatestMonitorResults, loadServiceMonitors]);
|
}, [loadLatestMonitorResults, loadServiceMonitors]);
|
||||||
@@ -641,13 +794,27 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
void loadResultsForModal();
|
void loadResultsForModal();
|
||||||
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
|
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
|
||||||
|
|
||||||
// Reload results for the active service monitor chart when range limit changes
|
// Auto-load results for the resolved default monitor when entering detail view
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detailNodeId) return;
|
||||||
|
if (activeServiceMonitorId) return; // user already selected one
|
||||||
|
// Find the first monitor belonging to this node (or panel-level)
|
||||||
|
const firstMonitor = serviceMonitors.find(
|
||||||
|
(m) => m.nodeId === detailNodeId || m.nodeId === 0,
|
||||||
|
);
|
||||||
|
if (firstMonitor && (!monitorResults[firstMonitor.id] || monitorResults[firstMonitor.id].length <= 1)) {
|
||||||
|
void loadMonitorResults(firstMonitor.id, { rangeMs: serviceMonitorRangeMs });
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [detailNodeId, serviceMonitors]);
|
||||||
|
|
||||||
|
// Reload results for the active service monitor chart when time range changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeServiceMonitorId) return;
|
if (!activeServiceMonitorId) return;
|
||||||
void loadMonitorResults(activeServiceMonitorId, serviceMonitorRangeLimit);
|
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
|
||||||
}, [activeServiceMonitorId, serviceMonitorRangeLimit, loadMonitorResults]);
|
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
|
||||||
|
|
||||||
const chartData = metrics.map((m) => ({
|
const chartData = useMemo(() => metrics.map((m) => ({
|
||||||
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
time: formatTimestamp(m.timestamp, metricsRangeMs),
|
||||||
cpu: m.cpuUsage,
|
cpu: m.cpuUsage,
|
||||||
memory: m.memoryUsage,
|
memory: m.memoryUsage,
|
||||||
@@ -659,52 +826,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
load15: m.load15,
|
load15: m.load15,
|
||||||
tcp: m.tcpConns,
|
tcp: m.tcpConns,
|
||||||
udp: m.udpConns,
|
udp: m.udpConns,
|
||||||
}));
|
})), [metrics, metricsRangeMs]);
|
||||||
|
|
||||||
const getChartConfig = () => {
|
|
||||||
switch (activeMetricType) {
|
|
||||||
case "cpu":
|
|
||||||
return {
|
|
||||||
lines: [{ dataKey: "cpu", color: "#3b82f6", name: "CPU %" }],
|
|
||||||
yAxisLabel: "使用率 (%)",
|
|
||||||
};
|
|
||||||
case "memory":
|
|
||||||
return {
|
|
||||||
lines: [{ dataKey: "memory", color: "#8b5cf6", name: "内存 %" }],
|
|
||||||
yAxisLabel: "使用率 (%)",
|
|
||||||
};
|
|
||||||
case "disk":
|
|
||||||
return {
|
|
||||||
lines: [{ dataKey: "disk", color: "#f59e0b", name: "磁盘 %" }],
|
|
||||||
yAxisLabel: "使用率 (%)",
|
|
||||||
};
|
|
||||||
case "network":
|
|
||||||
return {
|
|
||||||
lines: [
|
|
||||||
{ dataKey: "netIn", color: "#10b981", name: "入站速度" },
|
|
||||||
{ dataKey: "netOut", color: "#ef4444", name: "出站速度" },
|
|
||||||
],
|
|
||||||
yAxisLabel: "速度 (bytes/s)",
|
|
||||||
};
|
|
||||||
case "load":
|
|
||||||
return {
|
|
||||||
lines: [
|
|
||||||
{ dataKey: "load1", color: "#3b82f6", name: "负载 1m" },
|
|
||||||
{ dataKey: "load5", color: "#8b5cf6", name: "负载 5m" },
|
|
||||||
{ dataKey: "load15", color: "#f59e0b", name: "负载 15m" },
|
|
||||||
],
|
|
||||||
yAxisLabel: "负载值",
|
|
||||||
};
|
|
||||||
case "connections":
|
|
||||||
return {
|
|
||||||
lines: [
|
|
||||||
{ dataKey: "tcp", color: "#3b82f6", name: "TCP 连接" },
|
|
||||||
{ dataKey: "udp", color: "#10b981", name: "UDP 连接" },
|
|
||||||
],
|
|
||||||
yAxisLabel: "连接数",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -915,6 +1037,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
[resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec],
|
[resolvedServiceMonitorLimits.defaultIntervalSec, resolvedServiceMonitorLimits.minIntervalSec],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Backend batches DB writes every 30s, but latest API reads from in-memory cache.
|
||||||
|
// The cache timestamp reflects the real check time (every ~1s), so stale detection
|
||||||
|
// should still allow for the batch report interval + scan jitter.
|
||||||
|
const SERVICE_MONITOR_REPORT_INTERVAL_MS = 30_000; // matches backend serviceMonitorReportInterval
|
||||||
|
|
||||||
const isResultStale = useCallback(
|
const isResultStale = useCallback(
|
||||||
(monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => {
|
(monitor: ServiceMonitorApiItem, latestResult: ServiceMonitorResultApiItem | null) => {
|
||||||
if (monitor.enabled !== 1) {
|
if (monitor.enabled !== 1) {
|
||||||
@@ -925,8 +1052,9 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const intervalMs = resolveMonitorIntervalSec(monitor) * 1000;
|
const intervalMs = resolveMonitorIntervalSec(monitor) * 1000;
|
||||||
|
// Budget = batch report interval + one check interval + scan jitter + grace
|
||||||
const budgetMs =
|
const budgetMs =
|
||||||
intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
|
SERVICE_MONITOR_REPORT_INTERVAL_MS + intervalMs + resolvedServiceMonitorLimits.checkerScanIntervalSec * 1000 + 5000;
|
||||||
|
|
||||||
return Date.now() - latestResult.timestamp > budgetMs;
|
return Date.now() - latestResult.timestamp > budgetMs;
|
||||||
},
|
},
|
||||||
@@ -966,46 +1094,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
return { disabled, ok, fail, unknown, stale };
|
return { disabled, ok, fail, unknown, stale };
|
||||||
}, [getLatestResult, isResultStale, serviceMonitors]);
|
}, [getLatestResult, isResultStale, serviceMonitors]);
|
||||||
|
|
||||||
const chartConfig = getChartConfig();
|
|
||||||
|
|
||||||
const nodeYAxisTickFormatter = (value: unknown) => {
|
|
||||||
const n = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isFinite(n)) return "";
|
|
||||||
|
|
||||||
switch (activeMetricType) {
|
|
||||||
case "network":
|
|
||||||
return formatBytesPerSecond(n);
|
|
||||||
case "cpu":
|
|
||||||
case "memory":
|
|
||||||
case "disk":
|
|
||||||
return `${n.toFixed(0)}%`;
|
|
||||||
case "load":
|
|
||||||
return n.toFixed(1);
|
|
||||||
case "connections":
|
|
||||||
return String(Math.round(n));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const nodeTooltipFormatter = (value: unknown) => {
|
|
||||||
const n = Number(value);
|
|
||||||
|
|
||||||
if (!Number.isFinite(n)) return "-";
|
|
||||||
|
|
||||||
switch (activeMetricType) {
|
|
||||||
case "network":
|
|
||||||
return formatBytesPerSecond(n);
|
|
||||||
case "cpu":
|
|
||||||
case "memory":
|
|
||||||
case "disk":
|
|
||||||
return `${n.toFixed(1)}%`;
|
|
||||||
case "load":
|
|
||||||
return n.toFixed(2);
|
|
||||||
case "connections":
|
|
||||||
return String(Math.round(n));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
|
const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null;
|
||||||
@@ -1065,7 +1153,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Card className="w-full">
|
<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>
|
<TableHeader>
|
||||||
<TableColumn>状态</TableColumn>
|
<TableColumn>状态</TableColumn>
|
||||||
<TableColumn>名称</TableColumn>
|
<TableColumn>名称</TableColumn>
|
||||||
@@ -1084,30 +1180,45 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
const isOnline = node.connectionStatus === "online";
|
const isOnline = node.connectionStatus === "online";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={node.id} className="border-b border-divider/50 last:border-b-0">
|
<TableRow key={node.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
|
<div className="flex items-center gap-2">
|
||||||
|
<DistroIcon distro={parseDistroFromVersion(node.version)} className="w-4 h-4 flex-shrink-0" style={{ color: isOnline ? getDistroColor(parseDistroFromVersion(node.version)) : undefined }} />
|
||||||
|
<span className="font-semibold text-sm whitespace-nowrap">{node.name}</span>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
|
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
|
||||||
<div className="flex items-center gap-1 font-mono text-success-500">
|
<div className="flex items-center gap-1.5 font-mono text-success-500">
|
||||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span> ↑
|
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netOutSpeed) : "-"}</span>
|
||||||
|
<div className="flex items-center justify-center p-[3px] rounded-full bg-success-50 dark:bg-success-500/10 text-success-500">
|
||||||
|
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 font-mono text-primary-500">
|
<div className="flex items-center gap-1.5 font-mono text-primary-500">
|
||||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span> ↓
|
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytesPerSecond(metric.netInSpeed) : "-"}</span>
|
||||||
|
<div className="flex items-center justify-center p-[3px] rounded-full bg-primary-50 dark:bg-primary-500/10 text-primary-500">
|
||||||
|
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex flex-col gap-1.5 text-xs whitespace-nowrap">
|
<div className="flex flex-col gap-2 py-1 text-xs whitespace-nowrap">
|
||||||
<div className="flex items-center gap-1 font-mono text-default-600">
|
<div className="flex items-center gap-1.5 font-mono text-default-600">
|
||||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span> ↑
|
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netOutBytes) : "-"}</span>
|
||||||
|
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
|
||||||
|
<ArrowUp className="w-3 h-3" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 font-mono text-default-600">
|
<div className="flex items-center gap-1.5 font-mono text-default-600">
|
||||||
<span className="w-[60px] text-right">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span> ↓
|
<span className="w-[86px] text-right inline-block">{isOnline && metric ? formatBytes(metric.netInBytes) : "-"}</span>
|
||||||
|
<div className="flex items-center justify-center p-[3px] rounded-full bg-default-100 text-default-500 dark:bg-default-100/50">
|
||||||
|
<ArrowDown className="w-3 h-3" strokeWidth={2.5} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -1223,83 +1334,19 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Node metrics chart */}
|
{/* Node metrics chart */}
|
||||||
<Card>
|
<NodeMetricsChartCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between">
|
rangeMs={metricsRangeMs}
|
||||||
<h3 className="text-lg font-semibold">节点指标图表</h3>
|
onRangeChange={setMetricsRangeMs}
|
||||||
<div className="flex items-center gap-2">
|
activeMetricType={activeMetricType}
|
||||||
<Select
|
onMetricTypeChange={setActiveMetricType}
|
||||||
className="w-36"
|
loading={metricsLoading}
|
||||||
selectedKeys={[String(metricsRangeMs)]}
|
error={metricsError}
|
||||||
onSelectionChange={(keys) => {
|
truncated={metricsTruncated}
|
||||||
const v = Number(Array.from(keys)[0]);
|
maxRows={METRICS_MAX_ROWS}
|
||||||
if (v > 0) setMetricsRangeMs(v);
|
data={chartData}
|
||||||
}}
|
nodeId={selectedNodeId}
|
||||||
>
|
onRefresh={loadMetrics}
|
||||||
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
|
/>
|
||||||
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
|
|
||||||
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
|
|
||||||
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
|
|
||||||
</Select>
|
|
||||||
<Button isLoading={metricsLoading} size="sm" variant="flat" onPress={() => selectedNodeId && loadMetrics(selectedNodeId)}>
|
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardBody className="space-y-4">
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{([
|
|
||||||
{ key: "cpu", label: "CPU" },
|
|
||||||
{ key: "memory", label: "内存" },
|
|
||||||
{ key: "disk", label: "磁盘" },
|
|
||||||
{ key: "network", label: "网络" },
|
|
||||||
{ key: "load", label: "负载" },
|
|
||||||
{ key: "connections", label: "连接" },
|
|
||||||
] as { key: MetricType; label: string }[]).map((item) => (
|
|
||||||
<Button
|
|
||||||
key={item.key}
|
|
||||||
color={activeMetricType === item.key ? "primary" : "default"}
|
|
||||||
size="sm"
|
|
||||||
variant={activeMetricType === item.key ? "solid" : "flat"}
|
|
||||||
onPress={() => setActiveMetricType(item.key)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{metricsLoading ? (
|
|
||||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
|
||||||
) : metricsError ? (
|
|
||||||
<div className="text-center py-8 text-danger text-sm">{metricsError}</div>
|
|
||||||
) : metrics.length > 0 ? (
|
|
||||||
<>
|
|
||||||
<div className="h-64">
|
|
||||||
<ResponsiveContainer height="100%" width="100%">
|
|
||||||
<LineChart data={chartData}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" />
|
|
||||||
<XAxis dataKey="time" fontSize={12} />
|
|
||||||
<YAxis fontSize={12} tickFormatter={nodeYAxisTickFormatter} />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
|
||||||
labelStyle={{ color: "#fff" }}
|
|
||||||
formatter={nodeTooltipFormatter}
|
|
||||||
/>
|
|
||||||
{chartConfig.lines.map((line) => (
|
|
||||||
<Line key={line.dataKey} dataKey={line.dataKey} dot={false} name={line.name} stroke={line.color} strokeWidth={2} type="monotone" />
|
|
||||||
))}
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</div>
|
|
||||||
{metricsTruncated && (
|
|
||||||
<div className="text-xs text-default-500">数据点过多,已截断为最近 {METRICS_MAX_ROWS} 条,建议缩小时间范围。</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
|
||||||
)}
|
|
||||||
</CardBody>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
|
|
||||||
{/* Service monitors chart – same style as node metrics */}
|
{/* Service monitors chart – same style as node metrics */}
|
||||||
@@ -1314,10 +1361,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
|
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
|
||||||
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
|
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
|
||||||
const activeLatencyData = [...activeResults]
|
const activeLatencyData = [...activeResults]
|
||||||
.slice(0, serviceMonitorRangeLimit)
|
|
||||||
.reverse()
|
|
||||||
.map((r) => ({
|
.map((r) => ({
|
||||||
time: formatTimestamp(r.timestamp),
|
time: formatTimestamp(r.timestamp, serviceMonitorRangeMs),
|
||||||
latency: r.success === 1 ? r.latencyMs : null,
|
latency: r.success === 1 ? r.latencyMs : null,
|
||||||
success: r.success,
|
success: r.success,
|
||||||
}));
|
}));
|
||||||
@@ -1328,24 +1373,23 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
<h3 className="text-lg font-semibold">服务监控图表</h3>
|
<h3 className="text-lg font-semibold">服务监控图表</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Select
|
<Select
|
||||||
className="w-28"
|
className="w-36"
|
||||||
selectedKeys={[String(serviceMonitorRangeLimit)]}
|
selectedKeys={[String(serviceMonitorRangeMs)]}
|
||||||
onSelectionChange={(keys) => {
|
onSelectionChange={(keys) => {
|
||||||
const v = Number(Array.from(keys)[0]);
|
const v = Number(Array.from(keys)[0]);
|
||||||
if (v > 0) setServiceMonitorRangeLimit(v);
|
if (v > 0) setServiceMonitorRangeMs(v);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectItem key="20">20条</SelectItem>
|
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
|
||||||
<SelectItem key="50">50条</SelectItem>
|
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
|
||||||
<SelectItem key="100">100条</SelectItem>
|
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
|
||||||
<SelectItem key="200">200条</SelectItem>
|
|
||||||
</Select>
|
</Select>
|
||||||
{resolvedActiveMonitorId != null && (
|
{resolvedActiveMonitorId != null && (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="flat"
|
variant="flat"
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
void loadMonitorResults(resolvedActiveMonitorId, serviceMonitorRangeLimit);
|
void loadMonitorResults(resolvedActiveMonitorId, { rangeMs: serviceMonitorRangeMs });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RefreshCw className="w-4 h-4 mr-1" />
|
<RefreshCw className="w-4 h-4 mr-1" />
|
||||||
@@ -1383,7 +1427,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
setActiveServiceMonitorId(monitor.id);
|
setActiveServiceMonitorId(monitor.id);
|
||||||
// Load results for this monitor if not loaded
|
// Load results for this monitor if not loaded
|
||||||
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
|
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
|
||||||
void loadMonitorResults(monitor.id, serviceMonitorRangeLimit);
|
void loadMonitorResults(monitor.id, { rangeMs: serviceMonitorRangeMs });
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -1404,7 +1448,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
<div className="flex items-center gap-3 min-w-0 flex-wrap">
|
||||||
<Chip size="sm" color="primary" variant="flat">{resolvedActiveMonitor.type.toUpperCase()}</Chip>
|
<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="font-mono text-xs text-default-500">{resolvedActiveMonitor.target}</span>
|
||||||
<span className="text-xs text-default-500">间隔 {resolvedActiveMonitor.intervalSec}s</span>
|
<span className="text-xs text-default-500">每秒测试,30秒上报</span>
|
||||||
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
|
{activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? (
|
||||||
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
|
<span className="font-mono text-xs font-semibold text-success">{activeLatestResult.latencyMs.toFixed(0)}ms</span>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -1536,7 +1580,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
|
|||||||
<RefreshCw className="w-6 h-6 animate-spin" />
|
<RefreshCw className="w-6 h-6 animate-spin" />
|
||||||
</div>
|
</div>
|
||||||
) : modalResults.length > 0 ? (
|
) : 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>
|
<TableHeader>
|
||||||
<TableColumn>时间</TableColumn>
|
<TableColumn>时间</TableColumn>
|
||||||
<TableColumn>结果</TableColumn>
|
<TableColumn>结果</TableColumn>
|
||||||
|
|||||||
@@ -48,13 +48,49 @@ const parseRawSystemInfo = (messageData: unknown): RawSystemInfo | null => {
|
|||||||
return 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 = (
|
export const buildNodeSystemInfo = (
|
||||||
messageData: unknown,
|
messageData: unknown,
|
||||||
previous: NodeSystemInfo | null | undefined,
|
previous: NodeSystemInfo | null | undefined,
|
||||||
): NodeSystemInfo | null => {
|
): NodeSystemInfo | null => {
|
||||||
const raw = parseRawSystemInfo(messageData);
|
const raw = parseRawSystemInfo(messageData);
|
||||||
|
|
||||||
if (!raw) {
|
if (!raw || !looksLikeSystemInfo(raw)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -32,6 +32,9 @@ interface PanelAddress {
|
|||||||
|
|
||||||
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
|
const FORWARD_COMPACT_MODE_CONFIG_KEY = "forward_compact_mode";
|
||||||
|
|
||||||
|
const parseBooleanConfig = (value: unknown, defaultValue: boolean) =>
|
||||||
|
typeof value === "string" ? value === "true" : defaultValue;
|
||||||
|
|
||||||
export const SettingsPage = () => {
|
export const SettingsPage = () => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
const [panelAddresses, setPanelAddresses] = useState<PanelAddress[]>([]);
|
||||||
@@ -50,9 +53,16 @@ export const SettingsPage = () => {
|
|||||||
setPanelAddresses(newAddress);
|
setPanelAddresses(newAddress);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete (window as any).setPanelAddresses;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
// 加载面板地址列表
|
// 加载面板地址列表
|
||||||
const loadPanelAddresses = async () => {
|
const loadPanelAddresses = async () => {
|
||||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
|
||||||
getPanelAddresses();
|
getPanelAddresses();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -72,7 +82,6 @@ export const SettingsPage = () => {
|
|||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
|
||||||
savePanelAddress(newName.trim(), newAddress.trim());
|
savePanelAddress(newName.trim(), newAddress.trim());
|
||||||
setNewName("");
|
setNewName("");
|
||||||
setNewAddress("");
|
setNewAddress("");
|
||||||
@@ -81,14 +90,12 @@ export const SettingsPage = () => {
|
|||||||
|
|
||||||
// 设置当前面板地址
|
// 设置当前面板地址
|
||||||
const setCurrentPanel = async (name: string) => {
|
const setCurrentPanel = async (name: string) => {
|
||||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
|
||||||
setCurrentPanelAddress(name);
|
setCurrentPanelAddress(name);
|
||||||
reinitializeBaseURL();
|
reinitializeBaseURL();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 删除面板地址
|
// 删除面板地址
|
||||||
const handleDeletePanelAddress = async (name: string) => {
|
const handleDeletePanelAddress = async (name: string) => {
|
||||||
(window as any).setPanelAddresses = setPanelAddressesFunc;
|
|
||||||
deletePanelAddress(name);
|
deletePanelAddress(name);
|
||||||
reinitializeBaseURL();
|
reinitializeBaseURL();
|
||||||
toast.success("删除成功");
|
toast.success("删除成功");
|
||||||
@@ -103,12 +110,7 @@ export const SettingsPage = () => {
|
|||||||
const loadForwardCompactMode = async () => {
|
const loadForwardCompactMode = async () => {
|
||||||
try {
|
try {
|
||||||
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
|
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
|
||||||
const enabled =
|
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
|
||||||
res.code === 0 &&
|
|
||||||
typeof res.data?.value === "string" &&
|
|
||||||
res.data.value === "true";
|
|
||||||
|
|
||||||
setForwardCompactMode(enabled);
|
|
||||||
} catch {
|
} catch {
|
||||||
setForwardCompactMode(false);
|
setForwardCompactMode(false);
|
||||||
}
|
}
|
||||||
@@ -216,28 +218,30 @@ export const SettingsPage = () => {
|
|||||||
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
|
||||||
显示设置
|
显示设置
|
||||||
</h2>
|
</h2>
|
||||||
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
|
||||||
<div>
|
<div className="flex items-center justify-between gap-4">
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
<div>
|
||||||
规则页面精简模式
|
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
</p>
|
规则页面精简模式
|
||||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
</p>
|
||||||
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
|
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
</p>
|
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
color="primary"
|
||||||
|
isDisabled={!admin || forwardCompactModeSaving}
|
||||||
|
isSelected={forwardCompactMode}
|
||||||
|
onValueChange={handleForwardCompactModeChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
{!admin && (
|
||||||
color="primary"
|
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
||||||
isDisabled={!admin || forwardCompactModeSaving}
|
仅管理员可修改该全局配置。
|
||||||
isSelected={forwardCompactMode}
|
</p>
|
||||||
onValueChange={handleForwardCompactModeChange}
|
)}
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{!admin && (
|
|
||||||
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
|
|
||||||
仅管理员可修改该全局配置。
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -45,6 +45,15 @@ import { Alert } from "@/shadcn-bridge/heroui/alert";
|
|||||||
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
|
||||||
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
import { Progress } from "@/shadcn-bridge/heroui/progress";
|
||||||
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
|
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 {
|
import {
|
||||||
createTunnel,
|
createTunnel,
|
||||||
batchDeleteTunnelsWithForwards,
|
batchDeleteTunnelsWithForwards,
|
||||||
@@ -181,6 +190,10 @@ export default function TunnelPage() {
|
|||||||
"",
|
"",
|
||||||
);
|
);
|
||||||
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
const [isSearchVisible, setIsSearchVisible] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
|
||||||
|
"tunnel-view-mode",
|
||||||
|
"grid",
|
||||||
|
);
|
||||||
|
|
||||||
// 模态框状态
|
// 模态框状态
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
@@ -405,7 +418,7 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
// 表单验证
|
// 表单验证
|
||||||
const validateForm = (): boolean => {
|
const validateForm = (): boolean => {
|
||||||
const newErrors = validateTunnelForm(form, nodes);
|
const newErrors = validateTunnelForm(form, nodes, isEdit);
|
||||||
|
|
||||||
setErrors(newErrors);
|
setErrors(newErrors);
|
||||||
|
|
||||||
@@ -1523,6 +1536,14 @@ export default function TunnelPage() {
|
|||||||
>
|
>
|
||||||
批量
|
批量
|
||||||
</Button>
|
</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
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -1556,6 +1577,131 @@ export default function TunnelPage() {
|
|||||||
|
|
||||||
{/* 隧道卡片网格 */}
|
{/* 隧道卡片网格 */}
|
||||||
{tunnels.length > 0 ? (
|
{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}>
|
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={sortableTunnelIds}
|
items={sortableTunnelIds}
|
||||||
@@ -1836,6 +1982,7 @@ export default function TunnelPage() {
|
|||||||
</div>
|
</div>
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export const createTunnelFormDefaults = () => {
|
|||||||
export const validateTunnelForm = (
|
export const validateTunnelForm = (
|
||||||
form: TunnelFormInput,
|
form: TunnelFormInput,
|
||||||
nodes: TunnelNodeInput[],
|
nodes: TunnelNodeInput[],
|
||||||
|
isEdit = false,
|
||||||
): Record<string, string> => {
|
): Record<string, string> => {
|
||||||
const errors: Record<string, string> = {};
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
@@ -44,7 +45,9 @@ export const validateTunnelForm = (
|
|||||||
|
|
||||||
if (!form.inNodeId || form.inNodeId.length === 0) {
|
if (!form.inNodeId || form.inNodeId.length === 0) {
|
||||||
errors.inNodeId = "请至少选择一个入口节点";
|
errors.inNodeId = "请至少选择一个入口节点";
|
||||||
} else {
|
} else if (!isEdit) {
|
||||||
|
// Only enforce online check for new tunnels. During edit the backend
|
||||||
|
// allows existing offline nodes (user may be removing them).
|
||||||
const offlineInNodes = form.inNodeId.filter((item) => {
|
const offlineInNodes = form.inNodeId.filter((item) => {
|
||||||
const node = nodes.find((n) => n.id === item.nodeId);
|
const node = nodes.find((n) => n.id === item.nodeId);
|
||||||
|
|
||||||
@@ -64,14 +67,16 @@ export const validateTunnelForm = (
|
|||||||
if (!form.outNodeId || form.outNodeId.length === 0) {
|
if (!form.outNodeId || form.outNodeId.length === 0) {
|
||||||
errors.outNodeId = "请至少选择一个出口节点";
|
errors.outNodeId = "请至少选择一个出口节点";
|
||||||
} else {
|
} else {
|
||||||
const offlineOutNodes = form.outNodeId.filter((item) => {
|
if (!isEdit) {
|
||||||
const node = nodes.find((n) => n.id === item.nodeId);
|
const offlineOutNodes = form.outNodeId.filter((item) => {
|
||||||
|
const node = nodes.find((n) => n.id === item.nodeId);
|
||||||
|
|
||||||
return node && node.status !== 1;
|
return node && node.status !== 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (offlineOutNodes.length > 0) {
|
if (offlineOutNodes.length > 0) {
|
||||||
errors.outNodeId = "所有出口节点必须在线";
|
errors.outNodeId = "所有出口节点必须在线";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
const inNodeIds = form.inNodeId.map((item) => item.nodeId);
|
||||||
|
|||||||
@@ -1080,7 +1080,15 @@ export default function UserPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
) : viewMode === "list" ? (
|
) : viewMode === "list" ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
|
<Table
|
||||||
|
aria-label="用户列表"
|
||||||
|
className="overflow-x-auto min-w-full"
|
||||||
|
classNames={{
|
||||||
|
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||||
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
|
tr: "hover:bg-default-50/50 transition-colors",
|
||||||
|
}}
|
||||||
|
>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableColumn>用户名</TableColumn>
|
<TableColumn>用户名</TableColumn>
|
||||||
<TableColumn>流量统计</TableColumn>
|
<TableColumn>流量统计</TableColumn>
|
||||||
@@ -1888,8 +1896,9 @@ export default function UserPage() {
|
|||||||
<Table
|
<Table
|
||||||
aria-label="用户隧道权限列表"
|
aria-label="用户隧道权限列表"
|
||||||
classNames={{
|
classNames={{
|
||||||
wrapper: "shadow-none",
|
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
|
||||||
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
|
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
|
||||||
|
tr: "hover:bg-default-50/50 transition-colors",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<TableHeader>
|
<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 {
|
// Monotonic counter for snapshot identity
|
||||||
if (typeof window === "undefined") {
|
let _rev = 0;
|
||||||
return "light";
|
const _sub = (cb: () => void) =>
|
||||||
}
|
subscribe(() => {
|
||||||
|
_rev++;
|
||||||
const fromStorage = window.localStorage.getItem(STORAGE_KEY);
|
cb();
|
||||||
|
|
||||||
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);
|
|
||||||
});
|
});
|
||||||
}
|
const _snap = () => _rev;
|
||||||
|
|
||||||
export function useTheme() {
|
export function useTheme() {
|
||||||
const [theme, setThemeState] = React.useState<ThemeMode>(currentTheme);
|
useSyncExternalStore(_sub, _snap);
|
||||||
|
|
||||||
React.useEffect(() => {
|
const theme = getEffectiveMode();
|
||||||
const listener = (nextTheme: ThemeMode) => {
|
const mode = getSavedMode();
|
||||||
setThemeState(nextTheme);
|
|
||||||
};
|
|
||||||
|
|
||||||
listeners.add(listener);
|
const setTheme = useCallback((next: string) => {
|
||||||
|
if (next !== "dark" && next !== "light" && next !== "system") return;
|
||||||
return () => {
|
saveMode(next as ThemeMode);
|
||||||
listeners.delete(listener);
|
reapplyActiveTheme();
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setTheme = React.useCallback((nextTheme: string) => {
|
return { theme, mode, setTheme };
|
||||||
if (nextTheme !== "dark" && nextTheme !== "light") {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
broadcast(nextTheme);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return {
|
|
||||||
setTheme,
|
|
||||||
theme,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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