mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe42a77409 | |||
| ce9abf457f | |||
| 3c57a5ac84 | |||
| ff7c91d277 | |||
| 1498f3052d | |||
| 6b1264ae90 |
@@ -42,6 +42,8 @@ type Handler struct {
|
||||
|
||||
upgradeMu sync.Mutex
|
||||
pendingUpgradeRedeploy map[int64]struct{}
|
||||
|
||||
qualityProber *tunnelQualityProber
|
||||
}
|
||||
|
||||
type loginRequest struct {
|
||||
@@ -93,6 +95,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
|
||||
pendingUpgradeRedeploy: make(map[int64]struct{}),
|
||||
}
|
||||
h.healthCheck = health.NewChecker(repo, h.wsServer)
|
||||
h.qualityProber = newTunnelQualityProber(h)
|
||||
h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
|
||||
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
|
||||
metricInfo := metrics.SystemInfo{
|
||||
@@ -229,6 +232,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
|
||||
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
|
||||
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
|
||||
@@ -1234,6 +1238,21 @@ func nullableNullInt64(v sql.NullInt64) interface{} {
|
||||
return nil
|
||||
}
|
||||
|
||||
// flowCryptoCache caches AES crypto instances by secret to avoid per-request SHA256+GCM init.
|
||||
var flowCryptoCache sync.Map
|
||||
|
||||
func getOrCreateFlowCrypto(secret string) *security.AESCrypto {
|
||||
if v, ok := flowCryptoCache.Load(secret); ok {
|
||||
return v.(*security.AESCrypto)
|
||||
}
|
||||
c, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
flowCryptoCache.Store(secret, c)
|
||||
return c
|
||||
}
|
||||
|
||||
func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
defer body.Close()
|
||||
raw, err := io.ReadAll(body)
|
||||
@@ -1254,8 +1273,8 @@ func readAndDecryptFlowBody(body io.ReadCloser, secret string) (string, error) {
|
||||
return text, nil
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
crypto := getOrCreateFlowCrypto(secret)
|
||||
if crypto == nil {
|
||||
return text, nil
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
|
||||
@@ -18,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
h.jobsCancel = cancel
|
||||
h.jobsStarted = true
|
||||
h.jobsWG.Add(5)
|
||||
h.jobsWG.Add(6)
|
||||
h.jobsMu.Unlock()
|
||||
|
||||
go h.runHourlyStatsLoop(ctx)
|
||||
@@ -26,6 +26,7 @@ func (h *Handler) StartBackgroundJobs() {
|
||||
go h.runNodeRenewalCycleLoop(ctx)
|
||||
go h.runMetricsIngestion(ctx)
|
||||
go h.runHealthChecks(ctx)
|
||||
go h.runTunnelQualityProber(ctx)
|
||||
}
|
||||
|
||||
func (h *Handler) StopBackgroundJobs() {
|
||||
@@ -63,6 +64,13 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runTunnelQualityProber(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
if h.qualityProber != nil {
|
||||
h.qualityProber.Start(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
|
||||
defer h.jobsWG.Done()
|
||||
|
||||
|
||||
@@ -207,6 +207,98 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
|
||||
response.WriteJSON(w, response.OK(metric))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
// Try in-memory cache first
|
||||
if h.qualityProber != nil {
|
||||
items := h.qualityProber.GetAll()
|
||||
if len(items) > 0 {
|
||||
response.WriteJSON(w, response.OK(items))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to database (latest per tunnel)
|
||||
qualities, err := h.repo.GetLatestTunnelQualities()
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
|
||||
for _, q := range qualities {
|
||||
snapshots = append(snapshots, tunnelQualitySnapshot{
|
||||
TunnelID: q.TunnelID,
|
||||
EntryToExitLatency: q.EntryToExitLatency,
|
||||
ExitToBingLatency: q.ExitToBingLatency,
|
||||
EntryToExitLoss: q.EntryToExitLoss,
|
||||
ExitToBingLoss: q.ExitToBingLoss,
|
||||
Success: q.Success == 1,
|
||||
ErrorMessage: q.ErrorMessage,
|
||||
Timestamp: q.Timestamp,
|
||||
})
|
||||
}
|
||||
response.WriteJSON(w, response.OK(snapshots))
|
||||
}
|
||||
|
||||
// monitorTunnelQualityHistory returns quality probe history for charting.
|
||||
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
|
||||
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
|
||||
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
return
|
||||
}
|
||||
if !h.ensureMonitoringAccess(w, r) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now().UnixMilli()
|
||||
startMs := now - defaultMetricsRangeMs
|
||||
endMs := now
|
||||
|
||||
if s := r.URL.Query().Get("start"); s != "" {
|
||||
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
|
||||
startMs = v
|
||||
}
|
||||
}
|
||||
if e := r.URL.Query().Get("end"); e != "" {
|
||||
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
|
||||
endMs = v
|
||||
}
|
||||
}
|
||||
if startMs <= 0 || endMs <= 0 || endMs < startMs {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
|
||||
return
|
||||
}
|
||||
if endMs-startMs > maxMetricsRangeMs {
|
||||
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
|
||||
return
|
||||
}
|
||||
|
||||
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
|
||||
if err != nil {
|
||||
response.WriteJSON(w, response.Err(-2, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
response.WriteJSON(w, response.OK(results))
|
||||
}
|
||||
|
||||
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
response.WriteJSON(w, response.ErrDefault("请求失败"))
|
||||
@@ -216,7 +308,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics")
|
||||
path := r.URL.Path
|
||||
prefix := "/api/v1/monitor/tunnels/"
|
||||
if !strings.HasPrefix(path, prefix) {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的路径"))
|
||||
return
|
||||
}
|
||||
|
||||
rest := strings.TrimPrefix(path, prefix)
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/quality
|
||||
if strings.HasSuffix(rest, "/quality") {
|
||||
h.monitorTunnelQualityHistory(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
|
||||
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
|
||||
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
|
||||
if err != nil || tunnelID <= 0 {
|
||||
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -714,3 +714,20 @@ type ServiceMonitorResult struct {
|
||||
}
|
||||
|
||||
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" }
|
||||
|
||||
// TunnelQuality stores periodic probe results for a tunnel.
|
||||
// Unlike the old upsert model, rows accumulate for history/charting.
|
||||
// Old rows are pruned periodically (default: keep 24h).
|
||||
type TunnelQuality struct {
|
||||
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_quality_tunnel_time,priority:1" json:"tunnelId"`
|
||||
EntryToExitLatency float64 `gorm:"column:entry_to_exit_latency" json:"entryToExitLatency"`
|
||||
ExitToBingLatency float64 `gorm:"column:exit_to_bing_latency" json:"exitToBingLatency"`
|
||||
EntryToExitLoss float64 `gorm:"column:entry_to_exit_loss" json:"entryToExitLoss"`
|
||||
ExitToBingLoss float64 `gorm:"column:exit_to_bing_loss" json:"exitToBingLoss"`
|
||||
Success int `gorm:"not null;default:1" json:"success"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage,omitempty"`
|
||||
Timestamp int64 `gorm:"not null;index:idx_tunnel_quality_tunnel_time,priority:2;index:idx_tunnel_quality_time" json:"timestamp"`
|
||||
}
|
||||
|
||||
func (TunnelQuality) TableName() string { return "tunnel_quality" }
|
||||
|
||||
@@ -51,6 +51,7 @@ type NodeMetric = model.NodeMetric
|
||||
type TunnelMetric = model.TunnelMetric
|
||||
type ServiceMonitor = model.ServiceMonitor
|
||||
type ServiceMonitorResult = model.ServiceMonitorResult
|
||||
type TunnelQuality = model.TunnelQuality
|
||||
|
||||
// ─── Repository ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -191,6 +192,7 @@ func autoMigrateAll(db *gorm.DB) error {
|
||||
&model.TunnelMetric{},
|
||||
&model.ServiceMonitor{},
|
||||
&model.ServiceMonitorResult{},
|
||||
&model.TunnelQuality{},
|
||||
}
|
||||
|
||||
if db.Dialector.Name() != "sqlite" {
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"go-backend/internal/store/model"
|
||||
)
|
||||
|
||||
// InsertTunnelQuality appends a tunnel quality probe result.
|
||||
// (Follows the same pattern as InsertServiceMonitorResult.)
|
||||
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
|
||||
if r == nil || r.db == nil {
|
||||
return errors.New("repository not initialized")
|
||||
}
|
||||
if q == nil || q.TunnelID <= 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(q).Error
|
||||
}
|
||||
|
||||
// GetTunnelQualityHistory returns quality probe results for a tunnel
|
||||
// within a time range, ordered by timestamp ascending.
|
||||
// (Mirrors GetServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var results []model.TunnelQuality
|
||||
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
|
||||
Order("timestamp ASC").
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
|
||||
// (Mirrors GetLatestServiceMonitorResults pattern.)
|
||||
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var results []model.TunnelQuality
|
||||
|
||||
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
|
||||
q := `
|
||||
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
|
||||
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
|
||||
FROM (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
|
||||
FROM tunnel_quality
|
||||
) t
|
||||
WHERE rn = 1
|
||||
ORDER BY tunnel_id ASC
|
||||
`
|
||||
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Fallback for older SQLite
|
||||
results = nil
|
||||
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[int64]struct{}, len(results))
|
||||
out := make([]model.TunnelQuality, 0, len(results))
|
||||
for _, row := range results {
|
||||
if row.TunnelID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[row.TunnelID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[row.TunnelID] = struct{}{}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
|
||||
// (Mirrors PruneServiceMonitorResults pattern.)
|
||||
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
|
||||
if r == nil || r.db == nil {
|
||||
return nil
|
||||
}
|
||||
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
|
||||
}
|
||||
|
||||
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
|
||||
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, errors.New("repository not initialized")
|
||||
}
|
||||
var ids []int64
|
||||
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
|
||||
return ids, err
|
||||
}
|
||||
@@ -39,6 +39,7 @@ type nodeSession struct {
|
||||
nodeID int64
|
||||
secret string
|
||||
conn *connWrap
|
||||
crypto *security.AESCrypto // 缓存的 AES 加密器,避免每条消息重建
|
||||
}
|
||||
|
||||
type commandResponse struct {
|
||||
@@ -211,7 +212,12 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
_ = old.conn.conn.Close()
|
||||
delete(s.byConn, old.conn.conn)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw}
|
||||
// 初始化 AES 加密器并缓存(仅创建一次)
|
||||
var nodeCrypto *security.AESCrypto
|
||||
if strings.TrimSpace(secret) != "" {
|
||||
nodeCrypto, _ = security.NewAESCrypto(secret)
|
||||
}
|
||||
ns := &nodeSession{nodeID: nodeID, secret: secret, conn: cw, crypto: nodeCrypto}
|
||||
s.nodes[nodeID] = ns
|
||||
s.byConn[conn] = ns
|
||||
s.mu.Unlock()
|
||||
@@ -251,7 +257,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
return
|
||||
}
|
||||
|
||||
msg := decryptIfNeeded(payload, secret)
|
||||
msg := decryptIfNeeded(payload, ns.crypto, secret)
|
||||
s.tryResolvePending(nodeID, msg)
|
||||
|
||||
var parsed struct {
|
||||
@@ -259,6 +265,26 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
}
|
||||
if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" {
|
||||
switch parsed.Type {
|
||||
case "metric":
|
||||
// Agent 新版指标消息:{type:"metric", data:{...}}
|
||||
var envelope struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(msg), &envelope); err == nil && len(envelope.Data) > 0 {
|
||||
// 解析 SystemInfo 并调用 hook
|
||||
var sysInfo SystemInfo
|
||||
if json.Unmarshal(envelope.Data, &sysInfo) == nil {
|
||||
s.mu.RLock()
|
||||
onMetric := s.onNodeMetric
|
||||
s.mu.RUnlock()
|
||||
if onMetric != nil {
|
||||
go onMetric(nodeID, sysInfo)
|
||||
}
|
||||
}
|
||||
// 广播内层 data 给前端(保持平坦结构兼容性)
|
||||
s.broadcastTyped(nodeID, "metric", string(envelope.Data))
|
||||
}
|
||||
continue
|
||||
case "UpgradeProgress":
|
||||
s.broadcastTyped(nodeID, "upgrade_progress", msg)
|
||||
continue
|
||||
@@ -270,6 +296,7 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容旧版 Agent:无 type 字段的系统信息消息
|
||||
if looksLikeSystemInfoMessage(msg) {
|
||||
var sysInfo SystemInfo
|
||||
if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil {
|
||||
@@ -372,13 +399,8 @@ func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, tim
|
||||
}
|
||||
|
||||
messageData := rawCmd
|
||||
if strings.TrimSpace(ns.secret) != "" {
|
||||
crypto, err := security.NewAESCrypto(ns.secret)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
}
|
||||
encrypted, err := crypto.Encrypt(rawCmd)
|
||||
if ns.crypto != nil {
|
||||
encrypted, err := ns.crypto.Encrypt(rawCmd)
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return CommandResult{}, err
|
||||
@@ -428,6 +450,11 @@ func (s *Server) tryResolvePending(nodeID int64, message string) {
|
||||
return
|
||||
}
|
||||
|
||||
// 快速短路:指标消息永远不含 requestId,跳过完整 JSON 解析
|
||||
if !strings.Contains(message, "\"requestId\"") {
|
||||
return
|
||||
}
|
||||
|
||||
var resp commandResponse
|
||||
if err := json.Unmarshal([]byte(message), &resp); err != nil {
|
||||
return
|
||||
@@ -545,18 +572,22 @@ func (s *Server) broadcastToAdmins(message string) {
|
||||
}
|
||||
}
|
||||
|
||||
func decryptIfNeeded(payload []byte, secret string) string {
|
||||
func decryptIfNeeded(payload []byte, crypto *security.AESCrypto, secret string) string {
|
||||
text := string(payload)
|
||||
var wrap encryptedMessage
|
||||
if err := json.Unmarshal(payload, &wrap); err != nil || !wrap.Encrypted || strings.TrimSpace(wrap.Data) == "" {
|
||||
return text
|
||||
}
|
||||
|
||||
crypto, err := security.NewAESCrypto(secret)
|
||||
if err != nil {
|
||||
// 优先使用缓存的 crypto 实例
|
||||
c := crypto
|
||||
if c == nil && strings.TrimSpace(secret) != "" {
|
||||
c, _ = security.NewAESCrypto(secret)
|
||||
}
|
||||
if c == nil {
|
||||
return text
|
||||
}
|
||||
plain, err := crypto.Decrypt(wrap.Data)
|
||||
plain, err := c.Decrypt(wrap.Data)
|
||||
if err != nil {
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 053 List View Styling
|
||||
|
||||
## Objective
|
||||
Update the design of Node and User list table views to match the provided screenshot:
|
||||
- Use colored dots for status instead of separate "Status" text columns.
|
||||
- The action buttons should have text labels (e.g. "安装", "编辑") with `variant="flat"` instead of icons.
|
||||
- Add version column for Node list.
|
||||
- Remove traffic columns from Node list as requested.
|
||||
- Adjust User list to match this clean style.
|
||||
|
||||
## Tasks
|
||||
- [x] Update `pages/node.tsx` list view to use the new column layout (Node name with dot, Address, Version, Actions).
|
||||
- [x] Update action buttons in `pages/node.tsx` list view to use text instead of icons.
|
||||
- [x] Update `pages/user.tsx` list view to use the status dot pattern.
|
||||
- [x] Update action buttons in `pages/user.tsx` list view to use text instead of icons.
|
||||
- [x] Ensure `selectionMode="multiple"` (or similar) is properly reflected.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Agent-Panel 通信优化:提升稳定性与效率
|
||||
|
||||
## 背景
|
||||
|
||||
Agent(`go-gost/x/socket/websocket_reporter.go`)与 Panel(`go-backend/internal/ws/server.go`)之间通过 WebSocket 进行实时通信,包括指标上报(每 5s)、命令下发/响应、和流量上报(HTTP)。经过代码审查,以下是发现的问题和优化建议。
|
||||
|
||||
---
|
||||
|
||||
## 发现的问题
|
||||
|
||||
### 1. Keepalive 时序不匹配 —— 导致误断连
|
||||
|
||||
| 参数 | Agent 侧 | Panel 侧 |
|
||||
|------|----------|----------|
|
||||
| Read deadline | `reporterReadWait` = 60s | `wsPongWait` = 45s |
|
||||
| Ping 发送间隔 | 无主动 ping(靠指标数据 5s 续命) | `wsPingPeriod` = 15s |
|
||||
| Write timeout | `reporterWriteWait` = 5s | `wsWriteWait` = 5s |
|
||||
|
||||
**问题**:Panel 每 15s 发 ping,Agent read deadline 60s,但 Panel pong deadline 只有 45s。如果 Agent 的指标消息被延迟(网络抖动),Panel 可能因 pong 超时而关闭连接。两侧的超时参数缺乏协调设计。
|
||||
|
||||
### 2. 固定重连间隔 —— 无退避策略
|
||||
|
||||
Agent 断线后以固定 5s 间隔重试(`reconnectTime = 5 * time.Second`),在 Panel 长时间不可用(升级、网络故障)的情况下,会产生大量无用连接尝试。
|
||||
|
||||
### 3. Panel 侧每次解密都重建 AES 加密器
|
||||
|
||||
`ws/server.go` 的 `decryptIfNeeded()` 和 `SendCommand()` 每次调用都 `security.NewAESCrypto(secret)` 重新创建 cipher(SHA256 + AES-GCM 初始化),对于高频指标消息(5s/次 × N 节点),有不必要的 CPU 开销。
|
||||
|
||||
### 4. 指标消息使用 JSON Text 格式传输
|
||||
|
||||
每 5s 发送一次包含 13 个字段的 SystemInfo JSON,加密后还需 base64 编码,一条消息约 300-500 bytes(加密后约 700 bytes)。对于大量节点场景,存在优化空间。
|
||||
|
||||
### 5. `receiveMessages` 紧循环中有频繁锁竞争
|
||||
|
||||
`receiveMessages()` 在每次 `ReadMessage()` 前都要 `Lock/Unlock connMutex` 检查连接状态,但 `ReadMessage` 本身是阻塞的,实际不需要在循环外检查。
|
||||
|
||||
### 6. 状态变更命令阻塞读消息循环
|
||||
|
||||
`routeCommand` 中的 Service/Chain/Limiter CRUD 命令是同步执行的,包括 `saveConfig()` 文件写入。执行期间会阻塞 `receiveMessages` 的读取循环。
|
||||
|
||||
---
|
||||
|
||||
## 推荐的优化方案(按优先级排列)
|
||||
|
||||
### P0 — 高收益、低风险
|
||||
|
||||
#### 优化 1:协调 Keepalive 参数
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- Agent 增加独立的 WebSocket ping 发送(每 20s),不依赖指标数据来维持连接
|
||||
- 统一 read deadline 设置,确保两侧 read timeout > 2×ping interval
|
||||
|
||||
#### 优化 2:指数退避重连
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- 初始间隔 2s,按指数退避增长至最大 2 分钟
|
||||
- 连接成功后立即重置退避
|
||||
- 增加随机抖动(jitter)避免大量 Agent 同时重连
|
||||
|
||||
#### 优化 3:Panel 侧缓存 AES 加密器
|
||||
|
||||
**文件**:`ws/server.go`
|
||||
|
||||
- 将 `AESCrypto` 实例缓存在 `nodeSession` 中,避免每条消息重建
|
||||
- `SendCommand` 复用缓存实例
|
||||
|
||||
### P1 — 中等收益
|
||||
|
||||
#### 优化 4:减少 `receiveMessages` 锁竞争
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- 将连接状态检查移到循环外,只在出错/关闭时通过 channel 通知退出
|
||||
- 用 `context.WithCancel` 代替锁检查 `connected` flag 来控制生命周期
|
||||
|
||||
#### 优化 5:异步化状态变更命令处理
|
||||
|
||||
**文件**:`websocket_reporter.go`
|
||||
|
||||
- 所有命令统一异步执行(通过 goroutine + response channel),避免阻塞 readLoop
|
||||
- 当前只有 TcpPing/ServiceMonitorCheck/UpgradeAgent/RollbackAgent 是异步的
|
||||
|
||||
---
|
||||
|
||||
## 具体代码变更
|
||||
|
||||
### Agent 侧 (`go-gost/x/socket`)
|
||||
|
||||
---
|
||||
|
||||
#### [MODIFY] [websocket_reporter.go](file:///Users/sagit/Documents/github/flvx/go-gost/x/socket/websocket_reporter.go)
|
||||
|
||||
1. **指数退避重连**:将 `reconnectTime` 从固定 `5s` 改为动态退避字段,增加 `curBackoff/maxBackoff` 字段
|
||||
2. **独立 Ping 发送**:在 `handleConnection()` 中增加 WebSocket ping ticker(20s),独立于指标上报
|
||||
3. **减少锁竞争**:`receiveMessages` 中只在循环入口检查一次连接,此后靠 `ReadMessage` 的 error 退出
|
||||
4. **统一命令异步化**:所有 `routeCommand` 调用统一使用 goroutine
|
||||
|
||||
---
|
||||
|
||||
### Panel 侧 (`go-backend/internal/ws`)
|
||||
|
||||
---
|
||||
|
||||
#### [MODIFY] [server.go](file:///Users/sagit/Documents/github/flvx/go-backend/internal/ws/server.go)
|
||||
|
||||
1. **缓存 AES 加密器**:在 `nodeSession` 中增加 `crypto *security.AESCrypto` 字段,节点连接时初始化
|
||||
2. **`decryptIfNeeded` 接收 crypto 参数**而非 secret 字符串
|
||||
3. **`SendCommand` 使用缓存 crypto** 实例
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
|
||||
```bash
|
||||
# 运行现有 agent 侧单元测试(验证不回归)
|
||||
(cd go-gost/x && go test ./socket/... -v -count=1)
|
||||
|
||||
# 运行现有流量上报测试
|
||||
(cd go-gost/x && go test ./service/... -v -count=1)
|
||||
|
||||
# 运行 panel 侧全部测试
|
||||
(cd go-backend && go test ./... -count=1)
|
||||
```
|
||||
|
||||
### Manual Verification
|
||||
|
||||
> [!IMPORTANT]
|
||||
> 本次改动涉及实时通信核心路径,建议在 staging 环境部署后观察至少 30 分钟:
|
||||
> 1. 检查节点在面板中状态是否正常显示为在线
|
||||
> 2. 手动停止面板后观察 Agent 日志,确认重连间隔呈指数增长
|
||||
> 3. 恢复面板后确认 Agent 能自动恢复连接并恢复指标上报
|
||||
> 4. 通过面板下发命令(如添加/删除 service),确认命令执行成功
|
||||
|
||||
---
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 优化 1:Agent 增加独立 WebSocket ping 发送
|
||||
- [x] 优化 2:Agent 指数退避重连
|
||||
- [x] 优化 3:Panel 缓存 AES 加密器
|
||||
- [x] 优化 4:Agent 减少 receiveMessages 锁竞争
|
||||
- [x] 优化 5:Agent 命令处理统一异步化
|
||||
- [x] 运行现有测试验证不回归
|
||||
@@ -0,0 +1,31 @@
|
||||
# 055 - 隧道质量定时探测 + 实时展示 + 历史图表
|
||||
|
||||
## 背景
|
||||
当前隧道质量检测是手动触发的:用户点击"诊断"按钮 → 后端调用节点 TcpPing → 返回结果。
|
||||
需求:改为**后端定时(每10秒)自动探测**所有启用隧道的质量(入口→出口延迟、出口→Bing延迟),
|
||||
结果保留历史(24h),前端隧道 Tab 实时展示 + 图表历史趋势。
|
||||
|
||||
## 设计原则:与服务监控复用
|
||||
|
||||
| 复用点 | 服务监控 | 隧道质量 |
|
||||
|--------|---------|---------|
|
||||
| 调度方式 | `health.Checker.Start(ctx)` via `jobs.go` | `tunnelQualityProber.Start(ctx)` via `jobs.go` |
|
||||
| 存储模式 | `service_monitor_result` (history, insert) | `tunnel_quality` (history, insert) |
|
||||
| 清理方式 | `PruneServiceMonitorResults(olderThanMs)` | `PruneTunnelQualityResults(olderThanMs)` |
|
||||
| 最新查询 | `GetLatestServiceMonitorResults()` (window func) | `GetLatestTunnelQualities()` (window func) |
|
||||
| 历史查询 | `GetServiceMonitorResults(id, limit)` | `GetTunnelQualityHistory(id, start, end)` |
|
||||
| API 模式 | `GET /monitor/services/{id}/results` | `GET /monitor/tunnels/{id}/quality` |
|
||||
| 前端图表 | Recharts LineChart (延迟趋势) | Recharts LineChart (同样模式) |
|
||||
|
||||
## 任务清单
|
||||
|
||||
- [x] 1. `TunnelQuality` model 改为历史存储(composite index, 非 unique)
|
||||
- [x] 2. Repo 改为 insert(非 upsert),复用服务监控的查询模式
|
||||
- [x] 3. 添加 `PruneTunnelQualityResults` + `GetLatestTunnelQualities` + `GetTunnelQualityHistory`
|
||||
- [x] 4. Prober 生命周期集成到 `jobs.go`(与 healthCheck 同级)
|
||||
- [x] 5. Prober 添加 24h 清理周期
|
||||
- [x] 6. 添加 API `GET /monitor/tunnels/{id}/quality` 返回历史
|
||||
- [x] 7. 前端添加 `getMonitorTunnelQualityHistory()` API
|
||||
- [x] 8. 前端详情页添加质量趋势图表(复用服务监控图表组件模式)
|
||||
- [x] 9. Go 编译 + 测试通过
|
||||
- [x] 10. TypeScript 编译通过
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
@@ -1770,140 +1770,99 @@ export default function NodePage() {
|
||||
<Card>
|
||||
<Table aria-label="节点列表" className="overflow-x-auto min-w-full">
|
||||
<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 +1870,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
MonitorTunnelApiItem,
|
||||
TunnelMetricApiItem,
|
||||
TunnelDiagnosisApiItem,
|
||||
TunnelQualityApiItem,
|
||||
} from "@/api/types";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -23,16 +23,15 @@ import {
|
||||
ArrowRightLeft,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Stethoscope,
|
||||
} from "lucide-react";
|
||||
import toast from "react-hot-toast";
|
||||
|
||||
import {
|
||||
getMonitorTunnels,
|
||||
getTunnelMetrics,
|
||||
diagnoseTunnel,
|
||||
getMonitorTunnelQuality,
|
||||
getMonitorTunnelQualityHistory,
|
||||
} from "@/api";
|
||||
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
|
||||
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
|
||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||
@@ -51,18 +50,7 @@ interface TunnelMonitorViewProps {
|
||||
viewMode?: "list" | "grid";
|
||||
}
|
||||
|
||||
const METRICS_MAX_ROWS = 5000;
|
||||
|
||||
interface TunnelQuality {
|
||||
loading: boolean;
|
||||
entryToExitLatency?: number;
|
||||
exitToBingLatency?: number;
|
||||
entryToExitLoss?: number;
|
||||
exitToBingLoss?: number;
|
||||
results?: TunnelDiagnosisApiItem[];
|
||||
timestamp?: number;
|
||||
error?: string;
|
||||
}
|
||||
const QUALITY_POLL_INTERVAL = 10_000; // 10 seconds
|
||||
|
||||
const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
const date = new Date(ts);
|
||||
@@ -80,6 +68,7 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
|
||||
return date.toLocaleTimeString("zh-CN", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -93,32 +82,60 @@ const formatBytes = (bytes: number): string => {
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
|
||||
};
|
||||
|
||||
/** Render a colored latency value with appropriate visual cue */
|
||||
function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
|
||||
if (loading) {
|
||||
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
|
||||
}
|
||||
if (value === undefined || value < 0) {
|
||||
return <span className="text-default-400">-</span>;
|
||||
}
|
||||
const ms = value.toFixed(0);
|
||||
let colorClass = "text-success";
|
||||
if (value > 200) colorClass = "text-danger";
|
||||
else if (value > 100) colorClass = "text-warning";
|
||||
else if (value > 50) colorClass = "text-primary";
|
||||
|
||||
return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
|
||||
}
|
||||
|
||||
/** Animated pulse dot for live status */
|
||||
function LiveDot() {
|
||||
return (
|
||||
<span className="relative flex h-2 w-2">
|
||||
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-success opacity-75" />
|
||||
<span className="relative inline-flex rounded-full h-2 w-2 bg-success" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
|
||||
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
|
||||
const [tunnelsLoading, setTunnelsLoading] = useState(false);
|
||||
const [tunnelsError, setTunnelsError] = useState<string | null>(null);
|
||||
const [accessDenied, setAccessDenied] = useState<string | null>(null);
|
||||
|
||||
// Quality data from backend periodic probing (latest per tunnel)
|
||||
const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
|
||||
const [qualityLoading, setQualityLoading] = useState(false);
|
||||
const qualityTimerRef = useRef<number | null>(null);
|
||||
|
||||
// Detail view state
|
||||
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
|
||||
|
||||
// Quality history for chart (mirrors service monitor results)
|
||||
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
|
||||
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
|
||||
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
|
||||
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
// Tunnel traffic metrics for chart
|
||||
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
|
||||
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
|
||||
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
|
||||
const [, setTunnelMetricsTruncated] = useState(false);
|
||||
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
|
||||
|
||||
// Tunnel quality (diagnosis) state
|
||||
const [tunnelQualities, setTunnelQualities] = useState<Record<number, TunnelQuality>>({});
|
||||
const diagnosisAbortRef = useRef<Record<number, AbortController>>({});
|
||||
|
||||
// Cleanup abort controllers on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
Object.values(diagnosisAbortRef.current).forEach((c) => c.abort());
|
||||
diagnosisAbortRef.current = {};
|
||||
};
|
||||
}, []);
|
||||
|
||||
// --- Load tunnel list ---
|
||||
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setTunnelsLoading(true);
|
||||
@@ -161,7 +178,75 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
return () => window.clearInterval(timer);
|
||||
}, [loadTunnels]);
|
||||
|
||||
// Load tunnel metrics for detail view
|
||||
// --- Load quality snapshots (auto-polling every 10s) ---
|
||||
const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setQualityLoading(true);
|
||||
try {
|
||||
const response = await getMonitorTunnelQuality();
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
const map: Record<number, TunnelQualityApiItem> = {};
|
||||
for (const q of response.data) {
|
||||
map[q.tunnelId] = q;
|
||||
}
|
||||
setQualityMap(map);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore quality load failures
|
||||
} finally {
|
||||
if (!silent) setQualityLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadQuality();
|
||||
}, [loadQuality]);
|
||||
|
||||
useEffect(() => {
|
||||
qualityTimerRef.current = window.setInterval(() => {
|
||||
void loadQuality({ silent: true });
|
||||
}, QUALITY_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
if (qualityTimerRef.current) {
|
||||
window.clearInterval(qualityTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, [loadQuality]);
|
||||
|
||||
// --- Load quality history for detail chart ---
|
||||
const loadQualityHistory = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
if (!silent) setQualityHistoryLoading(true);
|
||||
try {
|
||||
const end = Date.now();
|
||||
const start = end - qualityRangeMs;
|
||||
const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setQualityHistoryError(null);
|
||||
setQualityHistory(response.data);
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限");
|
||||
return;
|
||||
}
|
||||
setQualityHistoryError(response.msg || "加载质量历史失败");
|
||||
if (!silent) toast.error(response.msg || "加载质量历史失败");
|
||||
} catch {
|
||||
if (!silent) {
|
||||
setQualityHistoryError("加载质量历史失败");
|
||||
}
|
||||
} finally {
|
||||
if (!silent) setQualityHistoryLoading(false);
|
||||
}
|
||||
},
|
||||
[qualityRangeMs],
|
||||
);
|
||||
|
||||
// --- Load tunnel traffic metrics for detail chart ---
|
||||
const loadTunnelMetrics = useCallback(
|
||||
async (tunnelId: number, options?: { silent?: boolean }) => {
|
||||
const silent = options?.silent ?? false;
|
||||
@@ -172,27 +257,16 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
const response = await getTunnelMetrics(tunnelId, start, end);
|
||||
|
||||
if (response.code === 0 && Array.isArray(response.data)) {
|
||||
setAccessDenied(null);
|
||||
setTunnelMetricsError(null);
|
||||
setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS);
|
||||
const ordered = [...response.data].sort(
|
||||
(a, b) => a.timestamp - b.timestamp,
|
||||
);
|
||||
setTunnelMetrics(ordered);
|
||||
return;
|
||||
}
|
||||
if (response.code === 403) {
|
||||
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
|
||||
setTunnelMetricsTruncated(false);
|
||||
setTunnelMetricsError(null);
|
||||
return;
|
||||
}
|
||||
setTunnelMetricsTruncated(false);
|
||||
setTunnelMetricsError(response.msg || "加载隧道指标失败");
|
||||
if (!silent) toast.error(response.msg || "加载隧道指标失败");
|
||||
setTunnelMetricsError(response.msg || "加载流量数据失败");
|
||||
} catch {
|
||||
setTunnelMetricsTruncated(false);
|
||||
if (!silent) setTunnelMetricsError("加载隧道指标失败");
|
||||
if (!silent) setTunnelMetricsError("加载流量数据失败");
|
||||
} finally {
|
||||
if (!silent) setTunnelMetricsLoading(false);
|
||||
}
|
||||
@@ -202,169 +276,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
useEffect(() => {
|
||||
if (detailTunnelId) {
|
||||
void loadQualityHistory(detailTunnelId);
|
||||
void loadTunnelMetrics(detailTunnelId);
|
||||
}
|
||||
}, [detailTunnelId, loadTunnelMetrics]);
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
|
||||
// Auto-refresh detail charts
|
||||
useEffect(() => {
|
||||
if (!detailTunnelId) return;
|
||||
const timer = window.setInterval(() => {
|
||||
void loadQualityHistory(detailTunnelId, { silent: true });
|
||||
void loadTunnelMetrics(detailTunnelId, { silent: true });
|
||||
}, 30_000);
|
||||
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detailTunnelId, loadTunnelMetrics]);
|
||||
}, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
|
||||
|
||||
// Diagnose tunnel quality
|
||||
const diagnoseTunnelQuality = useCallback(async (tunnelId: number) => {
|
||||
// Abort if already running
|
||||
if (diagnosisAbortRef.current[tunnelId]) {
|
||||
diagnosisAbortRef.current[tunnelId].abort();
|
||||
}
|
||||
const abortController = new AbortController();
|
||||
diagnosisAbortRef.current[tunnelId] = abortController;
|
||||
// Chart data for quality history
|
||||
const qualityChartData = qualityHistory.map((q) => ({
|
||||
time: formatTimestamp(q.timestamp, qualityRangeMs),
|
||||
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
|
||||
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
|
||||
entryToExitLoss: q.entryToExitLoss,
|
||||
exitToBingLoss: q.exitToBingLoss,
|
||||
}));
|
||||
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: { loading: true },
|
||||
}));
|
||||
|
||||
try {
|
||||
// Try stream first
|
||||
const results: TunnelDiagnosisApiItem[] = [];
|
||||
const streamResult = await diagnoseTunnelStream(
|
||||
tunnelId,
|
||||
{
|
||||
onItem: (payload) => {
|
||||
results.push(payload.result);
|
||||
},
|
||||
onError: (msg) => {
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: { loading: false, error: msg },
|
||||
}));
|
||||
},
|
||||
},
|
||||
abortController.signal,
|
||||
);
|
||||
|
||||
if (streamResult.fallback) {
|
||||
// Fallback to non-stream API
|
||||
try {
|
||||
const response = await diagnoseTunnel(tunnelId);
|
||||
if (response.code === 0 && response.data?.results) {
|
||||
const apiResults = response.data.results;
|
||||
const quality = extractQualityFromResults(apiResults);
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: {
|
||||
loading: false,
|
||||
...quality,
|
||||
results: apiResults,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: { loading: false, error: response.msg || "诊断失败" },
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: { loading: false, error: "诊断请求失败" },
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Process stream results
|
||||
if (results.length > 0) {
|
||||
const quality = extractQualityFromResults(results);
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: {
|
||||
loading: false,
|
||||
...quality,
|
||||
results,
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
}));
|
||||
} else {
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: { loading: false, error: "未获取到诊断结果" },
|
||||
}));
|
||||
}
|
||||
} catch {
|
||||
if (!abortController.signal.aborted) {
|
||||
setTunnelQualities((prev) => ({
|
||||
...prev,
|
||||
[tunnelId]: { loading: false, error: "诊断失败" },
|
||||
}));
|
||||
}
|
||||
} finally {
|
||||
delete diagnosisAbortRef.current[tunnelId];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const extractQualityFromResults = (
|
||||
results: TunnelDiagnosisApiItem[],
|
||||
): Pick<TunnelQuality, "entryToExitLatency" | "exitToBingLatency" | "entryToExitLoss" | "exitToBingLoss"> => {
|
||||
// The diagnosis results contain hop-by-hop tests
|
||||
// We look for entry→exit (hop between entry and exit nodes)
|
||||
// and exit→Bing (the last hop to external target like bing.com)
|
||||
let entryToExitLatency: number | undefined;
|
||||
let exitToBingLatency: number | undefined;
|
||||
let entryToExitLoss: number | undefined;
|
||||
let exitToBingLoss: number | undefined;
|
||||
|
||||
for (const r of results) {
|
||||
if (!r.success) continue;
|
||||
|
||||
// Entry to Exit: chainType transitions from 1 (entry) to 3 (exit)
|
||||
if (r.fromChainType === 1 && r.toChainType === 3) {
|
||||
entryToExitLatency = r.averageTime;
|
||||
entryToExitLoss = r.packetLoss;
|
||||
}
|
||||
// Or if it's a mid-chain to exit
|
||||
if (r.fromChainType === 2 && r.toChainType === 3) {
|
||||
// Use this if no direct entry→exit
|
||||
if (entryToExitLatency === undefined) {
|
||||
entryToExitLatency = r.averageTime;
|
||||
entryToExitLoss = r.packetLoss;
|
||||
}
|
||||
}
|
||||
|
||||
// Exit to external target (Bing / external)
|
||||
if (r.toChainType === undefined || r.toChainType === 0) {
|
||||
// This typically means it's the exit node testing external
|
||||
if (r.fromChainType === 3) {
|
||||
exitToBingLatency = r.averageTime;
|
||||
exitToBingLoss = r.packetLoss;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no chainType-based matching, use position-based heuristics
|
||||
if (entryToExitLatency === undefined && exitToBingLatency === undefined) {
|
||||
const successResults = results.filter((r) => r.success);
|
||||
if (successResults.length >= 2) {
|
||||
entryToExitLatency = successResults[0].averageTime;
|
||||
entryToExitLoss = successResults[0].packetLoss;
|
||||
exitToBingLatency = successResults[successResults.length - 1].averageTime;
|
||||
exitToBingLoss = successResults[successResults.length - 1].packetLoss;
|
||||
} else if (successResults.length === 1) {
|
||||
entryToExitLatency = successResults[0].averageTime;
|
||||
entryToExitLoss = successResults[0].packetLoss;
|
||||
}
|
||||
}
|
||||
|
||||
return { entryToExitLatency, exitToBingLatency, entryToExitLoss, exitToBingLoss };
|
||||
};
|
||||
|
||||
// Chart data
|
||||
// Chart data for traffic metrics
|
||||
const tunnelChartData = tunnelMetrics.map((m) => ({
|
||||
time: formatTimestamp(m.timestamp, tunnelRangeMs),
|
||||
bytesIn: m.bytesIn,
|
||||
@@ -391,14 +328,35 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
// Aggregate stats
|
||||
const tunnelStats = useMemo(() => {
|
||||
const enabled = tunnels.filter((t) => t.status === 1).length;
|
||||
const disabled = tunnels.length - enabled;
|
||||
const diagnosed = Object.keys(tunnelQualities).filter((k) => {
|
||||
const q = tunnelQualities[Number(k)];
|
||||
return q && !q.loading && !q.error;
|
||||
}).length;
|
||||
|
||||
return { total: tunnels.length, enabled, disabled, diagnosed };
|
||||
}, [tunnels, tunnelQualities]);
|
||||
return { total: tunnels.length, enabled };
|
||||
}, [tunnels]);
|
||||
|
||||
// Last quality update timestamp
|
||||
const lastQualityUpdate = useMemo(() => {
|
||||
let latest = 0;
|
||||
for (const q of Object.values(qualityMap)) {
|
||||
if (q.timestamp > latest) latest = q.timestamp;
|
||||
}
|
||||
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
|
||||
}, [qualityMap]);
|
||||
|
||||
/** Shared time range Select component */
|
||||
const TimeRangeSelect = ({ value, onChange }: { value: number; onChange: (v: number) => void }) => (
|
||||
<Select
|
||||
className="w-36"
|
||||
selectedKeys={[String(value)]}
|
||||
onSelectionChange={(keys) => {
|
||||
const v = Number(Array.from(keys)[0]);
|
||||
if (v > 0) onChange(v);
|
||||
}}
|
||||
>
|
||||
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem>
|
||||
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem>
|
||||
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem>
|
||||
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem>
|
||||
</Select>
|
||||
);
|
||||
|
||||
// =====================
|
||||
// RENDER
|
||||
@@ -423,7 +381,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
|
||||
// ===== DETAIL VIEW =====
|
||||
if (detailTunnelId && detailTunnel) {
|
||||
const quality = tunnelQualities[detailTunnelId];
|
||||
const quality = qualityMap[detailTunnelId];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -431,6 +389,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<Button size="sm" variant="flat" onPress={() => {
|
||||
setDetailTunnelId(null);
|
||||
setQualityHistory([]);
|
||||
setTunnelMetrics([]);
|
||||
}}>
|
||||
<ArrowLeft className="w-4 h-4 mr-1" />
|
||||
@@ -449,25 +408,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 延迟</span>
|
||||
<span className={`text-sm font-semibold font-mono ${quality?.entryToExitLatency !== undefined ? "text-primary" : ""}`}>
|
||||
{quality?.loading ? "检测中..." : quality?.entryToExitLatency !== undefined ? `${quality.entryToExitLatency.toFixed(0)}ms` : "-"}
|
||||
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
入口 → 出口 延迟
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 延迟</span>
|
||||
<span className={`text-sm font-semibold font-mono ${quality?.exitToBingLatency !== undefined ? "text-success" : ""}`}>
|
||||
{quality?.loading ? "检测中..." : quality?.exitToBingLatency !== undefined ? `${quality.exitToBingLatency.toFixed(0)}ms` : "-"}
|
||||
<span className="text-[11px] text-default-500 mb-1.5 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
出口 → Bing 延迟
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
|
||||
</CardBody>
|
||||
</Card>
|
||||
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
|
||||
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
||||
{quality?.loading ? "检测中..." : quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
|
||||
{quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -475,105 +436,104 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
|
||||
<span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
|
||||
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
|
||||
{quality?.loading ? "检测中..." : quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
|
||||
{quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Diagnose Button */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
variant="flat"
|
||||
isLoading={quality?.loading}
|
||||
onPress={() => diagnoseTunnelQuality(detailTunnelId)}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 mr-1" />
|
||||
{quality?.timestamp ? "重新检测质量" : "检测隧道质量"}
|
||||
</Button>
|
||||
{/* Auto-probe status */}
|
||||
<div className="flex items-center gap-2 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>自动探测中(每10秒)</span>
|
||||
{quality?.timestamp && (
|
||||
<span className="text-xs text-default-500">
|
||||
上次检测: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
<span className="text-default-400">
|
||||
· 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
)}
|
||||
{quality?.error && (
|
||||
<span className="text-xs text-danger">{quality.error}</span>
|
||||
{quality?.errorMessage && (
|
||||
<span className="text-danger ml-2">{quality.errorMessage}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Diagnosis Details */}
|
||||
{quality?.results && quality.results.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3 className="text-lg font-semibold">诊断详情</h3>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
<Table aria-label="诊断结果">
|
||||
<TableHeader>
|
||||
<TableColumn>描述</TableColumn>
|
||||
<TableColumn>节点</TableColumn>
|
||||
<TableColumn>目标</TableColumn>
|
||||
<TableColumn>延迟</TableColumn>
|
||||
<TableColumn>丢包</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{quality.results.map((r, idx) => (
|
||||
<TableRow key={idx}>
|
||||
<TableCell>
|
||||
<span className="text-sm">{r.description || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">{r.nodeName || "-"}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">
|
||||
{r.targetIp || "-"}{r.targetPort ? `:${r.targetPort}` : ""}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">
|
||||
{r.averageTime !== undefined ? `${r.averageTime.toFixed(0)}ms` : "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="text-sm font-mono">
|
||||
{r.packetLoss !== undefined ? `${r.packetLoss.toFixed(1)}%` : "-"}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip size="sm" color={r.success ? "success" : "danger"} variant="flat">
|
||||
{r.success ? "成功" : "失败"}
|
||||
</Chip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardBody>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tunnel traffic chart */}
|
||||
{/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">隧道流量趋势</h3>
|
||||
<h3 className="text-lg font-semibold">质量趋势</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Select
|
||||
className="w-36"
|
||||
selectedKeys={[String(tunnelRangeMs)]}
|
||||
onSelectionChange={(keys) => {
|
||||
const v = Number(Array.from(keys)[0]);
|
||||
if (v > 0) setTunnelRangeMs(v);
|
||||
}}
|
||||
<TimeRangeSelect value={qualityRangeMs} onChange={setQualityRangeMs} />
|
||||
<Button
|
||||
isLoading={qualityHistoryLoading}
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => detailTunnelId && loadQualityHistory(detailTunnelId)}
|
||||
>
|
||||
<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>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{qualityHistoryLoading ? (
|
||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||
) : qualityHistoryError ? (
|
||||
<div className="text-center py-8 text-danger text-sm">{qualityHistoryError}</div>
|
||||
) : qualityChartData.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={qualityChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||
<YAxis
|
||||
fontSize={11}
|
||||
tick={{ fill: "#888" }}
|
||||
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
|
||||
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
formatter={(value: unknown, name: string) => {
|
||||
const n = Number(value);
|
||||
if (!Number.isFinite(n)) return "-";
|
||||
const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
|
||||
return [`${n.toFixed(1)}ms`, label];
|
||||
}}
|
||||
/>
|
||||
|
||||
<Line
|
||||
connectNulls
|
||||
dataKey="entryToExit"
|
||||
dot={false}
|
||||
name="entryToExit"
|
||||
stroke="#10b981"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
<Line
|
||||
connectNulls
|
||||
dataKey="exitToBing"
|
||||
dot={false}
|
||||
name="exitToBing"
|
||||
stroke="#3b82f6"
|
||||
strokeWidth={2}
|
||||
type="monotone"
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无质量历史数据</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
|
||||
{/* ====== Traffic Chart (unchanged) ====== */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">流量趋势</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<TimeRangeSelect value={tunnelRangeMs} onChange={setTunnelRangeMs} />
|
||||
<Button
|
||||
isLoading={tunnelMetricsLoading}
|
||||
size="sm"
|
||||
@@ -590,25 +550,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
|
||||
) : tunnelMetricsError ? (
|
||||
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
|
||||
) : tunnelMetrics.length > 0 ? (
|
||||
) : tunnelChartData.length > 0 ? (
|
||||
<div className="h-64">
|
||||
<ResponsiveContainer height="100%" width="100%">
|
||||
<LineChart data={tunnelChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" />
|
||||
<XAxis dataKey="time" fontSize={12} />
|
||||
<YAxis fontSize={12} tickFormatter={tunnelYAxisTickFormatter} />
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
|
||||
<YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={tunnelYAxisTickFormatter} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.8)", border: "none", borderRadius: "8px" }}
|
||||
contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
|
||||
labelStyle={{ color: "#fff" }}
|
||||
formatter={tunnelTooltipFormatter}
|
||||
/>
|
||||
|
||||
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
|
||||
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-default-500">暂无指标数据</div>
|
||||
<div className="text-center py-8 text-default-500">暂无流量数据</div>
|
||||
)}
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -616,13 +577,16 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
);
|
||||
}
|
||||
|
||||
// ===== LIST/GRID VIEW =====
|
||||
// ===== LIST/GRID VIEW (unchanged) =====
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-3 mb-1">
|
||||
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
|
||||
{tunnelStats.diagnosed > 0 && (
|
||||
<Chip color="success" size="sm" variant="flat">已诊断 {tunnelStats.diagnosed}</Chip>
|
||||
{lastQualityUpdate && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-default-500">
|
||||
<LiveDot />
|
||||
<span>自动探测 · 更新于 {lastQualityUpdate}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto">
|
||||
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
|
||||
@@ -643,9 +607,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
{viewMode === "grid" ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{tunnels.map((tunnel) => {
|
||||
const quality = tunnelQualities[tunnel.id];
|
||||
const quality = qualityMap[tunnel.id];
|
||||
const isEnabled = tunnel.status === 1;
|
||||
const overallQuality = quality?.entryToExitLatency !== undefined
|
||||
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
|
||||
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
|
||||
: null;
|
||||
|
||||
@@ -655,10 +619,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
|
||||
onClick={() => setDetailTunnelId(tunnel.id)}
|
||||
>
|
||||
{/* Top gradient bar */}
|
||||
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||
|
||||
{/* Decorative background glow */}
|
||||
<div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
|
||||
|
||||
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
|
||||
@@ -684,59 +645,34 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
</CardHeader>
|
||||
|
||||
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden">
|
||||
{/* Quality metrics */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
入口→出口
|
||||
</div>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{quality?.loading ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||
) : quality?.entryToExitLatency !== undefined ? (
|
||||
`${quality.entryToExitLatency.toFixed(0)}ms`
|
||||
) : "-"}
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="text-[10px] text-default-500 flex items-center gap-1">
|
||||
<Globe className="w-3 h-3" />
|
||||
出口→Bing
|
||||
</div>
|
||||
<span className="font-mono text-xs font-semibold">
|
||||
{quality?.loading ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||
) : quality?.exitToBingLatency !== undefined ? (
|
||||
`${quality.exitToBingLatency.toFixed(0)}ms`
|
||||
) : "-"}
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Action area */}
|
||||
<div className="flex justify-between items-center pt-2 border-t border-divider/50">
|
||||
{quality?.error ? (
|
||||
<span className="text-[11px] text-danger truncate">{quality.error}</span>
|
||||
{quality?.errorMessage ? (
|
||||
<span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
|
||||
) : quality?.timestamp ? (
|
||||
<span className="text-[11px] text-default-500">
|
||||
<span className="text-[11px] text-default-500 flex items-center gap-1">
|
||||
<LiveDot />
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-[11px] text-default-400">未检测</span>
|
||||
<span className="text-[11px] text-default-400">等待探测...</span>
|
||||
)}
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
isLoading={quality?.loading}
|
||||
onPress={() => {
|
||||
diagnoseTunnelQuality(tunnel.id);
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 text-default-500" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardBody>
|
||||
</Card>
|
||||
@@ -752,13 +688,13 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<TableColumn>入口→出口</TableColumn>
|
||||
<TableColumn>出口→Bing</TableColumn>
|
||||
<TableColumn>质量</TableColumn>
|
||||
<TableColumn align="center">操作</TableColumn>
|
||||
<TableColumn>更新时间</TableColumn>
|
||||
</TableHeader>
|
||||
<TableBody emptyContent="暂无隧道">
|
||||
{tunnels.map((tunnel) => {
|
||||
const quality = tunnelQualities[tunnel.id];
|
||||
const quality = qualityMap[tunnel.id];
|
||||
const isEnabled = tunnel.status === 1;
|
||||
const overallQuality = quality?.entryToExitLatency !== undefined
|
||||
const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
|
||||
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
|
||||
: null;
|
||||
|
||||
@@ -777,22 +713,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs whitespace-nowrap">
|
||||
{quality?.loading ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||
) : quality?.entryToExitLatency !== undefined ? (
|
||||
`${quality.entryToExitLatency.toFixed(0)}ms`
|
||||
) : "-"}
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.entryToExitLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-xs whitespace-nowrap">
|
||||
{quality?.loading ? (
|
||||
<RefreshCw className="w-3 h-3 animate-spin inline" />
|
||||
) : quality?.exitToBingLatency !== undefined ? (
|
||||
`${quality.exitToBingLatency.toFixed(0)}ms`
|
||||
) : "-"}
|
||||
</span>
|
||||
<LatencyDisplay value={quality?.exitToBingLatency} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{overallQuality ? (
|
||||
@@ -804,18 +728,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-center gap-1">
|
||||
<Button
|
||||
isIconOnly
|
||||
size="sm"
|
||||
variant="light"
|
||||
isLoading={quality?.loading}
|
||||
onPress={() => diagnoseTunnelQuality(tunnel.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Stethoscope className="w-4 h-4 text-default-500" />
|
||||
</Button>
|
||||
</div>
|
||||
{quality?.timestamp ? (
|
||||
<span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
|
||||
<LiveDot />
|
||||
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-default-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
@@ -1083,7 +1083,6 @@ export default function UserPage() {
|
||||
<Table aria-label="用户列表" className="overflow-x-auto min-w-full">
|
||||
<TableHeader>
|
||||
<TableColumn>用户名</TableColumn>
|
||||
<TableColumn>状态</TableColumn>
|
||||
<TableColumn>流量统计</TableColumn>
|
||||
<TableColumn>配额限制</TableColumn>
|
||||
<TableColumn>规则数量</TableColumn>
|
||||
@@ -1101,35 +1100,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 +1184,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>
|
||||
|
||||
Reference in New Issue
Block a user