Compare commits

...

14 Commits

Author SHA1 Message Date
sagit 32e338d295 fix: include missing field in repository (#358)
Included missing field in repository.
2026-03-20 22:20:15 +08:00
sagitchu f6a753baa3 fix: include missing field in repository 2026-03-20 22:18:45 +08:00
sagit 322a10bb9d chore: release 2.1.9-beta7 (#357)
Sync current changes to 2.1.9-beta7.
2026-03-20 22:18:17 +08:00
sagitchu 27c13d6c47 chore: release 2.1.9-beta7 2026-03-20 22:15:51 +08:00
sagit f45f96063a fix: hide remote nodes from monitor view (#356)
Filter out nodes with is_remote=1 from the monitor API endpoint, as per
user requirement.
2026-03-20 18:42:58 +08:00
sagitchu 1780be73b9 fix(monitor): hide remote nodes from monitor view 2026-03-20 18:41:31 +08:00
sagit addf8e2089 feat: implement Uptime Kuma style historic quality bars for tunnel monitor (#355) 2026-03-20 14:17:16 +08:00
sagit 75cd60ea3e feat(ui): update list view tables styling for Node, Monitor, User, Tunnel, and Limit (#354) 2026-03-20 13:15:15 +08:00
sagit fe42a77409 feat: periodic tunnel quality probing (#353)
Implement tunnel quality metrics collection and display
2026-03-20 12:38:08 +08:00
sagitchu ce9abf457f fix(frontend): recharts Legend export and implicit any in monitoring 2026-03-20 12:36:48 +08:00
sagitchu 3c57a5ac84 feat: periodic tunnel quality probing and monitoring 2026-03-20 12:34:09 +08:00
sagit ff7c91d277 chore: optimize agent-panel metrics communication (#352) 2026-03-20 03:39:49 +00:00
sagit 1498f3052d feat: align node and user list view UI (#351)
Match monitor page list view style per user request.
2026-03-20 11:03:01 +08:00
sagitchu 6b1264ae90 feat: align node and user list view UI 2026-03-20 11:01:37 +08:00
39 changed files with 1947 additions and 719 deletions
+3 -3
View File
@@ -1,9 +1,9 @@
# PROJECT KNOWLEDGE BASE
**Generated:** Thu Mar 19 2026
**Commit:** 6458b5a
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-alpha5
**Tag:** 2.1.9-beta6
## OVERVIEW
FLVX (formerly Flux Panel) is a traffic forwarding management system built on a forked GOST v3 stack. It ships as a Go-based admin API (SQLite/PostgreSQL) + Vite/React UI + Go forwarding agent, with optional mobile WebView wrappers.
+4 -4
View File
@@ -33,13 +33,13 @@ curl -L https://raw.githubusercontent.com/Sagit-chu/flux-panel/main/install.sh -
#### 安装特定版本
从 [Releases](https://github.com/Sagit-chu/flux-panel/releases) 页面复制对应版本的安装命令,脚本会自动安装该版本而非最新版。
面板端(以 2.1.0 为例):
面板端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/panel_install.sh -o panel_install.sh && chmod +x panel_install.sh && ./panel_install.sh
```
节点端(以 2.1.0 为例):
节点端(以 2.1.9-beta6 为例):
```bash
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.0/install.sh -o install.sh && chmod +x install.sh && ./install.sh
curl -L https://github.com/Sagit-chu/flux-panel/releases/download/2.1.9-beta6/install.sh -o install.sh && chmod +x install.sh && ./install.sh
```
#### PostgreSQL 部署(Docker Compose)
+5
View File
@@ -1,5 +1,10 @@
# GO BACKEND KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Go-based Admin API for FLVX. Replaced legacy Spring Boot backend.
**Stack:** Go 1.24, net/http (std lib), GORM + SQLite/PostgreSQL (glebarez/sqlite - CGO-free).
+4 -1
View File
@@ -1,6 +1,9 @@
# BACKEND HTTP HANDLER KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
HTTP request handlers for FLVX Admin API. Core business logic layer.
+21 -2
View File
@@ -42,6 +42,8 @@ type Handler struct {
upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{}
qualityProber *tunnelQualityProber
}
type loginRequest struct {
@@ -93,6 +95,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
pendingUpgradeRedeploy: make(map[int64]struct{}),
}
h.healthCheck = health.NewChecker(repo, h.wsServer)
h.qualityProber = newTunnelQualityProber(h)
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
metricInfo := metrics.SystemInfo{
@@ -229,6 +232,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
@@ -1234,6 +1238,21 @@ func nullableNullInt64(v sql.NullInt64) interface{} {
return nil
}
// flowCryptoCache caches AES crypto instances by secret to avoid per-request SHA256+GCM init.
var flowCryptoCache sync.Map
func getOrCreateFlowCrypto(secret string) *security.AESCrypto {
if v, ok := flowCryptoCache.Load(secret); ok {
return v.(*security.AESCrypto)
}
c, err := security.NewAESCrypto(secret)
if err != nil {
return nil
}
flowCryptoCache.Store(secret, c)
return c
}
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
defer body.Close()
raw, err := io.ReadAll(body)
@@ -1254,8 +1273,8 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
return text, nil
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
crypto := getOrCreateFlowCrypto(secret)
if crypto == nil {
return text, nil
}
plain, err := crypto.Decrypt(wrap.Data)
+9 -1
View File
@@ -18,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel
h.jobsStarted = true
h.jobsWG.Add(5)
h.jobsWG.Add(6)
h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx)
@@ -26,6 +26,7 @@ func (h *Handler) StartBackgroundJobs() {
go h.runNodeRenewalCycleLoop(ctx)
go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
}
func (h *Handler) StopBackgroundJobs() {
@@ -63,6 +64,13 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
}
}
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done()
if h.qualityProber != nil {
h.qualityProber.Start(ctx)
}
}
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done()
+133 -1
View File
@@ -207,6 +207,98 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
response.WriteJSON(w, response.OK(metric))
}
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
// Try in-memory cache first
if h.qualityProber != nil {
items := h.qualityProber.GetAll()
if len(items) > 0 {
response.WriteJSON(w, response.OK(items))
return
}
}
// Fallback to database (latest per tunnel)
qualities, err := h.repo.GetLatestTunnelQualities()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
for _, q := range qualities {
snapshots = append(snapshots, tunnelQualitySnapshot{
TunnelID: q.TunnelID,
EntryToExitLatency: q.EntryToExitLatency,
ExitToBingLatency: q.ExitToBingLatency,
EntryToExitLoss: q.EntryToExitLoss,
ExitToBingLoss: q.ExitToBingLoss,
Success: q.Success == 1,
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
})
}
response.WriteJSON(w, response.OK(snapshots))
}
// monitorTunnelQualityHistory returns quality probe history for charting.
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -216,7 +308,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
return
}
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
path := r.URL.Path
prefix := "/api/v1/monitor/tunnels/"
if !strings.HasPrefix(path, prefix) {
response.WriteJSON(w, response.ErrDefault("无效的路径"))
return
}
rest := strings.TrimPrefix(path, prefix)
// Route: /api/v1/monitor/tunnels/{id}/quality
if strings.HasSuffix(rest, "/quality") {
h.monitorTunnelQualityHistory(w, r)
return
}
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
@@ -587,6 +695,30 @@ func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Re
return
}
// If start/end time range is provided, use time-based query (mirrors node metrics / tunnel quality pattern).
startStr := r.URL.Query().Get("start")
endStr := r.URL.Query().Get("end")
if startStr != "" && endStr != "" {
startMs, err1 := strconv.ParseInt(startStr, 10, 64)
endMs, err2 := strconv.ParseInt(endStr, 10, 64)
if err1 != nil || err2 != nil || startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetServiceMonitorResultsByTimeRange(monitorID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
return
}
// Fallback: count-based limit query (backward compat).
limit := 100
if l := r.URL.Query().Get("limit"); l != "" {
if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 {
@@ -0,0 +1,332 @@
package handler
import (
"context"
"log"
"sync"
"time"
"go-backend/internal/store/model"
)
const (
tunnelQualityProbeInterval = 10 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
)
// 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"`
}
// 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
}
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{
handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval,
}
}
// Start launches the background probe loop (call from jobs.go).
func (p *tunnelQualityProber) Start(ctx context.Context) {
// Use the provided context so we stop with other background jobs.
p.ctx, p.cancel = context.WithCancel(ctx)
p.loop()
}
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
p.cancel()
}
// GetAll returns all cached quality snapshots (latest per tunnel).
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
var items []tunnelQualitySnapshot
p.cache.Range(func(_, value interface{}) bool {
if snap, ok := value.(*tunnelQualitySnapshot); ok {
items = append(items, *snap)
}
return true
})
return items
}
func (p *tunnelQualityProber) loop() {
// Initial delay to let the system boot up
select {
case <-time.After(5 * time.Second):
case <-p.ctx.Done():
return
}
// Run once immediately
p.probeAll()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-p.ctx.Done():
return
case <-ticker.C:
p.probeAll()
p.maybePrune()
}
}
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
}
p.lastPrune = now
h := p.handler
if h == nil || h.repo == nil {
return
}
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
log.Printf("tunnel_quality_prober: prune err=%v", err)
}
}
func (p *tunnelQualityProber) probeAll() {
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 = 4
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)
p.cache.Store(snap.TunnelID, snap)
// 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)
}
}
+17
View File
@@ -714,3 +714,20 @@ type ServiceMonitorResult struct {
}
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
// TunnelQuality stores periodic probe results for a tunnel.
// Unlike the old upsert model, rows accumulate for history/charting.
// Old rows are pruned periodically (default: keep 24h).
type TunnelQuality struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
Success int `gorm:"not null;default:1" json:"success"`
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
}
func (TunnelQuality) TableName() string { return "tunnel_quality" }
+25 -3
View File
@@ -51,6 +51,7 @@ type NodeMetric = model.NodeMetric
type TunnelMetric = model.TunnelMetric
type ServiceMonitor = model.ServiceMonitor
type ServiceMonitorResult = model.ServiceMonitorResult
type TunnelQuality = model.TunnelQuality
// ─── Repository ──────────────────────────────────────────────────────
@@ -191,6 +192,7 @@ func autoMigrateAll(db *gorm.DB) error {
&model.TunnelMetric{},
&model.ServiceMonitor{},
&model.ServiceMonitorResult{},
&model.TunnelQuality{},
}
if db.Dialector.Name() != "sqlite" {
@@ -675,9 +677,10 @@ func (r *Repository) ListNodes() ([]map[string]interface{}, error) {
"version": nullableString(n.Version),
"http": n.HTTP, "tls": n.TLS, "socks": n.Socks,
"status": n.Status, "isRemote": n.IsRemote,
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"remoteUrl": nullableString(n.RemoteURL),
"remoteToken": nullableString(n.RemoteToken),
"remoteConfig": nullableString(n.RemoteConfig),
"expiryReminderDismissed": n.ExpiryReminderDismissed,
})
}
return items, nil
@@ -3566,6 +3569,25 @@ func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]mod
return results, err
}
// GetServiceMonitorResultsByTimeRange returns results for a monitor within [startMs, endMs].
// Mirrors GetNodeMetrics / GetTunnelMetrics pattern for time-range based charting.
func (r *Repository) GetServiceMonitorResultsByTimeRange(monitorID int64, startMs, endMs int64) ([]model.ServiceMonitorResult, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.ServiceMonitorResult
err := r.db.Where("monitor_id = ? AND timestamp >= ? AND timestamp <= ?", monitorID, startMs, endMs).
Order("timestamp DESC").
Limit(5000).
Find(&results).Error
if len(results) > 1 {
for i, j := 0, len(results)-1; i < j; i, j = i+1, j-1 {
results[i], results[j] = results[j], results[i]
}
}
return results, err
}
// GetLatestServiceMonitorResults returns the newest result per monitor_id.
// This is intended for list rendering (avoid N+1 queries).
func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) {
@@ -12,6 +12,7 @@ func (r *Repository) ListMonitorNodes() ([]model.Node, error) {
}
var nodes []model.Node
err := r.db.Select("id", "inx", "name", "status", "updated_time").
Where("is_remote = ?", 0).
Order("inx ASC, id ASC").
Find(&nodes).Error
return nodes, err
@@ -0,0 +1,98 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
// InsertTunnelQuality appends a tunnel quality probe result.
// (Follows the same pattern as InsertServiceMonitorResult.)
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if q == nil || q.TunnelID <= 0 {
return nil
}
return r.db.Create(q).Error
}
// GetTunnelQualityHistory returns quality probe results for a tunnel
// within a time range, ordered by timestamp ascending.
// (Mirrors GetServiceMonitorResults pattern.)
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var results []model.TunnelQuality
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
Order("timestamp ASC").
Find(&results).Error
return results, err
}
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
// (Mirrors GetLatestServiceMonitorResults pattern.)
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.TunnelQuality
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
q := `
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
FROM tunnel_quality
) t
WHERE rn = 1
ORDER BY tunnel_id ASC
`
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
return results, nil
}
// Fallback for older SQLite
results = nil
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
if err != nil {
return nil, err
}
seen := make(map[int64]struct{}, len(results))
out := make([]model.TunnelQuality, 0, len(results))
for _, row := range results {
if row.TunnelID <= 0 {
continue
}
if _, ok := seen[row.TunnelID]; ok {
continue
}
seen[row.TunnelID] = struct{}{}
out = append(out, row)
}
return out, nil
}
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
// (Mirrors PruneServiceMonitorResults pattern.)
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
}
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
return ids, err
}
+44 -13
View File
@@ -39,6 +39,7 @@ type nodeSession struct {
nodeID int64
secret string
conn *connWrap
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
}
type commandResponse struct {
@@ -211,7 +212,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
_ = old.conn.conn.Close()
delete(s.byConn, old.conn.conn)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
// 初始化 AES 加密器并缓存(仅创建一次)
var nodeCrypto *security.AESCrypto
if strings.TrimSpace(secret) != "" {
nodeCrypto, _ = security.NewAESCrypto(secret)
}
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto}
s.nodes[nodeID] = ns
s.byConn[conn] = ns
s.mu.Unlock()
@@ -251,7 +257,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
return
}
msg := decryptIfNeeded(payload, secret)
msg := decryptIfNeeded(payload, ns.crypto, secret)
s.tryResolvePending(nodeID, msg)
var parsed struct {
@@ -259,6 +265,26 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
}
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
switch parsed.Type {
case "metric":
// Agent 新版指标消息:{type:"metric", data:{...}}
var envelope struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal([]byte(msg), &envelope); err == nil && len(envelope.Data) > 0 {
// 解析 SystemInfo 并调用 hook
var sysInfo SystemInfo
if json.Unmarshal(envelope.Data, &sysInfo) == nil {
s.mu.RLock()
onMetric := s.onNodeMetric
s.mu.RUnlock()
if onMetric != nil {
go onMetric(nodeID, sysInfo)
}
}
// 广播内层 data 给前端(保持平坦结构兼容性)
s.broadcastTyped(nodeID, "metric", string(envelope.Data))
}
continue
case "UpgradeProgress":
s.broadcastTyped(nodeID, "upgrade_progress", msg)
continue
@@ -270,6 +296,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
}
}
// 兼容旧版 Agent:无 type 字段的系统信息消息
if looksLikeSystemInfoMessage(msg) {
var sysInfo SystemInfo
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
@@ -372,13 +399,8 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
}
messageData := rawCmd
if strings.TrimSpace(ns.secret) != "" {
crypto, err := security.NewAESCrypto(ns.secret)
if err != nil {
cleanup()
return CommandResult{}, err
}
encrypted, err := crypto.Encrypt(rawCmd)
if ns.crypto != nil {
encrypted, err := ns.crypto.Encrypt(rawCmd)
if err != nil {
cleanup()
return CommandResult{}, err
@@ -428,6 +450,11 @@ func (s *Server) tryResolvePending(nodeID int64, message string) {
return
}
// 快速短路:指标消息永远不含 requestId,跳过完整 JSON 解析
if !strings.Contains(message, "\"requestId\"") {
return
}
var resp commandResponse
if err := json.Unmarshal([]byte(message), &resp); err != nil {
return
@@ -545,18 +572,22 @@ func (s *Server) broadcastToAdmins(message string) {
}
}
func decryptIfNeeded(payload []byte, secret string) string {
func decryptIfNeeded(payload []byte, crypto *security.AESCrypto, secret string) string {
text := string(payload)
var wrap encryptedMessage
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
return text
}
crypto, err := security.NewAESCrypto(secret)
if err != nil {
// 优先使用缓存的 crypto 实例
c := crypto
if c == nil && strings.TrimSpace(secret) != "" {
c, _ = security.NewAESCrypto(secret)
}
if c == nil {
return text
}
plain, err := crypto.Decrypt(wrap.Data)
plain, err := c.Decrypt(wrap.Data)
if err != nil {
return text
}
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST SERVICE KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Local fork of `github.com/go-gost/x` used by `go-gost/` via `replace github.com/go-gost/x => ./x`. Most protocol/runtime behavior changes happen here. 30+ top-level packages - framework-style layout.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X API KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Gin-based management API for reading/writing config and controlling services at runtime.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X CONFIG KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Config model + parsing/loading pipeline for the `go-gost/x` runtime. This is the bridge between `gost.json`/`gost.yaml` and in-memory registries/services.
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST CONNECTOR KNOWLEDGE BASE
**Generated:** Fri Feb 13 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Connection initiators (clients) for various protocols in GOST forwarding.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X DIALERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Outbound dialers (client-side connection establishment) used by connectors/handlers.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X HANDLERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Protocol handlers (server-side request handling) used by services defined in the GOST config.
+5
View File
@@ -1,5 +1,10 @@
# GO-GOST/X LISTENERS KNOWLEDGE BASE
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Inbound listeners (transport-level accept loops) used by services defined in the GOST config.
+4 -1
View File
@@ -1,6 +1,9 @@
# GO-GOST REGISTRY KNOWLEDGE BASE
**Generated:** Wed Feb 04 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
Central registration point for all pluggable GOST components (handlers, listeners, dialers, etc.).
+4 -1
View File
@@ -1,6 +1,9 @@
# GOST SOCKET KNOWLEDGE BASE
**Generated:** Sun Feb 15 2026
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## OVERVIEW
WebSocket reporter and socket utilities for panel integration.
+100 -88
View File
@@ -9,6 +9,7 @@ import (
"encoding/json"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
@@ -146,6 +147,9 @@ type ServiceMonitorCheckResult struct {
const (
reporterReadWait = 60 * time.Second
reporterWriteWait = 5 * time.Second
wsPingInterval = 20 * time.Second // 独立 WebSocket ping 间隔
initialBackoff = 2 * time.Second // 重连初始退避
maxBackoff = 2 * time.Minute // 重连最大退避
)
type WebSocketReporter struct {
@@ -155,15 +159,15 @@ type WebSocketReporter struct {
version string // 保存版本号
preferredWSScheme string
conn *websocket.Conn
reconnectTime time.Duration
curBackoff time.Duration // 当前重连退避间隔
pingInterval time.Duration
configInterval time.Duration
ctx context.Context
cancel context.CancelFunc
connected bool
connecting bool // 新增:正在连接状态
connMutex sync.Mutex // 新增:连接状态锁
aesCrypto *crypto.AESCrypto // 新增:AES加密器
connecting bool // 正在连接状态
connMutex sync.Mutex // 连接状态锁
aesCrypto *crypto.AESCrypto // AES加密器
}
var wsDial = func(dialer *websocket.Dialer, rawURL string) (*websocket.Conn, *http.Response, error) {
@@ -185,7 +189,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter {
return &WebSocketReporter{
url: serverURL,
reconnectTime: 5 * time.Second, // 重连间隔
curBackoff: initialBackoff, // 当前退避间隔
pingInterval: 5 * time.Second, // 指标上报间隔
configInterval: 10 * time.Minute, // 配置上报间隔
ctx: ctx,
@@ -204,10 +208,17 @@ func (w *WebSocketReporter) Start() {
// Stop 停止WebSocket报告器
func (w *WebSocketReporter) Stop() {
w.cancel()
w.connMutex.Lock()
if w.conn != nil {
w.conn.Close()
}
w.connMutex.Unlock()
}
// backoffWithJitter 返回带随机抖动的退避时间(±25%)
func backoffWithJitter(base time.Duration) time.Duration {
jitter := time.Duration(float64(base) * (0.75 + rand.Float64()*0.5))
return jitter
}
// run 主运行循环
@@ -224,23 +235,32 @@ func (w *WebSocketReporter) run() {
if needConnect {
if err := w.connect(); err != nil {
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, w.reconnectTime)
wait := backoffWithJitter(w.curBackoff)
fmt.Printf("❌ WebSocket连接失败: %v,%v后重试\n", err, wait)
// 指数退避:翻倍当前退避间隔,上限 maxBackoff
w.curBackoff *= 2
if w.curBackoff > maxBackoff {
w.curBackoff = maxBackoff
}
select {
case <-time.After(w.reconnectTime):
case <-time.After(wait):
continue
case <-w.ctx.Done():
return
}
}
// 连接成功:重置退避
w.curBackoff = initialBackoff
}
// 连接成功,开始发送消息
if w.connected {
w.handleConnection()
} else {
wait := backoffWithJitter(w.curBackoff)
// 如果连接失败,等待重试
select {
case <-time.After(w.reconnectTime):
case <-time.After(wait):
continue
case <-w.ctx.Done():
return
@@ -473,15 +493,34 @@ func (w *WebSocketReporter) handleConnection() {
// 启动消息接收goroutine
go w.receiveMessages()
// 主发送循环
ticker := time.NewTicker(w.pingInterval)
defer ticker.Stop()
// 指标上报 ticker
metricTicker := time.NewTicker(w.pingInterval)
defer metricTicker.Stop()
// 独立 WebSocket keepalive ping ticker
pingTicker := time.NewTicker(wsPingInterval)
defer pingTicker.Stop()
for {
select {
case <-w.ctx.Done():
return
case <-ticker.C:
case <-pingTicker.C:
// 发送 WebSocket ping 保活,独立于指标上报
w.connMutex.Lock()
conn := w.conn
isConnected := w.connected
w.connMutex.Unlock()
if !isConnected || conn == nil {
return
}
if err := conn.WriteControl(websocket.PingMessage, nil, time.Now().Add(reporterWriteWait)); err != nil {
fmt.Printf("❌ 发送WebSocket ping失败: %v,准备重连\n", err)
return
}
case <-metricTicker.C:
// 检查连接状态
w.connMutex.Lock()
isConnected := w.connected
@@ -548,6 +587,37 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo {
}
}
// encryptPayload 加密 JSON 数据,返回加密后的消息字节(若加密失败则回退到原始数据)
func (w *WebSocketReporter) encryptPayload(jsonData []byte) []byte {
if w.aesCrypto == nil {
return jsonData
}
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
return jsonData
}
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err := json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
return jsonData
}
return messageData
}
// metricEnvelope wraps SystemInfo with a type field for fast identification on the panel side.
type metricEnvelope struct {
Type string `json:"type"`
Data SystemInfo `json:"data"`
}
// sendSystemInfo 发送系统信息
func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
w.connMutex.Lock()
@@ -557,42 +627,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
return fmt.Errorf("连接未建立")
}
// 转换为JSON
jsonData, err := json.Marshal(sysInfo)
// 使用 type:"metric" 信封包装,Panel 可通过 type 字段直接识别指标消息
envelope := metricEnvelope{Type: "metric", Data: sysInfo}
jsonData, err := json.Marshal(envelope)
if err != nil {
return fmt.Errorf("序列化系统信息失败: %v", err)
}
var messageData []byte
messageData := w.encryptPayload(jsonData)
// 如果有加密器,则加密数据
if w.aesCrypto != nil {
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密失败,发送原始数据: %v\n", err)
messageData = jsonData
} else {
// 创建加密消息包装器
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err = json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密消息失败,发送原始数据: %v\n", err)
messageData = jsonData
}
}
} else {
messageData = jsonData
}
// 设置写入超时
w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := w.conn.WriteMessage(websocket.TextMessage, messageData); err != nil {
w.connected = false // 标记连接已断开
w.connected = false
return fmt.Errorf("写入消息失败: %v", err)
}
@@ -601,23 +648,19 @@ func (w *WebSocketReporter) sendSystemInfo(sysInfo SystemInfo) error {
// receiveMessages 接收服务端发送的消息
func (w *WebSocketReporter) receiveMessages() {
// 获取连接引用一次即可,连接生命周期由 handleConnection 管理
w.connMutex.Lock()
conn := w.conn
w.connMutex.Unlock()
if conn == nil {
return
}
for {
select {
case <-w.ctx.Done():
return
default:
w.connMutex.Lock()
conn := w.conn
connected := w.connected
w.connMutex.Unlock()
if conn == nil || !connected {
return
}
// 设置读取超时
conn.SetReadDeadline(time.Now().Add(reporterReadWait))
messageType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
@@ -705,12 +748,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
} else {
// 处理普通消息
@@ -721,12 +760,8 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt
return
}
if cmdMsg.Type != "call" {
// 其他状态变更命令保持同步,确保顺序执行
if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" {
go w.routeCommand(cmdMsg)
} else {
w.routeCommand(cmdMsg)
}
// 所有命令统一异步执行,避免阻塞消息接收循环
go w.routeCommand(cmdMsg)
}
}
@@ -1400,30 +1435,7 @@ func (w *WebSocketReporter) sendResponse(response CommandResponse) {
return
}
var messageData []byte
// 如果有加密器,则加密数据
if w.aesCrypto != nil {
encryptedData, err := w.aesCrypto.Encrypt(jsonData)
if err != nil {
fmt.Printf("⚠️ 加密响应失败,发送原始数据: %v\n", err)
messageData = jsonData
} else {
// 创建加密消息包装器
encryptedMessage := map[string]interface{}{
"encrypted": true,
"data": encryptedData,
"timestamp": time.Now().Unix(),
}
messageData, err = json.Marshal(encryptedMessage)
if err != nil {
fmt.Printf("⚠️ 序列化加密响应失败,发送原始数据: %v\n", err)
messageData = jsonData
}
}
} else {
messageData = jsonData
}
messageData := w.encryptPayload(jsonData)
// 检查消息大小,如果超过10MB则记录警告
if len(messageData) > 10*1024*1024 {
+16
View File
@@ -0,0 +1,16 @@
# 053 List View Styling
## Objective
Update the design of Node and User list table views to match the provided screenshot:
- Use colored dots for status instead of separate "Status" text columns.
- The action buttons should have text labels (e.g. "安装", "编辑") with `variant="flat"` instead of icons.
- Add version column for Node list.
- Remove traffic columns from Node list as requested.
- Adjust User list to match this clean style.
## Tasks
- [x] Update `pages/node.tsx` list view to use the new column layout (Node name with dot, Address, Version, Actions).
- [x] Update action buttons in `pages/node.tsx` list view to use text instead of icons.
- [x] Update `pages/user.tsx` list view to use the status dot pattern.
- [x] Update action buttons in `pages/user.tsx` list view to use text instead of icons.
- [x] Ensure `selectionMode="multiple"` (or similar) is properly reflected.
+147
View File
@@ -0,0 +1,147 @@
# Agent-Panel 通信优化:提升稳定性与效率
## 背景
Agent(`go-gost/x/socket/websocket_reporter.go`)与 Panel(`go-backend/internal/ws/server.go`)之间通过 WebSocket 进行实时通信,包括指标上报(每 5s)、命令下发/响应、和流量上报(HTTP)。经过代码审查,以下是发现的问题和优化建议。
---
## 发现的问题
### 1. Keepalive 时序不匹配 —— 导致误断连
| 参数 | Agent 侧 | Panel 侧 |
|------|----------|----------|
| Read deadline | `reporterReadWait` = 60s | `wsPongWait` = 45s |
| Ping 发送间隔 | 无主动 ping(靠指标数据 5s 续命) | `wsPingPeriod` = 15s |
| Write timeout | `reporterWriteWait` = 5s | `wsWriteWait` = 5s |
**问题**:Panel 每 15s 发 ping,Agent read deadline 60s,但 Panel pong deadline 只有 45s。如果 Agent 的指标消息被延迟(网络抖动),Panel 可能因 pong 超时而关闭连接。两侧的超时参数缺乏协调设计。
### 2. 固定重连间隔 —— 无退避策略
Agent 断线后以固定 5s 间隔重试(`reconnectTime = 5 * time.Second`),在 Panel 长时间不可用(升级、网络故障)的情况下,会产生大量无用连接尝试。
### 3. Panel 侧每次解密都重建 AES 加密器
`ws/server.go` 的 `decryptIfNeeded()` 和 `SendCommand()` 每次调用都 `security.NewAESCrypto(secret)` 重新创建 cipher(SHA256 + AES-GCM 初始化),对于高频指标消息(5s/次 × N 节点),有不必要的 CPU 开销。
### 4. 指标消息使用 JSON Text 格式传输
每 5s 发送一次包含 13 个字段的 SystemInfo JSON,加密后还需 base64 编码,一条消息约 300-500 bytes(加密后约 700 bytes)。对于大量节点场景,存在优化空间。
### 5. `receiveMessages` 紧循环中有频繁锁竞争
`receiveMessages()` 在每次 `ReadMessage()` 前都要 `Lock/Unlock connMutex` 检查连接状态,但 `ReadMessage` 本身是阻塞的,实际不需要在循环外检查。
### 6. 状态变更命令阻塞读消息循环
`routeCommand` 中的 Service/Chain/Limiter CRUD 命令是同步执行的,包括 `saveConfig()` 文件写入。执行期间会阻塞 `receiveMessages` 的读取循环。
---
## 推荐的优化方案(按优先级排列)
### P0 — 高收益、低风险
#### 优化 1:协调 Keepalive 参数
**文件**:`websocket_reporter.go`
- Agent 增加独立的 WebSocket ping 发送(每 20s),不依赖指标数据来维持连接
- 统一 read deadline 设置,确保两侧 read timeout > 2×ping interval
#### 优化 2:指数退避重连
**文件**:`websocket_reporter.go`
- 初始间隔 2s,按指数退避增长至最大 2 分钟
- 连接成功后立即重置退避
- 增加随机抖动(jitter)避免大量 Agent 同时重连
#### 优化 3:Panel 侧缓存 AES 加密器
**文件**:`ws/server.go`
- 将 `AESCrypto` 实例缓存在 `nodeSession` 中,避免每条消息重建
- `SendCommand` 复用缓存实例
### P1 — 中等收益
#### 优化 4:减少 `receiveMessages` 锁竞争
**文件**:`websocket_reporter.go`
- 将连接状态检查移到循环外,只在出错/关闭时通过 channel 通知退出
- 用 `context.WithCancel` 代替锁检查 `connected` flag 来控制生命周期
#### 优化 5:异步化状态变更命令处理
**文件**:`websocket_reporter.go`
- 所有命令统一异步执行(通过 goroutine + response channel),避免阻塞 readLoop
- 当前只有 TcpPing/ServiceMonitorCheck/UpgradeAgent/RollbackAgent 是异步的
---
## 具体代码变更
### Agent 侧 (`go-gost/x/socket`)
---
#### [MODIFY] [websocket_reporter.go](file:///Users/sagit/Documents/github/flvx/go-gost/x/socket/websocket_reporter.go)
1. **指数退避重连**:将 `reconnectTime` 从固定 `5s` 改为动态退避字段,增加 `curBackoff/maxBackoff` 字段
2. **独立 Ping 发送**:在 `handleConnection()` 中增加 WebSocket ping ticker(20s),独立于指标上报
3. **减少锁竞争**:`receiveMessages` 中只在循环入口检查一次连接,此后靠 `ReadMessage` 的 error 退出
4. **统一命令异步化**:所有 `routeCommand` 调用统一使用 goroutine
---
### Panel 侧 (`go-backend/internal/ws`)
---
#### [MODIFY] [server.go](file:///Users/sagit/Documents/github/flvx/go-backend/internal/ws/server.go)
1. **缓存 AES 加密器**:在 `nodeSession` 中增加 `crypto *security.AESCrypto` 字段,节点连接时初始化
2. **`decryptIfNeeded` 接收 crypto 参数**而非 secret 字符串
3. **`SendCommand` 使用缓存 crypto** 实例
---
## Verification Plan
### Automated Tests
```bash
# 运行现有 agent 侧单元测试(验证不回归)
(cd go-gost/x && go test ./socket/... -v -count=1)
# 运行现有流量上报测试
(cd go-gost/x && go test ./service/... -v -count=1)
# 运行 panel 侧全部测试
(cd go-backend && go test ./... -count=1)
```
### Manual Verification
> [!IMPORTANT]
> 本次改动涉及实时通信核心路径,建议在 staging 环境部署后观察至少 30 分钟:
> 1. 检查节点在面板中状态是否正常显示为在线
> 2. 手动停止面板后观察 Agent 日志,确认重连间隔呈指数增长
> 3. 恢复面板后确认 Agent 能自动恢复连接并恢复指标上报
> 4. 通过面板下发命令(如添加/删除 service),确认命令执行成功
---
## 任务清单
- [x] 优化 1:Agent 增加独立 WebSocket ping 发送
- [x] 优化 2:Agent 指数退避重连
- [x] 优化 3:Panel 缓存 AES 加密器
- [x] 优化 4:Agent 减少 receiveMessages 锁竞争
- [x] 优化 5:Agent 命令处理统一异步化
- [x] 运行现有测试验证不回归
@@ -0,0 +1,31 @@
# 055 - 隧道质量定时探测 + 实时展示 + 历史图表
## 背景
当前隧道质量检测是手动触发的:用户点击"诊断"按钮 → 后端调用节点 TcpPing → 返回结果。
需求:改为**后端定时(每10秒)自动探测**所有启用隧道的质量(入口→出口延迟、出口→Bing延迟),
结果保留历史(24h),前端隧道 Tab 实时展示 + 图表历史趋势。
## 设计原则:与服务监控复用
| 复用点 | 服务监控 | 隧道质量 |
|--------|---------|---------|
| 调度方式 | `health.Checker.Start(ctx)` via `jobs.go` | `tunnelQualityProber.Start(ctx)` via `jobs.go` |
| 存储模式 | `service_monitor_result` (history, insert) | `tunnel_quality` (history, insert) |
| 清理方式 | `PruneServiceMonitorResults(olderThanMs)` | `PruneTunnelQualityResults(olderThanMs)` |
| 最新查询 | `GetLatestServiceMonitorResults()` (window func) | `GetLatestTunnelQualities()` (window func) |
| 历史查询 | `GetServiceMonitorResults(id, limit)` | `GetTunnelQualityHistory(id, start, end)` |
| API 模式 | `GET /monitor/services/{id}/results` | `GET /monitor/tunnels/{id}/quality` |
| 前端图表 | Recharts LineChart (延迟趋势) | Recharts LineChart (同样模式) |
## 任务清单
- [x] 1. `TunnelQuality` model 改为历史存储(composite index, 非 unique)
- [x] 2. Repo 改为 insert(非 upsert),复用服务监控的查询模式
- [x] 3. 添加 `PruneTunnelQualityResults` + `GetLatestTunnelQualities` + `GetTunnelQualityHistory`
- [x] 4. Prober 生命周期集成到 `jobs.go`(与 healthCheck 同级)
- [x] 5. Prober 添加 24h 清理周期
- [x] 6. 添加 API `GET /monitor/tunnels/{id}/quality` 返回历史
- [x] 7. 前端添加 `getMonitorTunnelQualityHistory()` API
- [x] 8. 前端详情页添加质量趋势图表(复用服务监控图表组件模式)
- [x] 9. Go 编译 + 测试通过
- [x] 10. TypeScript 编译通过
+16
View File
@@ -0,0 +1,16 @@
# Tunnel Quality Uptime Kuma Display
**Plan ID**: 056-tunnel-uptime-kuma-bars.md
## Objective
The goal is to modify the "Monitor - Tunnel" UI page to remove the isolated "Quality" column/chip, and replace the specific latency readouts for entry->exit and exit->Bing with an Uptime Kuma style row of visual history bars.
## Steps
- [x] Remove the individual "Quality" column from the tunnel monitor table view.
- [x] Remove the individual "Quality" chip from the tunnel monitor grid view.
- [x] Implement `<UptimeHistoryBar />` to display a historical sequence of up to 30 metrics, coloring by latency (success, warning, danger) and packet loss (danger).
- [x] Modify the front-end to poll/load initial tunnel quality history, effectively padding the history bars instead of them starting empty.
- [x] Update `getMonitorTunnelQuality` auto-polling locally to append strictly to the local history state, truncating appropriately.
- [x] Retain current latency display next to or below the Uptime bars to allow numerical visibility.
## Status
Completed. The interface will now load history for all displayed tunnels upon open, and continue tracking with bars appending in real time every 10-second polling interval.
+13
View File
@@ -0,0 +1,13 @@
# 057 Hide Remote Nodes from Monitor View
## Goal
Do not display remote nodes on the Monitoring page.
## Changes Made
- Modified `ListMonitorNodes` in `go-backend/internal/store/repo/repository_monitor_nodes.go` by adding a `.Where("is_remote = ?", 0)` constraint so that remote nodes (imported via Federation feature) are entirely excluded from the returned payload for API `/api/v1/monitor/nodes`.
- This efficiently removes remote nodes from both the grid/list displaying Node stats in the Monitoring tab and also eliminates remote nodes from the selection dropdown when creating new Service Monitors.
## Checklist
- [x] Identify how "remote node" is defined in the database structure (`IsRemote` = 1 or 0).
- [x] Add SQL query constraint to filter out remote nodes from the `/monitor/nodes` API response.
- [x] Verify changes compile successfully.
+13
View File
@@ -0,0 +1,13 @@
# Plan 058: Sync Main and Push Tag
Sync current changes to `main`, merge to `master` (if needed) or just PR/merge on `main` (if that's the flow), and push a new tag `2.1.9-beta7`.
## Tasks
- [ ] Determine the next tag version (`2.1.9-beta7`).
- [ ] Create a feature branch `release/2.1.9-beta7`.
- [ ] Commit all current changes to the feature branch.
- [ ] Push the feature branch to remote.
- [ ] Create a PR to `main`.
- [ ] Merge the PR to `main`.
- [ ] Create and push tag `2.1.9-beta7`.
+3 -3
View File
@@ -1,9 +1,9 @@
# VITE FRONTEND KNOWLEDGE BASE
**Generated:** Thu Feb 26 2026
**Commit:** 21008cc
**Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.5-rc15
**Tag:** 2.1.9-beta6
## OVERVIEW
Web management console for FLVX.
+34 -3
View File
@@ -40,6 +40,7 @@ import type {
MonitorTunnelApiItem,
MonitorPermissionApiItem,
MonitorAccessApiData,
TunnelQualityApiItem,
} from "./types";
import axios from "axios";
@@ -451,6 +452,25 @@ export const getTunnelMetrics = (
export const getMonitorTunnels = () =>
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
export const getMonitorTunnelQuality = () =>
Network.get<TunnelQualityApiItem[]>("/monitor/tunnels/quality");
export const getMonitorTunnelQualityHistory = (
tunnelId: number,
start?: number,
end?: number,
) => {
const params: Record<string, string> = {};
if (start) params.start = String(start);
if (end) params.end = String(end);
return Network.get<TunnelQualityApiItem[]>(
`/monitor/tunnels/${tunnelId}/quality`,
params,
);
};
export const getServiceMonitorList = () =>
Network.get<ServiceMonitorApiItem[]>("/monitor/services");
@@ -466,11 +486,22 @@ export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) =>
export const deleteServiceMonitor = (id: number) =>
Network.post("/monitor/services/delete", { id });
export const getServiceMonitorResults = (monitorId: number, limit = 100) =>
Network.get<ServiceMonitorResultApiItem[]>(
export const getServiceMonitorResults = (
monitorId: number,
options?: { limit?: number; start?: number; end?: number },
) => {
const params: Record<string, string> = {};
if (options?.start != null && options?.end != null) {
params.start = String(options.start);
params.end = String(options.end);
} else if (options?.limit != null) {
params.limit = String(options.limit);
}
return Network.get<ServiceMonitorResultApiItem[]>(
`/monitor/services/${monitorId}/results`,
{ limit: String(limit) },
params,
);
};
export const getServiceMonitorLatestResults = () =>
Network.get<ServiceMonitorResultApiItem[]>(
+11
View File
@@ -487,3 +487,14 @@ export interface MonitorAccessApiData {
allowed: boolean;
reason?: string;
}
export interface TunnelQualityApiItem {
tunnelId: number;
entryToExitLatency: number;
exitToBingLatency: number;
entryToExitLoss: number;
exitToBingLoss: number;
success: boolean;
errorMessage?: string;
timestamp: number;
}
+89 -3
View File
@@ -18,6 +18,15 @@ import {
ModalFooter,
} from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createSpeedLimit,
getSpeedLimitList,
@@ -51,6 +60,10 @@ export default function LimitPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"limit-view-mode",
"grid",
);
const filteredRules = useMemo(() => {
if (!searchKeyword.trim()) return rules;
@@ -231,13 +244,85 @@ export default function LimitPage() {
/>
</div>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
<div className="flex items-center gap-2">
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增
</Button>
</div>
</div>
{/* 统一卡片网格 */}
{filteredRules.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="限速规则列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>规则名称</TableColumn>
<TableColumn>速度限制</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={filteredRules}>
{(rule) => (
<TableRow key={rule.id}>
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
rule.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{rule.name}
</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm font-mono text-default-600">
{rule.speed} Mbps
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</Card>
) : (
<StaggerList className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4">
{filteredRules.map((rule) => (
<StaggerItem key={rule.id}>
@@ -324,6 +409,7 @@ export default function LimitPage() {
</StaggerItem>
))}
</StaggerList>
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+78 -117
View File
@@ -20,7 +20,7 @@ import { CSS } from "@dnd-kit/utilities";
import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page";
import { LayoutGrid, List, Terminal, ArrowUpCircle, RotateCcw, Edit, Trash2 } from "lucide-react";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
@@ -1768,142 +1768,109 @@ export default function NodePage() {
/>
) : viewMode === "list" ? (
<Card>
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
<Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
onValueChange={(checked) => {
if (checked) {
selectAll();
setSelectMode(true);
} else {
deselectAll();
setSelectMode(false);
}
}}
/>
</TableColumn>
<TableColumn>节点名称</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>协议/端口</TableColumn>
<TableColumn>系统信息</TableColumn>
<TableColumn>到期时间</TableColumn>
<TableColumn>地址</TableColumn>
<TableColumn>版本</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={displayNodes}>
{(node) => {
const isRemoteNode = node.isRemote === 1;
const remoteUsage = isRemoteNode
? remoteUsageMap[node.id]
: null;
const expiryMeta = getNodeExpiryMeta(
node.expiryTime,
node.renewalCycle,
);
const connectionStatusMeta = getConnectionStatusMeta(
node.connectionStatus,
);
const hasRemark = Boolean(node.remark?.trim());
return (
<TableRow key={node.id}>
<TableCell>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<span className="font-medium text-foreground text-sm">
{node.name}
</span>
{hasRemark && (
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
{node.remark}
</Chip>
)}
</div>
<span className="text-xs text-default-500 font-mono mt-0.5">
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
</span>
</div>
<TableCell className="px-4">
<Checkbox
isSelected={selectedIds.has(node.id)}
onValueChange={(checked) => {
if (checked) {
setSelectMode(true);
setSelectedIds((prev) => new Set([...prev, node.id]));
} else {
setSelectedIds((prev) => {
const next = new Set(prev);
next.delete(node.id);
if (next.size === 0) setSelectMode(false);
return next;
});
}
}}
/>
</TableCell>
<TableCell>
<Chip
variant="flat"
size="sm"
color={connectionStatusMeta.color}
className="text-xs"
>
<div className="flex items-center gap-1.5">
<div className={`w-1.5 h-1.5 rounded-full ${node.connectionStatus === "online" ? "bg-success" : node.syncError ? "bg-danger" : "bg-default-400"} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`} />
<span>{connectionStatusMeta.text}</span>
</div>
</Chip>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
<span className="text-sm font-medium">
{isRemoteNode ? "Remote" : "Local"}
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
node.connectionStatus === "online"
? "bg-success"
: node.syncError
? "bg-danger"
: "bg-default-400"
} ${node.systemInfo && node.connectionStatus === "online" ? "animate-pulse" : ""}`}
/>
<span className="font-medium text-foreground text-sm">
{node.name}
</span>
<span className="text-xs text-default-500 font-mono">
{isRemoteNode
? (remoteUsage ? `${remoteUsage.portRangeStart}-${remoteUsage.portRangeEnd}` : "未知端口")
: node.port}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px] min-w-[100px]">
{!isRemoteNode && node.systemInfo ? (
<>
<div className="flex items-center justify-between gap-2">
<span className="text-default-500">CPU</span>
<span className={node.systemInfo.cpuUsage > 80 ? "text-danger font-medium" : ""}>
{node.systemInfo.cpuUsage.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between gap-2">
<span className="text-default-500">RAM</span>
<span className={node.systemInfo.memoryUsage > 80 ? "text-danger font-medium" : ""}>
{node.systemInfo.memoryUsage.toFixed(1)}%
</span>
</div>
</>
) : (
<span className="text-default-400">-</span>
{hasRemark && (
<Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
{node.remark}
</Chip>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 items-start whitespace-nowrap min-w-[100px]">
{node.expiryTime ? (
<>
<span className="text-sm">
{new Date(node.expiryTime).toLocaleString()}
</span>
{expiryMeta && expiryMeta.state !== "healthy" && expiryMeta.state !== "permanent" && (
<Chip
color={expiryMeta.tone}
size="sm"
variant="flat"
className="h-5 px-1 text-[10px]"
>
{expiryMeta.label}
</Chip>
)}
</>
) : (
<span className="text-default-400 text-sm">无限期</span>
)}
</div>
<span className="text-sm font-mono text-default-600">
{isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-sm text-default-500">
{node.version || "-"}
</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="success"
className="min-w-8 min-h-8 px-1"
title="安装命令"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
isLoading={node.copyLoading}
onPress={() => openInstallSelector(node)}
>
<Terminal className="w-4 h-4" />
安装
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="warning"
className="min-w-8 min-h-8 px-1"
title="升级"
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.upgradeLoading}
onPress={() => {
@@ -1911,44 +1878,38 @@ export default function NodePage() {
openUpgradeModal("single");
}}
>
<ArrowUpCircle className="w-4 h-4" />
升级
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="secondary"
className="min-w-8 min-h-8 px-1"
title="回退"
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
isDisabled={node.connectionStatus !== "online"}
isLoading={node.rollbackLoading}
onPress={() => handleRollbackNode(node)}
>
<RotateCcw className="w-4 h-4" />
回退
</Button>
)}
{!isRemoteNode && (
<Button
size="sm"
variant="flat"
color="primary"
className="min-w-8 min-h-8 px-1"
title="编辑"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
onPress={() => handleEdit(node)}
>
<Edit className="w-4 h-4" />
编辑
</Button>
)}
<Button
size="sm"
variant="flat"
color="danger"
className="min-w-8 min-h-8 px-1"
title="删除"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
onPress={() => handleDelete(node)}
>
<Trash2 className="w-4 h-4" />
删除
</Button>
</div>
</TableCell>
+38 -22
View File
@@ -314,7 +314,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
});
const [submitLoading, setSubmitLoading] = useState(false);
const [activeServiceMonitorId, setActiveServiceMonitorId] = useState<number | null>(null);
const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50);
const [serviceMonitorRangeMs, setServiceMonitorRangeMs] = useState(60 * 60 * 1000);
const [accessDenied, setAccessDenied] = useState<string | null>(null);
const [resultsModalOpen, setResultsModalOpen] = useState(false);
@@ -526,9 +526,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
}, []);
const loadMonitorResults = useCallback(
async (monitorId: number, limit = 20) => {
async (monitorId: number, options?: { rangeMs?: number; limit?: number }) => {
try {
const response = await getServiceMonitorResults(monitorId, limit);
const apiOptions = options?.rangeMs != null
? { start: Date.now() - options.rangeMs, end: Date.now() }
: { limit: options?.limit ?? 100 };
const response = await getServiceMonitorResults(monitorId, apiOptions);
if (response.code === 0 && response.data) {
setMonitorResults((prev) => ({
@@ -600,7 +603,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
if (!resultsMonitorId) return;
setResultsLoading(true);
try {
await loadMonitorResults(resultsMonitorId, resultsLimit);
await loadMonitorResults(resultsMonitorId, { limit: resultsLimit });
} finally {
setResultsLoading(false);
}
@@ -641,11 +644,11 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
void loadResultsForModal();
}, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]);
// Reload results for the active service monitor chart when range limit changes
// Reload results for the active service monitor chart when time range changes
useEffect(() => {
if (!activeServiceMonitorId) return;
void loadMonitorResults(activeServiceMonitorId, serviceMonitorRangeLimit);
}, [activeServiceMonitorId, serviceMonitorRangeLimit, loadMonitorResults]);
void loadMonitorResults(activeServiceMonitorId, { rangeMs: serviceMonitorRangeMs });
}, [activeServiceMonitorId, serviceMonitorRangeMs, loadMonitorResults]);
const chartData = metrics.map((m) => ({
time: formatTimestamp(m.timestamp, metricsRangeMs),
@@ -1065,7 +1068,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
</div>
) : (
<Card className="w-full">
<Table aria-label="节点列表">
<Table
aria-label="节点列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>状态</TableColumn>
<TableColumn>名称</TableColumn>
@@ -1084,7 +1095,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const isOnline = node.connectionStatus === "online";
return (
<TableRow key={node.id} className="border-b border-divider/50 last:border-b-0">
<TableRow key={node.id}>
<TableCell>
<div className={`w-2 h-2 rounded-full ml-1 ${isOnline ? "bg-success" : "bg-danger"}`} />
</TableCell>
@@ -1314,10 +1325,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false;
const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : [];
const activeLatencyData = [...activeResults]
.slice(0, serviceMonitorRangeLimit)
.reverse()
.map((r) => ({
time: formatTimestamp(r.timestamp),
time: formatTimestamp(r.timestamp, serviceMonitorRangeMs),
latency: r.success === 1 ? r.latencyMs : null,
success: r.success,
}));
@@ -1328,24 +1337,23 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<h3 className="text-lg font-semibold">服务监控图表</h3>
<div className="flex items-center gap-2">
<Select
className="w-28"
selectedKeys={[String(serviceMonitorRangeLimit)]}
className="w-36"
selectedKeys={[String(serviceMonitorRangeMs)]}
onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]);
if (v > 0) setServiceMonitorRangeLimit(v);
if (v > 0) setServiceMonitorRangeMs(v);
}}
>
<SelectItem key="20">20条</SelectItem>
<SelectItem key="50">50条</SelectItem>
<SelectItem key="100">100条</SelectItem>
<SelectItem key="200">200条</SelectItem>
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
</Select>
{resolvedActiveMonitorId != null && (
<Button
size="sm"
variant="flat"
onPress={() => {
void loadMonitorResults(resolvedActiveMonitorId, serviceMonitorRangeLimit);
void loadMonitorResults(resolvedActiveMonitorId, { rangeMs: serviceMonitorRangeMs });
}}
>
<RefreshCw className="w-4 h-4 mr-1" />
@@ -1383,7 +1391,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
setActiveServiceMonitorId(monitor.id);
// Load results for this monitor if not loaded
if (!monitorResults[monitor.id] || monitorResults[monitor.id].length <= 1) {
void loadMonitorResults(monitor.id, serviceMonitorRangeLimit);
void loadMonitorResults(monitor.id, { rangeMs: serviceMonitorRangeMs });
}
}}
>
@@ -1536,7 +1544,15 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
<RefreshCw className="w-6 h-6 animate-spin" />
</div>
) : modalResults.length > 0 ? (
<Table aria-label="监控记录" className="w-full">
<Table
aria-label="监控记录"
className="w-full overflow-x-auto"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>时间</TableColumn>
<TableColumn>结果</TableColumn>
File diff suppressed because it is too large Load Diff
+147
View File
@@ -45,6 +45,15 @@ import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Radio, RadioGroup } from "@/shadcn-bridge/heroui/radio";
import { LayoutGrid, List } from "lucide-react";
import {
Table,
TableHeader,
TableColumn,
TableBody,
TableRow,
TableCell,
} from "@/shadcn-bridge/heroui/table";
import {
createTunnel,
batchDeleteTunnelsWithForwards,
@@ -181,6 +190,10 @@ export default function TunnelPage() {
"",
);
const [isSearchVisible, setIsSearchVisible] = useState(false);
const [viewMode, setViewMode] = useLocalStorageState<"list" | "grid">(
"tunnel-view-mode",
"grid",
);
// 模态框状态
const [modalOpen, setModalOpen] = useState(false);
@@ -1523,6 +1536,14 @@ export default function TunnelPage() {
>
批量
</Button>
<Button
isIconOnly
size="sm"
variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
>
{viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
</Button>
<Button
color="primary"
size="sm"
@@ -1556,6 +1577,131 @@ export default function TunnelPage() {
{/* 隧道卡片网格 */}
{tunnels.length > 0 ? (
viewMode === "list" ? (
<Card>
<Table
aria-label="隧道列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
{selectMode ? <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox
isSelected={selectedIds.size === sortedTunnels.length && sortedTunnels.length > 0}
onValueChange={(checked) => checked ? selectAll() : deselectAll()}
/>
</TableColumn> : <TableColumn className="w-0 p-0 overflow-hidden text-[0px]"></TableColumn>}
<TableColumn>隧道名称</TableColumn>
<TableColumn>类型</TableColumn>
<TableColumn>拓扑</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody items={sortedTunnels}>
{(tunnel) => {
const typeDisplay = getTunnelTypeDisplay(tunnel.type);
const tunnelTypeChipClassName =
tunnel.type === 1
? "text-[10px] h-5 bg-primary-100 text-primary-800 border-primary-300 dark:bg-primary-900/45 dark:text-primary-200 dark:border-primary-700"
: "text-[10px] h-5 bg-success-100 text-success-800 border-success-300 dark:bg-success-900/35 dark:text-success-200 dark:border-success-700";
return (
<TableRow key={tunnel.id}>
{selectMode ? <TableCell className="px-4">
<Checkbox
isSelected={selectedIds.has(tunnel.id)}
onValueChange={() => toggleSelect(tunnel.id)}
/>
</TableCell> : <TableCell className="w-0 p-0 overflow-hidden text-[0px]"></TableCell>}
<TableCell>
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
tunnel.status === 1 ? "bg-success" : "bg-danger"
}`}
/>
<span className="font-medium text-foreground text-sm">
{tunnel.name}
</span>
</div>
</TableCell>
<TableCell>
<Chip
className={tunnelTypeChipClassName}
color={typeDisplay.color as any}
size="sm"
variant="flat"
>
{typeDisplay.text}
</Chip>
</TableCell>
<TableCell>
<div className="flex items-center gap-1.5 text-xs">
<span className="font-semibold text-primary-700 dark:text-primary-400">
{tunnel.inNodeId?.length || 0}入口
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-secondary-700 dark:text-secondary-400">
{tunnel.type === 2 ? tunnel.chainNodes?.length || 0 : 0}跳
</span>
<span className="text-default-400">→</span>
<span className="font-semibold text-success-700 dark:text-success-400">
{tunnel.type === 2 ? tunnel.outNodeId?.length || 0 : tunnel.inNodeId?.length || 0}出口
</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-2 text-xs">
<span className="text-default-500">{getTunnelFlowDisplay(tunnel.flow)}</span>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.trafficRatio}x</span>
{tunnel.type === 2 && tunnel.ipPreference && (
<>
<span className="text-default-300">|</span>
<span className="text-default-500">{tunnel.ipPreference === "v4" ? "IPv4" : "IPv6"}</span>
</>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
size="sm"
variant="flat"
onPress={() => handleEdit(tunnel)}
>
编辑
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
size="sm"
variant="flat"
onPress={() => handleDiagnose(tunnel)}
>
诊断
</Button>
<Button
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
size="sm"
variant="flat"
onPress={() => handleDelete(tunnel)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
);
}}
</TableBody>
</Table>
</Card>
) : (
<DndContext sensors={sensors} onDragEnd={handleDragEnd}>
<SortableContext
items={sortableTunnelIds}
@@ -1836,6 +1982,7 @@ export default function TunnelPage() {
</div>
</SortableContext>
</DndContext>
)
) : (
/* 空状态 */
<Card className="shadow-sm border border-gray-200 dark:border-gray-700 bg-default-50/50">
+48 -64
View File
@@ -1080,10 +1080,17 @@ export default function UserPage() {
</Card>
) : viewMode === "list" ? (
<Card>
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
<Table
aria-label="用户列表"
className="overflow-x-auto min-w-full"
classNames={{
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>
<TableColumn>用户名</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>流量统计</TableColumn>
<TableColumn>配额限制</TableColumn>
<TableColumn>规则数量</TableColumn>
@@ -1101,35 +1108,27 @@ export default function UserPage() {
return (
<TableRow key={user.id}>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-foreground text-sm">
{user.name || user.user}
</span>
<span className="text-xs text-default-500">
@{user.user}
</span>
</div>
</TableCell>
<TableCell>
<div className="flex flex-col gap-1 items-start">
<Chip
className="text-xs"
color={userStatus.color}
size="sm"
variant="flat"
>
{userStatus.text}
</Chip>
{user.disabledByQuota ? (
<Chip
className="text-[10px] h-5 px-1"
color="danger"
size="sm"
variant="flat"
>
配额超额
<div className="flex items-center gap-2">
<div
className={`shrink-0 w-2 h-2 rounded-full ${
userStatus.color === "success"
? "bg-success"
: "bg-danger"
}`}
/>
<div className="flex flex-col">
<span className="font-medium text-foreground text-sm">
{user.name || user.user}
</span>
<span className="text-xs text-default-500">
@{user.user}
</span>
</div>
{user.disabledByQuota && (
<Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
超额
</Chip>
) : null}
)}
</div>
</TableCell>
<TableCell>
@@ -1193,55 +1192,39 @@ export default function UserPage() {
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap items-center gap-2">
<div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button
size="sm"
variant="flat"
color="success"
className="min-h-8 min-w-[50px] px-2"
startContent={<SettingsIcon className="w-3 h-3" />}
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
onPress={() => handleManageTunnels(user)}
>
权限
</Button>
<Button
isIconOnly
size="sm"
variant="light"
className="text-default-600 hover:text-primary min-h-8 min-w-8"
onPress={() => handleEdit(user)}
>
<EditIcon className="w-4 h-4" />
</Button>
<Button
isIconOnly
size="sm"
variant="light"
className="text-default-600 hover:text-warning min-h-8 min-w-8"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-amber-50 text-amber-600 hover:bg-amber-100 dark:bg-amber-950/30 dark:text-amber-400"
title="重置流量"
onPress={() => handleResetFlow(user)}
>
<svg
aria-hidden="true"
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M4 2a1 1 0 011 1v2.101a7.002 7.002 0 0111.601 2.566 1 1 0 11-1.885.666A5.002 5.002 0 005.999 7H9a1 1 0 010 2H4a1 1 0 01-1-1V3a1 1 0 011-1zm.008 9.057a1 1 0 011.276.61A5.002 5.002 0 0014.001 13H11a1 1 0 110-2h5a1 1 0 011 1v5a1 1 0 11-2 0v-2.101a7.002 7.002 0 01-11.601-2.566 1 1 0 01.61-1.276z"
fillRule="evenodd"
/>
</svg>
重置
</Button>
<Button
isIconOnly
size="sm"
variant="light"
className="text-default-600 hover:text-danger min-h-8 min-w-8"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-indigo-50 text-indigo-600 hover:bg-indigo-100 dark:bg-indigo-950/30 dark:text-indigo-400"
onPress={() => handleEdit(user)}
>
编辑
</Button>
<Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-rose-50 text-rose-600 hover:bg-rose-100 dark:bg-rose-950/30 dark:text-rose-400"
onPress={() => handleDelete(user)}
>
<DeleteIcon className="w-4 h-4" />
删除
</Button>
</div>
</TableCell>
@@ -1913,8 +1896,9 @@ export default function UserPage() {
<Table
aria-label="用户隧道权限列表"
classNames={{
wrapper: "shadow-none",
th: "bg-gray-50 dark:bg-gray-800 text-gray-700 dark:text-gray-300 font-medium",
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-default-50/50 transition-colors",
}}
>
<TableHeader>