feat: periodic tunnel quality probing and monitoring

This commit is contained in:
sagitchu
2026-03-20 12:34:09 +08:00
parent ff7c91d277
commit 3c57a5ac84
11 changed files with 928 additions and 370 deletions
@@ -42,6 +42,8 @@ type Handler struct {
upgradeMu sync.Mutex upgradeMu sync.Mutex
pendingUpgradeRedeploy map[int64]struct{} pendingUpgradeRedeploy map[int64]struct{}
qualityProber *tunnelQualityProber
} }
type loginRequest struct { type loginRequest struct {
@@ -93,6 +95,7 @@ func New(repo *repo.Repository, jwtSecret string) *Handler {
pendingUpgradeRedeploy: make(map[int64]struct{}), pendingUpgradeRedeploy: make(map[int64]struct{}),
} }
h.healthCheck = health.NewChecker(repo, h.wsServer) h.healthCheck = health.NewChecker(repo, h.wsServer)
h.qualityProber = newTunnelQualityProber(h)
h.wsServer.SetNodeOnlineHook(h.onNodeOnline) h.wsServer.SetNodeOnlineHook(h.onNodeOnline)
h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) { h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) {
metricInfo := metrics.SystemInfo{ metricInfo := metrics.SystemInfo{
@@ -229,6 +232,7 @@ func (h *Handler) Register(mux *http.ServeMux) {
mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler) mux.HandleFunc("/api/v1/monitor/nodes/", h.monitorNodeMetricsHandler)
mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler) mux.HandleFunc("/api/v1/monitor/nodes", h.monitorNodeListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler) mux.HandleFunc("/api/v1/monitor/tunnels", h.monitorTunnelListHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/quality", h.monitorTunnelQualityHandler)
mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics) mux.HandleFunc("/api/v1/monitor/tunnels/", h.monitorTunnelMetrics)
mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler) mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler)
mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate) mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate)
+9 -1
View File
@@ -18,7 +18,7 @@ func (h *Handler) StartBackgroundJobs() {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
h.jobsCancel = cancel h.jobsCancel = cancel
h.jobsStarted = true h.jobsStarted = true
h.jobsWG.Add(5) h.jobsWG.Add(6)
h.jobsMu.Unlock() h.jobsMu.Unlock()
go h.runHourlyStatsLoop(ctx) go h.runHourlyStatsLoop(ctx)
@@ -26,6 +26,7 @@ func (h *Handler) StartBackgroundJobs() {
go h.runNodeRenewalCycleLoop(ctx) go h.runNodeRenewalCycleLoop(ctx)
go h.runMetricsIngestion(ctx) go h.runMetricsIngestion(ctx)
go h.runHealthChecks(ctx) go h.runHealthChecks(ctx)
go h.runTunnelQualityProber(ctx)
} }
func (h *Handler) StopBackgroundJobs() { func (h *Handler) StopBackgroundJobs() {
@@ -63,6 +64,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) { func (h *Handler) runHourlyStatsLoop(ctx context.Context) {
defer h.jobsWG.Done() defer h.jobsWG.Done()
+109 -1
View File
@@ -207,6 +207,98 @@ func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request
response.WriteJSON(w, response.OK(metric)) response.WriteJSON(w, response.OK(metric))
} }
func (h *Handler) monitorTunnelQualityHandler(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
// Try in-memory cache first
if h.qualityProber != nil {
items := h.qualityProber.GetAll()
if len(items) > 0 {
response.WriteJSON(w, response.OK(items))
return
}
}
// Fallback to database (latest per tunnel)
qualities, err := h.repo.GetLatestTunnelQualities()
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
snapshots := make([]tunnelQualitySnapshot, 0, len(qualities))
for _, q := range qualities {
snapshots = append(snapshots, tunnelQualitySnapshot{
TunnelID: q.TunnelID,
EntryToExitLatency: q.EntryToExitLatency,
ExitToBingLatency: q.ExitToBingLatency,
EntryToExitLoss: q.EntryToExitLoss,
ExitToBingLoss: q.ExitToBingLoss,
Success: q.Success == 1,
ErrorMessage: q.ErrorMessage,
Timestamp: q.Timestamp,
})
}
response.WriteJSON(w, response.OK(snapshots))
}
// monitorTunnelQualityHistory returns quality probe history for charting.
// GET /api/v1/monitor/tunnels/{id}/quality?start=...&end=...
// Mirrors monitorTunnelMetrics / monitorServiceResultsHandler pattern.
func (h *Handler) monitorTunnelQualityHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败"))
return
}
if !h.ensureMonitoringAccess(w, r) {
return
}
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/quality")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
return
}
now := time.Now().UnixMilli()
startMs := now - defaultMetricsRangeMs
endMs := now
if s := r.URL.Query().Get("start"); s != "" {
if v, err := strconv.ParseInt(s, 10, 64); err == nil {
startMs = v
}
}
if e := r.URL.Query().Get("end"); e != "" {
if v, err := strconv.ParseInt(e, 10, 64); err == nil {
endMs = v
}
}
if startMs <= 0 || endMs <= 0 || endMs < startMs {
response.WriteJSON(w, response.ErrDefault("无效的时间范围"))
return
}
if endMs-startMs > maxMetricsRangeMs {
response.WriteJSON(w, response.ErrDefault("时间范围过大"))
return
}
results, err := h.repo.GetTunnelQualityHistory(tunnelID, startMs, endMs)
if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error()))
return
}
response.WriteJSON(w, response.OK(results))
}
func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) { func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet { if r.Method != http.MethodGet {
response.WriteJSON(w, response.ErrDefault("请求失败")) response.WriteJSON(w, response.ErrDefault("请求失败"))
@@ -216,7 +308,23 @@ func (h *Handler) monitorTunnelMetrics(w http.ResponseWriter, r *http.Request) {
return return
} }
tunnelIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/tunnels/", "/metrics") path := r.URL.Path
prefix := "/api/v1/monitor/tunnels/"
if !strings.HasPrefix(path, prefix) {
response.WriteJSON(w, response.ErrDefault("无效的路径"))
return
}
rest := strings.TrimPrefix(path, prefix)
// Route: /api/v1/monitor/tunnels/{id}/quality
if strings.HasSuffix(rest, "/quality") {
h.monitorTunnelQualityHistory(w, r)
return
}
// Route: /api/v1/monitor/tunnels/{id}/metrics (original)
tunnelIDStr := extractPathParam(path, prefix, "/metrics")
tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64) tunnelID, err := strconv.ParseInt(tunnelIDStr, 10, 64)
if err != nil || tunnelID <= 0 { if err != nil || tunnelID <= 0 {
response.WriteJSON(w, response.ErrDefault("无效的隧道ID")) response.WriteJSON(w, response.ErrDefault("无效的隧道ID"))
@@ -0,0 +1,332 @@
package handler
import (
"context"
"log"
"sync"
"time"
"go-backend/internal/store/model"
)
const (
tunnelQualityProbeInterval = 10 * time.Second
tunnelQualityProbeTimeout = 8 * time.Second
tunnelQualityPingTimeoutMs = 5000
tunnelQualityRetention = 24 * time.Hour // keep 24h of history
tunnelQualityPruneInterval = 10 * time.Minute
)
// tunnelQualitySnapshot is the in-memory latest probe result for a tunnel.
type tunnelQualitySnapshot struct {
TunnelID int64 `json:"tunnelId"`
EntryToExitLatency float64 `json:"entryToExitLatency"`
ExitToBingLatency float64 `json:"exitToBingLatency"`
EntryToExitLoss float64 `json:"entryToExitLoss"`
ExitToBingLoss float64 `json:"exitToBingLoss"`
Success bool `json:"success"`
ErrorMessage string `json:"errorMessage,omitempty"`
Timestamp int64 `json:"timestamp"`
}
// tunnelQualityProber runs periodic TCP ping probes against all enabled tunnels.
// Design mirrors health.Checker: background goroutine with worker pool + scheduled cleanup.
type tunnelQualityProber struct {
handler *Handler
cache sync.Map // tunnelID (int64) → *tunnelQualitySnapshot
ctx context.Context
cancel context.CancelFunc
interval time.Duration
lastPrune int64
}
// newTunnelQualityProber creates a new prober (not yet running).
func newTunnelQualityProber(h *Handler) *tunnelQualityProber {
ctx, cancel := context.WithCancel(context.Background())
return &tunnelQualityProber{
handler: h,
ctx: ctx,
cancel: cancel,
interval: tunnelQualityProbeInterval,
}
}
// Start launches the background probe loop (call from jobs.go).
func (p *tunnelQualityProber) Start(ctx context.Context) {
// Use the provided context so we stop with other background jobs.
p.ctx, p.cancel = context.WithCancel(ctx)
p.loop()
}
// Stop halts the background probe loop.
func (p *tunnelQualityProber) Stop() {
p.cancel()
}
// GetAll returns all cached quality snapshots (latest per tunnel).
func (p *tunnelQualityProber) GetAll() []tunnelQualitySnapshot {
var items []tunnelQualitySnapshot
p.cache.Range(func(_, value interface{}) bool {
if snap, ok := value.(*tunnelQualitySnapshot); ok {
items = append(items, *snap)
}
return true
})
return items
}
func (p *tunnelQualityProber) loop() {
// Initial delay to let the system boot up
select {
case <-time.After(5 * time.Second):
case <-p.ctx.Done():
return
}
// Run once immediately
p.probeAll()
ticker := time.NewTicker(p.interval)
defer ticker.Stop()
for {
select {
case <-p.ctx.Done():
return
case <-ticker.C:
p.probeAll()
p.maybePrune()
}
}
}
// maybePrune deletes old quality rows periodically (mirrors PruneServiceMonitorResults).
func (p *tunnelQualityProber) maybePrune() {
now := time.Now().UnixMilli()
if p.lastPrune > 0 && now-p.lastPrune < int64(tunnelQualityPruneInterval/time.Millisecond) {
return
}
p.lastPrune = now
h := p.handler
if h == nil || h.repo == nil {
return
}
cutoff := now - int64(tunnelQualityRetention/time.Millisecond)
if err := h.repo.PruneTunnelQualityResults(cutoff); err != nil {
log.Printf("tunnel_quality_prober: prune err=%v", err)
}
}
func (p *tunnelQualityProber) probeAll() {
h := p.handler
if h == nil || h.repo == nil {
return
}
tunnelIDs, err := h.repo.ListEnabledTunnelIDs()
if err != nil {
log.Printf("tunnel_quality_prober: list enabled tunnels err=%v", err)
return
}
if len(tunnelIDs) == 0 {
return
}
// Probe tunnels concurrently with a worker limit
// (mirrors health.Checker worker pool pattern)
const maxWorkers = 4
sem := make(chan struct{}, maxWorkers)
var wg sync.WaitGroup
for _, tunnelID := range tunnelIDs {
select {
case <-p.ctx.Done():
return
default:
}
wg.Add(1)
sem <- struct{}{}
go func(tid int64) {
defer wg.Done()
defer func() { <-sem }()
p.probeTunnel(tid)
}(tunnelID)
}
wg.Wait()
}
func (p *tunnelQualityProber) probeTunnel(tunnelID int64) {
h := p.handler
if h == nil || h.repo == nil {
return
}
now := time.Now().UnixMilli()
snap := &tunnelQualitySnapshot{
TunnelID: tunnelID,
Timestamp: now,
}
// Get tunnel chain info
tunnel, err := h.getTunnelRecord(tunnelID)
if err != nil {
snap.ErrorMessage = "隧道不存在"
p.storeResult(snap)
return
}
chainRows, err := h.listChainNodesForTunnel(tunnelID)
if err != nil || len(chainRows) == 0 {
snap.ErrorMessage = "隧道配置不完整"
p.storeResult(snap)
return
}
ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
inNodes, _, outNodes := splitChainNodeGroups(chainRows)
options := diagnosisExecOptions{
commandTimeout: tunnelQualityProbeTimeout,
pingTimeoutMS: tunnelQualityPingTimeoutMs,
timeoutMessage: "探测超时",
}
switch tunnel.Type {
case 1:
// Port forwarding: entry → Bing only
if len(inNodes) > 0 {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
snap.Success = true
} else {
snap.ErrorMessage = err.Error()
}
}
case 2:
// Tunnel forwarding: entry → exit + exit → Bing
probeOK := true
if len(inNodes) > 0 && len(outNodes) > 0 {
// Entry → Exit
targetNode, nodeErr := h.getNodeRecord(outNodes[0].NodeID)
if nodeErr == nil && targetNode != nil {
fromNode, _ := h.getNodeRecord(inNodes[0].NodeID)
targetIP, targetPort, resolveErr := resolveChainProbeTarget(fromNode, targetNode, outNodes[0].Port, ipPreference, outNodes[0].ConnectIP)
if resolveErr == nil {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, targetIP, targetPort, options)
if err == nil {
snap.EntryToExitLatency = lat
snap.EntryToExitLoss = loss
} else {
snap.EntryToExitLatency = -1
snap.EntryToExitLoss = 100
probeOK = false
}
} else {
snap.ErrorMessage = resolveErr.Error()
probeOK = false
}
} else {
snap.ErrorMessage = "出口节点不可用"
probeOK = false
}
}
// Exit → Bing
if len(outNodes) > 0 {
lat, loss, err := p.tcpPingNode(outNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
} else {
if snap.ErrorMessage == "" {
snap.ErrorMessage = err.Error()
}
probeOK = false
}
}
snap.Success = probeOK
default:
// Unknown type: entry → Bing
if len(inNodes) > 0 {
lat, loss, err := p.tcpPingNode(inNodes[0].NodeID, "www.bing.com", 443, options)
if err == nil {
snap.ExitToBingLatency = lat
snap.ExitToBingLoss = loss
snap.Success = true
} else {
snap.ErrorMessage = err.Error()
}
}
}
p.storeResult(snap)
}
func (p *tunnelQualityProber) tcpPingNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (latency float64, loss float64, err error) {
h := p.handler
if h == nil {
return 0, 100, nil
}
node, nodeErr := h.getNodeRecord(nodeID)
if nodeErr != nil {
return 0, 100, nodeErr
}
var pingData map[string]interface{}
var pingErr error
if node != nil && node.IsRemote == 1 {
pingData, pingErr = h.tcpPingViaRemoteNode(node, ip, port, options)
} else {
pingData, pingErr = h.tcpPingViaNode(nodeID, ip, port, options)
}
if pingErr != nil {
return 0, 100, pingErr
}
avgTime := asFloat(pingData["averageTime"], 0)
packetLoss := asFloat(pingData["packetLoss"], 100)
return avgTime, packetLoss, nil
}
func (p *tunnelQualityProber) storeResult(snap *tunnelQualitySnapshot) {
if snap == nil {
return
}
// Update in-memory cache (latest per tunnel)
p.cache.Store(snap.TunnelID, snap)
// Persist to database (history)
h := p.handler
if h == nil || h.repo == nil {
return
}
successInt := 0
if snap.Success {
successInt = 1
}
q := &model.TunnelQuality{
TunnelID: snap.TunnelID,
EntryToExitLatency: snap.EntryToExitLatency,
ExitToBingLatency: snap.ExitToBingLatency,
EntryToExitLoss: snap.EntryToExitLoss,
ExitToBingLoss: snap.ExitToBingLoss,
Success: successInt,
ErrorMessage: snap.ErrorMessage,
Timestamp: snap.Timestamp,
}
if err := h.repo.InsertTunnelQuality(q); err != nil {
log.Printf("tunnel_quality_prober: insert db err=%v tunnel_id=%d", err, snap.TunnelID)
}
}
+17
View File
@@ -714,3 +714,20 @@ type ServiceMonitorResult struct {
} }
func (ServiceMonitorResult) TableName() string { return "service_monitor_result" } 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 TunnelMetric = model.TunnelMetric
type ServiceMonitor = model.ServiceMonitor type ServiceMonitor = model.ServiceMonitor
type ServiceMonitorResult = model.ServiceMonitorResult type ServiceMonitorResult = model.ServiceMonitorResult
type TunnelQuality = model.TunnelQuality
// ─── Repository ────────────────────────────────────────────────────── // ─── Repository ──────────────────────────────────────────────────────
@@ -191,6 +192,7 @@ func autoMigrateAll(db *gorm.DB) error {
&model.TunnelMetric{}, &model.TunnelMetric{},
&model.ServiceMonitor{}, &model.ServiceMonitor{},
&model.ServiceMonitorResult{}, &model.ServiceMonitorResult{},
&model.TunnelQuality{},
} }
if db.Dialector.Name() != "sqlite" { if db.Dialector.Name() != "sqlite" {
@@ -0,0 +1,98 @@
package repo
import (
"errors"
"go-backend/internal/store/model"
)
// InsertTunnelQuality appends a tunnel quality probe result.
// (Follows the same pattern as InsertServiceMonitorResult.)
func (r *Repository) InsertTunnelQuality(q *model.TunnelQuality) error {
if r == nil || r.db == nil {
return errors.New("repository not initialized")
}
if q == nil || q.TunnelID <= 0 {
return nil
}
return r.db.Create(q).Error
}
// GetTunnelQualityHistory returns quality probe results for a tunnel
// within a time range, ordered by timestamp ascending.
// (Mirrors GetServiceMonitorResults pattern.)
func (r *Repository) GetTunnelQualityHistory(tunnelID int64, startMs, endMs int64) ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var results []model.TunnelQuality
err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs).
Order("timestamp ASC").
Find(&results).Error
return results, err
}
// GetLatestTunnelQualities returns the newest quality result per tunnel_id.
// (Mirrors GetLatestServiceMonitorResults pattern.)
func (r *Repository) GetLatestTunnelQualities() ([]model.TunnelQuality, error) {
if r == nil || r.db == nil {
return nil, nil
}
var results []model.TunnelQuality
// Use window function (works on modern SQLite 3.25+ and PostgreSQL).
q := `
SELECT id, tunnel_id, entry_to_exit_latency, exit_to_bing_latency,
entry_to_exit_loss, exit_to_bing_loss, success, error_message, timestamp
FROM (
SELECT *, ROW_NUMBER() OVER (PARTITION BY tunnel_id ORDER BY timestamp DESC, id DESC) AS rn
FROM tunnel_quality
) t
WHERE rn = 1
ORDER BY tunnel_id ASC
`
if err := r.db.Raw(q).Scan(&results).Error; err == nil {
return results, nil
}
// Fallback for older SQLite
results = nil
err := r.db.Order("timestamp DESC, id DESC").Limit(5000).Find(&results).Error
if err != nil {
return nil, err
}
seen := make(map[int64]struct{}, len(results))
out := make([]model.TunnelQuality, 0, len(results))
for _, row := range results {
if row.TunnelID <= 0 {
continue
}
if _, ok := seen[row.TunnelID]; ok {
continue
}
seen[row.TunnelID] = struct{}{}
out = append(out, row)
}
return out, nil
}
// PruneTunnelQualityResults deletes quality results older than the given timestamp.
// (Mirrors PruneServiceMonitorResults pattern.)
func (r *Repository) PruneTunnelQualityResults(olderThanMs int64) error {
if r == nil || r.db == nil {
return nil
}
return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelQuality{}).Error
}
// ListEnabledTunnelIDs returns IDs of all tunnels with status=1.
func (r *Repository) ListEnabledTunnelIDs() ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.Tunnel{}).Where("status = ?", 1).Pluck("id", &ids).Error
return ids, err
}
@@ -0,0 +1,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 编译通过
+20
View File
@@ -40,6 +40,7 @@ import type {
MonitorTunnelApiItem, MonitorTunnelApiItem,
MonitorPermissionApiItem, MonitorPermissionApiItem,
MonitorAccessApiData, MonitorAccessApiData,
TunnelQualityApiItem,
} from "./types"; } from "./types";
import axios from "axios"; import axios from "axios";
@@ -451,6 +452,25 @@ export const getTunnelMetrics = (
export const getMonitorTunnels = () => export const getMonitorTunnels = () =>
Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels"); Network.get<MonitorTunnelApiItem[]>("/monitor/tunnels");
export const getMonitorTunnelQuality = () =>
Network.get<TunnelQualityApiItem[]>("/monitor/tunnels/quality");
export const getMonitorTunnelQualityHistory = (
tunnelId: number,
start?: number,
end?: number,
) => {
const params: Record<string, string> = {};
if (start) params.start = String(start);
if (end) params.end = String(end);
return Network.get<TunnelQualityApiItem[]>(
`/monitor/tunnels/${tunnelId}/quality`,
params,
);
};
export const getServiceMonitorList = () => export const getServiceMonitorList = () =>
Network.get<ServiceMonitorApiItem[]>("/monitor/services"); Network.get<ServiceMonitorApiItem[]>("/monitor/services");
+11
View File
@@ -487,3 +487,14 @@ export interface MonitorAccessApiData {
allowed: boolean; allowed: boolean;
reason?: string; reason?: string;
} }
export interface TunnelQualityApiItem {
tunnelId: number;
entryToExitLatency: number;
exitToBingLatency: number;
entryToExitLoss: number;
exitToBingLoss: number;
success: boolean;
errorMessage?: string;
timestamp: number;
}
@@ -1,7 +1,7 @@
import type { import type {
MonitorTunnelApiItem, MonitorTunnelApiItem,
TunnelMetricApiItem, TunnelMetricApiItem,
TunnelDiagnosisApiItem, TunnelQualityApiItem,
} from "@/api/types"; } from "@/api/types";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -13,6 +13,7 @@ import {
CartesianGrid, CartesianGrid,
Tooltip, Tooltip,
ResponsiveContainer, ResponsiveContainer,
Legend,
} from "recharts"; } from "recharts";
import { import {
RefreshCw, RefreshCw,
@@ -23,16 +24,15 @@ import {
ArrowRightLeft, ArrowRightLeft,
Wifi, Wifi,
WifiOff, WifiOff,
Stethoscope,
} from "lucide-react"; } from "lucide-react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import {
getMonitorTunnels, getMonitorTunnels,
getTunnelMetrics, getTunnelMetrics,
diagnoseTunnel, getMonitorTunnelQuality,
getMonitorTunnelQualityHistory,
} from "@/api"; } from "@/api";
import { diagnoseTunnelStream } from "@/api/diagnosis-stream";
import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis"; import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
@@ -51,18 +51,7 @@ interface TunnelMonitorViewProps {
viewMode?: "list" | "grid"; viewMode?: "list" | "grid";
} }
const METRICS_MAX_ROWS = 5000; const QUALITY_POLL_INTERVAL = 10_000; // 10 seconds
interface TunnelQuality {
loading: boolean;
entryToExitLatency?: number;
exitToBingLatency?: number;
entryToExitLoss?: number;
exitToBingLoss?: number;
results?: TunnelDiagnosisApiItem[];
timestamp?: number;
error?: string;
}
const formatTimestamp = (ts: number, rangeMs?: number): string => { const formatTimestamp = (ts: number, rangeMs?: number): string => {
const date = new Date(ts); const date = new Date(ts);
@@ -80,6 +69,7 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
return date.toLocaleTimeString("zh-CN", { return date.toLocaleTimeString("zh-CN", {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
second: "2-digit",
}); });
}; };
@@ -93,32 +83,60 @@ const formatBytes = (bytes: number): string => {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; 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) { export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]); const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false); const [tunnelsLoading, setTunnelsLoading] = useState(false);
const [tunnelsError, setTunnelsError] = useState<string | null>(null); const [tunnelsError, setTunnelsError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = 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 // Detail view state
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null); 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 [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null); const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
const [, setTunnelMetricsTruncated] = useState(false);
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
// Tunnel quality (diagnosis) state // --- Load tunnel list ---
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 = {};
};
}, []);
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => { const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
if (!silent) setTunnelsLoading(true); if (!silent) setTunnelsLoading(true);
@@ -161,7 +179,75 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [loadTunnels]); }, [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( const loadTunnelMetrics = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => { async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
@@ -172,27 +258,16 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
const response = await getTunnelMetrics(tunnelId, start, end); const response = await getTunnelMetrics(tunnelId, start, end);
if (response.code === 0 && Array.isArray(response.data)) { if (response.code === 0 && Array.isArray(response.data)) {
setAccessDenied(null);
setTunnelMetricsError(null); setTunnelMetricsError(null);
setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS);
const ordered = [...response.data].sort( const ordered = [...response.data].sort(
(a, b) => a.timestamp - b.timestamp, (a, b) => a.timestamp - b.timestamp,
); );
setTunnelMetrics(ordered); setTunnelMetrics(ordered);
return; return;
} }
if (response.code === 403) { setTunnelMetricsError(response.msg || "加载流量数据失败");
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
setTunnelMetricsTruncated(false);
setTunnelMetricsError(null);
return;
}
setTunnelMetricsTruncated(false);
setTunnelMetricsError(response.msg || "加载隧道指标失败");
if (!silent) toast.error(response.msg || "加载隧道指标失败");
} catch { } catch {
setTunnelMetricsTruncated(false); if (!silent) setTunnelMetricsError("加载流量数据失败");
if (!silent) setTunnelMetricsError("加载隧道指标失败");
} finally { } finally {
if (!silent) setTunnelMetricsLoading(false); if (!silent) setTunnelMetricsLoading(false);
} }
@@ -202,169 +277,32 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
useEffect(() => { useEffect(() => {
if (detailTunnelId) { if (detailTunnelId) {
void loadQualityHistory(detailTunnelId);
void loadTunnelMetrics(detailTunnelId); void loadTunnelMetrics(detailTunnelId);
} }
}, [detailTunnelId, loadTunnelMetrics]); }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
// Auto-refresh detail charts
useEffect(() => { useEffect(() => {
if (!detailTunnelId) return; if (!detailTunnelId) return;
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
void loadQualityHistory(detailTunnelId, { silent: true });
void loadTunnelMetrics(detailTunnelId, { silent: true }); void loadTunnelMetrics(detailTunnelId, { silent: true });
}, 30_000); }, 30_000);
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, [detailTunnelId, loadTunnelMetrics]); }, [detailTunnelId, loadQualityHistory, loadTunnelMetrics]);
// Diagnose tunnel quality // Chart data for quality history
const diagnoseTunnelQuality = useCallback(async (tunnelId: number) => { const qualityChartData = qualityHistory.map((q) => ({
// Abort if already running time: formatTimestamp(q.timestamp, qualityRangeMs),
if (diagnosisAbortRef.current[tunnelId]) { entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
diagnosisAbortRef.current[tunnelId].abort(); exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
} entryToExitLoss: q.entryToExitLoss,
const abortController = new AbortController(); exitToBingLoss: q.exitToBingLoss,
diagnosisAbortRef.current[tunnelId] = abortController; }));
setTunnelQualities((prev) => ({ // Chart data for traffic metrics
...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
const tunnelChartData = tunnelMetrics.map((m) => ({ const tunnelChartData = tunnelMetrics.map((m) => ({
time: formatTimestamp(m.timestamp, tunnelRangeMs), time: formatTimestamp(m.timestamp, tunnelRangeMs),
bytesIn: m.bytesIn, bytesIn: m.bytesIn,
@@ -391,14 +329,35 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// Aggregate stats // Aggregate stats
const tunnelStats = useMemo(() => { const tunnelStats = useMemo(() => {
const enabled = tunnels.filter((t) => t.status === 1).length; 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 }; return { total: tunnels.length, enabled };
}, [tunnels, tunnelQualities]); }, [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 // RENDER
@@ -423,7 +382,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
// ===== DETAIL VIEW ===== // ===== DETAIL VIEW =====
if (detailTunnelId && detailTunnel) { if (detailTunnelId && detailTunnel) {
const quality = tunnelQualities[detailTunnelId]; const quality = qualityMap[detailTunnelId];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -431,6 +390,7 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<Button size="sm" variant="flat" onPress={() => { <Button size="sm" variant="flat" onPress={() => {
setDetailTunnelId(null); setDetailTunnelId(null);
setQualityHistory([]);
setTunnelMetrics([]); setTunnelMetrics([]);
}}> }}>
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
@@ -449,25 +409,27 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <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"> <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]"> <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-[11px] text-default-500 mb-1.5 flex items-center gap-1">
<span className={`text-sm font-semibold font-mono ${quality?.entryToExitLatency !== undefined ? "text-primary" : ""}`}> <Zap className="w-3 h-3" />
{quality?.loading ? "检测中..." : quality?.entryToExitLatency !== undefined ? `${quality.entryToExitLatency.toFixed(0)}ms` : "-"} 入口 → 出口 延迟
</span> </span>
<LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
</CardBody> </CardBody>
</Card> </Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50"> <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]"> <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-[11px] text-default-500 mb-1.5 flex items-center gap-1">
<span className={`text-sm font-semibold font-mono ${quality?.exitToBingLatency !== undefined ? "text-success" : ""}`}> <Globe className="w-3 h-3" />
{quality?.loading ? "检测中..." : quality?.exitToBingLatency !== undefined ? `${quality.exitToBingLatency.toFixed(0)}ms` : "-"} 出口 → Bing 延迟
</span> </span>
<LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
</CardBody> </CardBody>
</Card> </Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50"> <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]"> <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-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}> <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> </span>
</CardBody> </CardBody>
</Card> </Card>
@@ -475,105 +437,110 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]"> <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-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
<span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}> <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> </span>
</CardBody> </CardBody>
</Card> </Card>
</div> </div>
{/* Diagnose Button */} {/* Auto-probe status */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2 text-xs text-default-500">
<Button <LiveDot />
size="sm" <span>自动探测中(每10秒)</span>
color="primary"
variant="flat"
isLoading={quality?.loading}
onPress={() => diagnoseTunnelQuality(detailTunnelId)}
>
<Stethoscope className="w-4 h-4 mr-1" />
{quality?.timestamp ? "重新检测质量" : "检测隧道质量"}
</Button>
{quality?.timestamp && ( {quality?.timestamp && (
<span className="text-xs text-default-500"> <span className="text-default-400">
上次检测: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} · 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span> </span>
)} )}
{quality?.error && ( {quality?.errorMessage && (
<span className="text-xs text-danger">{quality.error}</span> <span className="text-danger ml-2">{quality.errorMessage}</span>
)} )}
</div> </div>
{/* Diagnosis Details */} {/* ====== Quality History Chart (mirrors service monitor chart) ====== */}
{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 */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between"> <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"> <div className="flex items-center gap-2">
<Select <TimeRangeSelect value={qualityRangeMs} onChange={setQualityRangeMs} />
className="w-36" <Button
selectedKeys={[String(tunnelRangeMs)]} isLoading={qualityHistoryLoading}
onSelectionChange={(keys) => { size="sm"
const v = Number(Array.from(keys)[0]); variant="flat"
if (v > 0) setTunnelRangeMs(v); onPress={() => detailTunnelId && loadQualityHistory(detailTunnelId)}
}}
> >
<SelectItem key={String(15 * 60 * 1000)}>15分钟</SelectItem> <RefreshCw className="w-4 h-4 mr-1" />
<SelectItem key={String(60 * 60 * 1000)}>1小时</SelectItem> 刷新
<SelectItem key={String(6 * 60 * 60 * 1000)}>6小时</SelectItem> </Button>
<SelectItem key={String(24 * 60 * 60 * 1000)}>24小时</SelectItem> </div>
</Select> </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) => `${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];
}}
/>
<Legend
formatter={(value: string) => {
if (value === "entryToExit") return "入口→出口";
if (value === "exitToBing") return "出口→Bing";
return value;
}}
/>
<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 <Button
isLoading={tunnelMetricsLoading} isLoading={tunnelMetricsLoading}
size="sm" size="sm"
@@ -590,25 +557,26 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div> <div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
) : tunnelMetricsError ? ( ) : tunnelMetricsError ? (
<div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div> <div className="text-center py-8 text-danger text-sm">{tunnelMetricsError}</div>
) : tunnelMetrics.length > 0 ? ( ) : tunnelChartData.length > 0 ? (
<div className="h-64"> <div className="h-64">
<ResponsiveContainer height="100%" width="100%"> <ResponsiveContainer height="100%" width="100%">
<LineChart data={tunnelChartData}> <LineChart data={tunnelChartData}>
<CartesianGrid strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={12} /> <XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis fontSize={12} tickFormatter={tunnelYAxisTickFormatter} /> <YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={tunnelYAxisTickFormatter} />
<Tooltip <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" }} labelStyle={{ color: "#fff" }}
formatter={tunnelTooltipFormatter} formatter={tunnelTooltipFormatter}
/> />
<Legend />
<Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" /> <Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
<Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" /> <Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-default-500">暂无指标数据</div> <div className="text-center py-8 text-default-500">暂无流量数据</div>
)} )}
</CardBody> </CardBody>
</Card> </Card>
@@ -616,13 +584,16 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
); );
} }
// ===== LIST/GRID VIEW ===== // ===== LIST/GRID VIEW (unchanged) =====
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1"> <div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip> <Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
{tunnelStats.diagnosed > 0 && ( {lastQualityUpdate && (
<Chip color="success" size="sm" variant="flat">已诊断 {tunnelStats.diagnosed}</Chip> <div className="flex items-center gap-1.5 text-xs text-default-500">
<LiveDot />
<span>自动探测 · 更新于 {lastQualityUpdate}</span>
</div>
)} )}
<div className="ml-auto"> <div className="ml-auto">
<Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}> <Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
@@ -643,9 +614,9 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
{viewMode === "grid" ? ( {viewMode === "grid" ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{tunnels.map((tunnel) => { {tunnels.map((tunnel) => {
const quality = tunnelQualities[tunnel.id]; const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1; const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null; : null;
@@ -655,10 +626,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" 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)} onClick={() => setDetailTunnelId(tunnel.id)}
> >
{/* Top gradient bar */}
<div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} /> <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"}`} /> <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"> <CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
@@ -684,59 +652,34 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
</CardHeader> </CardHeader>
<CardBody className="py-3 px-5 flex-1 flex flex-col justify-end gap-3 z-10 w-full overflow-hidden"> <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="grid grid-cols-2 gap-3">
<div className="space-y-1"> <div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1"> <div className="text-[10px] text-default-500 flex items-center gap-1">
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
入口→出口 入口→出口
</div> </div>
<span className="font-mono text-xs font-semibold"> <LatencyDisplay value={quality?.entryToExitLatency} />
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.entryToExitLatency !== undefined ? (
`${quality.entryToExitLatency.toFixed(0)}ms`
) : "-"}
</span>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1"> <div className="text-[10px] text-default-500 flex items-center gap-1">
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
出口→Bing 出口→Bing
</div> </div>
<span className="font-mono text-xs font-semibold"> <LatencyDisplay value={quality?.exitToBingLatency} />
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.exitToBingLatency !== undefined ? (
`${quality.exitToBingLatency.toFixed(0)}ms`
) : "-"}
</span>
</div> </div>
</div> </div>
{/* Action area */}
<div className="flex justify-between items-center pt-2 border-t border-divider/50"> <div className="flex justify-between items-center pt-2 border-t border-divider/50">
{quality?.error ? ( {quality?.errorMessage ? (
<span className="text-[11px] text-danger truncate">{quality.error}</span> <span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
) : quality?.timestamp ? ( ) : 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")} {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span> </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> </div>
</CardBody> </CardBody>
</Card> </Card>
@@ -752,13 +695,13 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<TableColumn>入口→出口</TableColumn> <TableColumn>入口→出口</TableColumn>
<TableColumn>出口→Bing</TableColumn> <TableColumn>出口→Bing</TableColumn>
<TableColumn>质量</TableColumn> <TableColumn>质量</TableColumn>
<TableColumn align="center">操作</TableColumn> <TableColumn>更新时间</TableColumn>
</TableHeader> </TableHeader>
<TableBody emptyContent="暂无隧道"> <TableBody emptyContent="暂无隧道">
{tunnels.map((tunnel) => { {tunnels.map((tunnel) => {
const quality = tunnelQualities[tunnel.id]; const quality = qualityMap[tunnel.id];
const isEnabled = tunnel.status === 1; const isEnabled = tunnel.status === 1;
const overallQuality = quality?.entryToExitLatency !== undefined const overallQuality = quality?.entryToExitLatency !== undefined && quality.entryToExitLatency >= 0
? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0)
: null; : null;
@@ -777,22 +720,10 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
<span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span> <span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-mono text-xs whitespace-nowrap"> <LatencyDisplay value={quality?.entryToExitLatency} />
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.entryToExitLatency !== undefined ? (
`${quality.entryToExitLatency.toFixed(0)}ms`
) : "-"}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-mono text-xs whitespace-nowrap"> <LatencyDisplay value={quality?.exitToBingLatency} />
{quality?.loading ? (
<RefreshCw className="w-3 h-3 animate-spin inline" />
) : quality?.exitToBingLatency !== undefined ? (
`${quality.exitToBingLatency.toFixed(0)}ms`
) : "-"}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
{overallQuality ? ( {overallQuality ? (
@@ -804,18 +735,14 @@ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps)
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex justify-center gap-1"> {quality?.timestamp ? (
<Button <span className="text-xs text-default-500 flex items-center gap-1 whitespace-nowrap">
isIconOnly <LiveDot />
size="sm" {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
variant="light" </span>
isLoading={quality?.loading} ) : (
onPress={() => diagnoseTunnelQuality(tunnel.id)} <span className="text-xs text-default-400">-</span>
onClick={(e) => e.stopPropagation()} )}
>
<Stethoscope className="w-4 h-4 text-default-500" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
); );