From 9de240f03470837c988c87d9f85e92a5d6143fdd Mon Sep 17 00:00:00 2001 From: sagitchu Date: Tue, 17 Mar 2026 14:59:09 +0800 Subject: [PATCH] feat(monitoring): add node/tunnel metrics, service monitors, and health checks - Add NodeMetric/TunnelMetric/ServiceMonitor models and repository methods - Implement metrics ingestion service with per-minute bucket aggregation - Add health checker for node connectivity monitoring - Wire node metrics from WebSocket SystemInfo messages - Add tunnel metrics ingestion from flow upload endpoint - Create monitoring REST API endpoints for nodes, tunnels, services - Implement service monitor CRUD and execution (TCP/ICMP checks) - Add MonitorPermission for non-admin access control - Create frontend monitor page with node/tunnel/service views - Add tunnel metrics ingestion from agent flow reports - Include schema migration for tunnel_metric unique index - Fix tunnel entry port conflict validation to use transaction Entire-Checkpoint: 030821a7c8e3 --- .gitignore | 3 + go-backend/internal/health/checker.go | 360 +++ go-backend/internal/health/checker_test.go | 477 ++++ go-backend/internal/http/handler/handler.go | 50 +- go-backend/internal/http/handler/jobs.go | 18 +- .../internal/http/handler/monitoring.go | 795 +++++++ go-backend/internal/http/handler/mutations.go | 31 +- .../http/handler/tunnel_metrics_ingestion.go | 111 + go-backend/internal/http/middleware/auth.go | 4 + go-backend/internal/metrics/ingestion.go | 135 ++ go-backend/internal/metrics/ingestion_test.go | 294 +++ go-backend/internal/monitoring/limits.go | 114 + go-backend/internal/store/model/model.go | 73 + go-backend/internal/store/repo/repository.go | 488 +++- .../internal/store/repo/repository_control.go | 70 + .../internal/store/repo/repository_flow.go | 58 + .../store/repo/repository_monitor_nodes.go | 18 + .../repo/repository_monitor_permission.go | 54 + .../store/repo/repository_monitor_tunnels.go | 18 + .../store/repo/repository_monitoring_test.go | 134 ++ go-backend/internal/ws/server.go | 85 +- ...ue313_entry_port_conflict_contract_test.go | 9 +- .../contract/monitoring_contract_test.go | 1448 ++++++++++++ .../tunnel_metrics_ingestion_contract_test.go | 97 + go-gost/x/service/service.go | 9 +- go-gost/x/socket/websocket_reporter.go | 398 +++- plans/037-monitoring-metrics-health.md | 104 + ...8-monitoring-bugfixes-and-optimizations.md | 59 + ...039-monitoring-tunnel-metrics-ingestion.md | 41 + ...onitor-configurable-limits-and-ui-hints.md | 40 + ...g-reliability-realtime-and-ux-hardening.md | 224 ++ vite-frontend/src/App.tsx | 9 + vite-frontend/src/api/error-message.ts | 7 +- vite-frontend/src/api/index.ts | 92 + vite-frontend/src/api/types.ts | 101 + .../components/batch-action-result-modal.tsx | 3 +- vite-frontend/src/layouts/admin.tsx | 87 +- vite-frontend/src/layouts/h5.tsx | 77 +- .../src/pages/forward/batch-actions.ts | 11 +- vite-frontend/src/pages/monitor.tsx | 111 + vite-frontend/src/pages/node.tsx | 426 +++- vite-frontend/src/pages/node/monitor-view.tsx | 1982 +++++++++++++++++ vite-frontend/src/pages/node/system-info.ts | 16 + vite-frontend/src/pages/tunnel.tsx | 7 +- vite-frontend/src/pages/user.tsx | 146 +- 45 files changed, 8704 insertions(+), 190 deletions(-) create mode 100644 go-backend/internal/health/checker.go create mode 100644 go-backend/internal/health/checker_test.go create mode 100644 go-backend/internal/http/handler/monitoring.go create mode 100644 go-backend/internal/http/handler/tunnel_metrics_ingestion.go create mode 100644 go-backend/internal/metrics/ingestion.go create mode 100644 go-backend/internal/metrics/ingestion_test.go create mode 100644 go-backend/internal/monitoring/limits.go create mode 100644 go-backend/internal/store/repo/repository_monitor_nodes.go create mode 100644 go-backend/internal/store/repo/repository_monitor_permission.go create mode 100644 go-backend/internal/store/repo/repository_monitor_tunnels.go create mode 100644 go-backend/internal/store/repo/repository_monitoring_test.go create mode 100644 go-backend/tests/contract/monitoring_contract_test.go create mode 100644 go-backend/tests/contract/tunnel_metrics_ingestion_contract_test.go create mode 100644 plans/037-monitoring-metrics-health.md create mode 100644 plans/038-monitoring-bugfixes-and-optimizations.md create mode 100644 plans/039-monitoring-tunnel-metrics-ingestion.md create mode 100644 plans/040-service-monitor-configurable-limits-and-ui-hints.md create mode 100644 plans/041-monitoring-reliability-realtime-and-ux-hardening.md create mode 100644 vite-frontend/src/pages/monitor.tsx create mode 100644 vite-frontend/src/pages/node/monitor-view.tsx diff --git a/.gitignore b/.gitignore index a8c784c..e7c80ee 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,9 @@ go-gost/ss/ .classpath .project .settings/ + +# OpenCode session metadata +.entire/ bin/ tmp/ *.swp diff --git a/go-backend/internal/health/checker.go b/go-backend/internal/health/checker.go new file mode 100644 index 0000000..ad3825e --- /dev/null +++ b/go-backend/internal/health/checker.go @@ -0,0 +1,360 @@ +package health + +import ( + "context" + "errors" + "fmt" + "log" + "net" + "strings" + "sync" + "time" + + "go-backend/internal/monitoring" + "go-backend/internal/store/model" + "go-backend/internal/store/repo" + "go-backend/internal/ws" +) + +type nodeCommander interface { + SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (ws.CommandResult, error) +} + +type Checker struct { + repo *repo.Repository + commander nodeCommander + lastRun map[int64]int64 + inFlight map[int64]struct{} + + mu sync.RWMutex + cancel context.CancelFunc + wg sync.WaitGroup +} + +func NewChecker(repo *repo.Repository, commander nodeCommander) *Checker { + return &Checker{ + repo: repo, + commander: commander, + lastRun: make(map[int64]int64), + inFlight: make(map[int64]struct{}), + } +} + +func (c *Checker) Start(ctx context.Context) { + c.mu.Lock() + ctx, cancel := context.WithCancel(ctx) + c.cancel = cancel + c.mu.Unlock() + + c.runChecks(ctx) + + for { + limits := c.loadServiceMonitorLimits() + scanInterval := time.Duration(limits.CheckerScanIntervalSec) * time.Second + if scanInterval <= 0 { + scanInterval = 30 * time.Second + } + + timer := time.NewTimer(scanInterval) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + c.runChecks(ctx) + } + } +} + +func (c *Checker) Stop() { + c.mu.Lock() + if c.cancel != nil { + c.cancel() + } + c.mu.Unlock() + c.wg.Wait() +} + +func (c *Checker) RunOnce(m *model.ServiceMonitor) (*model.ServiceMonitorResult, error) { + if c == nil { + return nil, errors.New("checker not initialized") + } + if m == nil { + return nil, errors.New("monitor is nil") + } + limits := c.loadServiceMonitorLimits() + return c.executeCheck(m, time.Now().UnixMilli(), limits), nil +} + +func (c *Checker) runChecks(ctx context.Context) { + if c == nil || c.repo == nil { + return + } + + limits := c.loadServiceMonitorLimits() + monitors, err := c.repo.ListEnabledServiceMonitors() + if err != nil { + log.Printf("service monitor scheduler failed op=list_enabled err=%v", err) + return + } + if len(monitors) == 0 { + return + } + + // Use persisted result timestamps to avoid restart bursts. + latest, err := c.repo.GetLatestServiceMonitorResults() + if err != nil { + log.Printf("service monitor scheduler failed op=get_latest_results err=%v", err) + latest = nil + } + persistedLast := make(map[int64]int64, len(latest)) + for _, r := range latest { + if r.MonitorID <= 0 || r.Timestamp <= 0 { + continue + } + persistedLast[r.MonitorID] = r.Timestamp + } + + now := time.Now().UnixMilli() + due := make([]model.ServiceMonitor, 0, len(monitors)) + for _, m := range monitors { + select { + case <-ctx.Done(): + return + default: + } + + intervalSec := m.IntervalSec + if intervalSec <= 0 { + intervalSec = limits.DefaultIntervalSec + } + if intervalSec < limits.MinIntervalSec { + intervalSec = limits.MinIntervalSec + } + intervalMs := int64(intervalSec) * 1000 + + c.mu.Lock() + if _, ok := c.inFlight[m.ID]; ok { + c.mu.Unlock() + continue + } + + lastSeen := persistedLast[m.ID] + if v := c.lastRun[m.ID]; v > lastSeen { + lastSeen = v + } + if lastSeen > 0 && intervalMs > 0 && now-lastSeen < intervalMs { + c.mu.Unlock() + continue + } + + c.inFlight[m.ID] = struct{}{} + // Use now as a best-effort guard against overlapping scans; the final + // timestamp is updated again when the result is persisted. + c.lastRun[m.ID] = now + c.mu.Unlock() + + due = append(due, m) + } + if len(due) == 0 { + return + } + + workerLimit := limits.WorkerLimit + if workerLimit <= 0 { + workerLimit = 1 + } + if workerLimit > len(due) { + workerLimit = len(due) + } + + jobs := make(chan model.ServiceMonitor, len(due)) + for _, m := range due { + jobs <- m + } + close(jobs) + + for i := 0; i < workerLimit; i++ { + c.wg.Add(1) + go func() { + defer c.wg.Done() + for { + select { + case <-ctx.Done(): + return + case m, ok := <-jobs: + if !ok { + return + } + ts := time.Now().UnixMilli() + result := c.executeCheck(&m, ts, limits) + if err := c.repo.InsertServiceMonitorResult(result); err != nil { + log.Printf("monitoring write failed op=service_monitor_result.insert monitor_id=%d err=%v", result.MonitorID, err) + } + + c.mu.Lock() + c.lastRun[m.ID] = result.Timestamp + delete(c.inFlight, m.ID) + c.mu.Unlock() + } + } + }() + } +} + +func (c *Checker) executeCheck(m *model.ServiceMonitor, timestamp int64, limits monitoring.ServiceMonitorLimits) *model.ServiceMonitorResult { + result := &model.ServiceMonitorResult{ + MonitorID: m.ID, + NodeID: m.NodeID, + Timestamp: timestamp, + } + + timeoutSec := m.TimeoutSec + if timeoutSec <= 0 { + timeoutSec = limits.DefaultTimeoutSec + } + if timeoutSec < limits.MinTimeoutSec { + timeoutSec = limits.MinTimeoutSec + } + if timeoutSec > limits.MaxTimeoutSec { + timeoutSec = limits.MaxTimeoutSec + } + + timeout := time.Duration(timeoutSec) * time.Second + + // When nodeId is set, run checks on the specified node. + if m.NodeID > 0 { + c.checkOnNode(m, timeoutSec, timeout, result) + return result + } + + switch strings.ToLower(strings.TrimSpace(m.Type)) { + case "tcp": + c.checkTCP(m.Target, timeout, result) + case "icmp": + result.Success = 0 + result.ErrorMessage = "ICMP 监控必须指定执行节点" + default: + result.Success = 0 + result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type) + } + + return result +} + +func (c *Checker) loadServiceMonitorLimits() monitoring.ServiceMonitorLimits { + defaults := monitoring.DefaultServiceMonitorLimits() + if c == nil || c.repo == nil { + return defaults + } + cfg, err := c.repo.GetConfigsByNames([]string{ + monitoring.ConfigServiceMonitorCheckerScanIntervalSec, + monitoring.ConfigServiceMonitorWorkerLimit, + monitoring.ConfigServiceMonitorMinIntervalSec, + monitoring.ConfigServiceMonitorDefaultIntervalSec, + monitoring.ConfigServiceMonitorMinTimeoutSec, + monitoring.ConfigServiceMonitorDefaultTimeoutSec, + monitoring.ConfigServiceMonitorMaxTimeoutSec, + }) + if err != nil { + return defaults + } + return monitoring.ServiceMonitorLimitsFromConfigMap(cfg) +} + +type serviceMonitorCheckRequest struct { + MonitorID int64 `json:"monitorId"` + Type string `json:"type"` + Target string `json:"target"` + TimeoutSec int `json:"timeoutSec"` +} + +func (c *Checker) checkOnNode(m *model.ServiceMonitor, timeoutSec int, timeout time.Duration, result *model.ServiceMonitorResult) { + if c == nil || m == nil || result == nil { + return + } + if c.commander == nil { + result.Success = 0 + result.ErrorMessage = "节点检查不可用" + return + } + + checkType := strings.ToLower(strings.TrimSpace(m.Type)) + if checkType != "tcp" && checkType != "icmp" { + result.Success = 0 + result.ErrorMessage = fmt.Sprintf("不支持的检查类型: %s", m.Type) + return + } + if strings.TrimSpace(m.Target) == "" { + result.Success = 0 + result.ErrorMessage = "检查目标为空" + return + } + + req := serviceMonitorCheckRequest{ + MonitorID: m.ID, + Type: checkType, + Target: m.Target, + TimeoutSec: timeoutSec, + } + + cmdTimeout := timeout + if cmdTimeout < 2*time.Second { + cmdTimeout = 2 * time.Second + } + cmdTimeout = cmdTimeout + 2*time.Second + + cmdRes, err := c.commander.SendCommand(m.NodeID, "ServiceMonitorCheck", req, cmdTimeout) + if err != nil { + result.Success = 0 + result.ErrorMessage = err.Error() + return + } + if cmdRes.Data == nil { + result.Success = 0 + result.ErrorMessage = "节点返回为空" + return + } + + if v, ok := cmdRes.Data["success"]; ok { + if b, ok := v.(bool); ok { + if b { + result.Success = 1 + } else { + result.Success = 0 + } + } + } + if v, ok := cmdRes.Data["latencyMs"]; ok { + if f, ok := v.(float64); ok { + result.LatencyMs = f + } + } + if v, ok := cmdRes.Data["statusCode"]; ok { + if f, ok := v.(float64); ok { + result.StatusCode = int(f) + } + } + if v, ok := cmdRes.Data["errorMessage"]; ok { + if s, ok := v.(string); ok { + result.ErrorMessage = s + } + } +} + +func (c *Checker) checkTCP(target string, timeout time.Duration, result *model.ServiceMonitorResult) { + start := time.Now() + + conn, err := net.DialTimeout("tcp", target, timeout) + latency := time.Since(start) + + result.LatencyMs = float64(latency.Milliseconds()) + + if err != nil { + result.Success = 0 + result.ErrorMessage = err.Error() + return + } + _ = conn.Close() + result.Success = 1 +} diff --git a/go-backend/internal/health/checker_test.go b/go-backend/internal/health/checker_test.go new file mode 100644 index 0000000..13ccb72 --- /dev/null +++ b/go-backend/internal/health/checker_test.go @@ -0,0 +1,477 @@ +package health + +import ( + "context" + "net" + "testing" + "time" + + "go-backend/internal/monitoring" + "go-backend/internal/store/model" + "go-backend/internal/store/repo" + "go-backend/internal/ws" +) + +type fakeCommander struct { + lastNodeID int64 + lastType string + lastData interface{} + res ws.CommandResult + err error +} + +type delayedCommander struct { + delayByMonitorID map[int64]time.Duration +} + +func (d *delayedCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) { + _ = nodeID + _ = cmdType + if req, ok := data.(serviceMonitorCheckRequest); ok { + if delay := d.delayByMonitorID[req.MonitorID]; delay > 0 { + time.Sleep(delay) + } + } + return ws.CommandResult{ + Success: true, + Data: map[string]interface{}{ + "success": true, + "latencyMs": float64(1), + }, + }, nil +} + +func (f *fakeCommander) SendCommand(nodeID int64, cmdType string, data interface{}, _ time.Duration) (ws.CommandResult, error) { + f.lastNodeID = nodeID + f.lastType = cmdType + f.lastData = data + return f.res, f.err +} + +func TestTCPHealthCheckViaMonitor(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + addr := listener.Addr().String() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + t.Run("successful tcp check", func(t *testing.T) { + checker := NewChecker(nil, nil) + limits := checker.loadServiceMonitorLimits() + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + Type: "tcp", + Target: addr, + TimeoutSec: 5, + } + result := checker.executeCheck(monitor, now, limits) + if result.Success != 1 { + t.Fatalf("expected success, got error: %s", result.ErrorMessage) + } + if result.LatencyMs < 0 { + t.Fatalf("expected non-negative latency, got %f", result.LatencyMs) + } + }) + + t.Run("failed tcp check - connection refused", func(t *testing.T) { + checker := NewChecker(nil, nil) + limits := checker.loadServiceMonitorLimits() + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + Type: "tcp", + Target: "127.0.0.1:1", + TimeoutSec: 1, + } + result := checker.executeCheck(monitor, now, limits) + if result.Success == 1 { + t.Fatalf("expected failure for connection refused") + } + if result.ErrorMessage == "" { + t.Fatalf("expected error message") + } + }) +} + +func TestCheckerRunChecks(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + tcpAddr := listener.Addr().String() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + now := time.Now().UnixMilli() + + monitors := []*model.ServiceMonitor{ + { + Name: "TCP Monitor", + Type: "tcp", + Target: tcpAddr, + IntervalSec: 60, + TimeoutSec: 5, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + }, + { + Name: "TCP Monitor 2", + Type: "tcp", + Target: tcpAddr, + IntervalSec: 60, + TimeoutSec: 5, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + }, + { + Name: "Disabled Monitor", + Type: "tcp", + Target: "127.0.0.1:1", + IntervalSec: 60, + TimeoutSec: 5, + NodeID: 0, + Enabled: 0, + CreatedTime: now, + UpdatedTime: now, + }, + } + + for _, m := range monitors { + if err := r.CreateServiceMonitor(m); err != nil { + t.Fatalf("create monitor: %v", err) + } + } + + monitors[2].Enabled = 0 + if err := r.UpdateServiceMonitor(monitors[2]); err != nil { + t.Fatalf("update disabled monitor: %v", err) + } + + enabledMonitors, err := r.ListEnabledServiceMonitors() + if err != nil { + t.Fatalf("list enabled monitors: %v", err) + } + if len(enabledMonitors) != 2 { + t.Fatalf("expected 2 enabled monitors, got %d", len(enabledMonitors)) + } + + checker := NewChecker(r, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + go checker.Start(ctx) + time.Sleep(500 * time.Millisecond) + + results, err := r.GetServiceMonitorResults(monitors[0].ID, 10) + if err != nil { + t.Fatalf("get tcp results: %v", err) + } + if len(results) == 0 { + t.Fatalf("expected at least one result for tcp monitor") + } + for _, res := range results { + if res.Success != 1 { + t.Fatalf("expected success for tcp monitor, got failure: %s", res.ErrorMessage) + } + } + + results2, err := r.GetServiceMonitorResults(monitors[1].ID, 10) + if err != nil { + t.Fatalf("get tcp results 2: %v", err) + } + if len(results2) == 0 { + t.Fatalf("expected at least one result for tcp monitor 2") + } + for _, res := range results2 { + if res.Success != 1 { + t.Fatalf("expected success for tcp monitor 2, got failure: %s", res.ErrorMessage) + } + } + + disabledResults, err := r.GetServiceMonitorResults(monitors[2].ID, 10) + if err != nil { + t.Fatalf("get disabled results: %v", err) + } + if len(disabledResults) != 0 { + t.Fatalf("expected no results for disabled monitor, got %d", len(disabledResults)) + } +} + +func TestCheckerUnsupportedType(t *testing.T) { + checker := NewChecker(nil, nil) + limits := checker.loadServiceMonitorLimits() + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + Type: "http", + Target: "https://example.com", + TimeoutSec: 5, + } + result := checker.executeCheck(monitor, now, limits) + if result.Success == 1 { + t.Fatalf("expected failure for unsupported type") + } + if result.ErrorMessage == "" { + t.Fatalf("expected error message for unsupported type") + } +} + +func TestCheckerDefaultTimeout(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + addr := listener.Addr().String() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + checker := NewChecker(nil, nil) + limits := checker.loadServiceMonitorLimits() + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + Type: "tcp", + Target: addr, + TimeoutSec: 0, + } + result := checker.executeCheck(monitor, now, limits) + if result.Success != 1 { + t.Fatalf("expected success with default timeout, got error: %s", result.ErrorMessage) + } +} + +func TestCheckerStop(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer listener.Close() + + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + conn.Close() + } + }() + + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + Name: "Test Monitor", + Type: "tcp", + Target: listener.Addr().String(), + IntervalSec: 60, + TimeoutSec: 5, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := r.CreateServiceMonitor(monitor); err != nil { + t.Fatalf("create monitor: %v", err) + } + + checker := NewChecker(r, nil) + + ctx := context.Background() + go checker.Start(ctx) + + time.Sleep(100 * time.Millisecond) + checker.Stop() + + results, err := r.GetServiceMonitorResults(monitor.ID, 10) + if err != nil { + t.Fatalf("get results: %v", err) + } + if len(results) == 0 { + t.Fatalf("expected at least one result before stop") + } +} + +func TestCheckerRunsOnNodeWhenNodeIDSet(t *testing.T) { + fake := &fakeCommander{ + res: ws.CommandResult{ + Success: true, + Data: map[string]interface{}{ + "success": false, + "latencyMs": float64(12), + "errorMessage": "unreachable", + }, + }, + } + checker := NewChecker(nil, fake) + limits := checker.loadServiceMonitorLimits() + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + ID: 99, + Type: "icmp", + Target: "8.8.8.8", + TimeoutSec: 2, + NodeID: 123, + } + res := checker.executeCheck(monitor, now, limits) + if fake.lastNodeID != 123 { + t.Fatalf("expected command to be sent to node 123, got %d", fake.lastNodeID) + } + if fake.lastType != "ServiceMonitorCheck" { + t.Fatalf("expected ServiceMonitorCheck command, got %s", fake.lastType) + } + if res.Success != 0 { + t.Fatalf("expected failed result from node check") + } + if res.ErrorMessage != "unreachable" { + t.Fatalf("expected errorMessage unreachable, got %q", res.ErrorMessage) + } +} + +func TestCheckerDoesNotBurstOnRestartWhenRecentResultsExist(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + monitor := &model.ServiceMonitor{ + Name: "recent-monitor", + Type: "tcp", + Target: "127.0.0.1:1", + IntervalSec: 60, + TimeoutSec: 1, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := r.CreateServiceMonitor(monitor); err != nil { + t.Fatalf("create monitor: %v", err) + } + if err := r.InsertServiceMonitorResult(&model.ServiceMonitorResult{ + MonitorID: monitor.ID, + NodeID: 0, + Timestamp: now - 10_000, + Success: 1, + }); err != nil { + t.Fatalf("seed recent result: %v", err) + } + + checker := NewChecker(r, nil) + ctx, cancel := context.WithCancel(context.Background()) + go checker.Start(ctx) + // Give the initial scan a chance to run. + time.Sleep(200 * time.Millisecond) + cancel() + checker.Stop() + + results, err := r.GetServiceMonitorResults(monitor.ID, 10) + if err != nil { + t.Fatalf("get results: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected no immediate rerun (1 result), got %d", len(results)) + } +} + +func TestCheckerConcurrencyPreventsSlowMonitorBlockingOthers(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + now := time.Now().UnixMilli() + // Force worker limit to at least 2 for this test. + _ = r.UpsertConfig(monitoring.ConfigServiceMonitorWorkerLimit, "2", now) + + slow := &model.ServiceMonitor{ + Name: "slow", + Type: "icmp", + Target: "8.8.8.8", + IntervalSec: 60, + TimeoutSec: 1, + NodeID: 123, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := r.CreateServiceMonitor(slow); err != nil { + t.Fatalf("create slow monitor: %v", err) + } + fast := &model.ServiceMonitor{ + Name: "fast", + Type: "icmp", + Target: "1.1.1.1", + IntervalSec: 60, + TimeoutSec: 1, + NodeID: 123, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := r.CreateServiceMonitor(fast); err != nil { + t.Fatalf("create fast monitor: %v", err) + } + + cmd := &delayedCommander{delayByMonitorID: map[int64]time.Duration{slow.ID: 800 * time.Millisecond}} + checker := NewChecker(r, cmd) + ctx, cancel := context.WithCancel(context.Background()) + go checker.Start(ctx) + + // Fast monitor should complete even while slow one is still running. + time.Sleep(250 * time.Millisecond) + results, err := r.GetServiceMonitorResults(fast.ID, 10) + if err != nil { + t.Fatalf("get fast results: %v", err) + } + if len(results) == 0 { + t.Fatalf("expected fast monitor to have results without waiting for slow") + } + + cancel() + checker.Stop() +} diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 5b71034..322f5d4 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -16,17 +16,21 @@ import ( "time" "go-backend/internal/auth" + "go-backend/internal/health" "go-backend/internal/http/middleware" "go-backend/internal/http/response" + "go-backend/internal/metrics" "go-backend/internal/security" "go-backend/internal/store/repo" "go-backend/internal/ws" ) type Handler struct { - repo *repo.Repository - jwtSecret string - wsServer *ws.Server + repo *repo.Repository + jwtSecret string + wsServer *ws.Server + metrics *metrics.IngestionService + healthCheck *health.Checker captchaMu sync.Mutex captchaTokens map[string]int64 @@ -83,10 +87,31 @@ func New(repo *repo.Repository, jwtSecret string) *Handler { repo: repo, jwtSecret: jwtSecret, wsServer: ws.NewServer(repo, jwtSecret), + metrics: metrics.NewIngestionService(repo), + healthCheck: nil, captchaTokens: make(map[string]int64), pendingUpgradeRedeploy: make(map[int64]struct{}), } + h.healthCheck = health.NewChecker(repo, h.wsServer) h.wsServer.SetNodeOnlineHook(h.onNodeOnline) + h.wsServer.SetNodeMetricHook(func(nodeID int64, info ws.SystemInfo) { + metricInfo := metrics.SystemInfo{ + Uptime: info.Uptime, + BytesReceived: info.BytesReceived, + BytesTransmitted: info.BytesTransmitted, + CPUUsage: info.CPUUsage, + MemoryUsage: info.MemoryUsage, + DiskUsage: info.DiskUsage, + Load1: info.Load1, + Load5: info.Load5, + Load15: info.Load15, + TCPConns: info.TCPConns, + UDPConns: info.UDPConns, + NetInSpeed: info.NetInSpeed, + NetOutSpeed: info.NetOutSpeed, + } + h.metrics.RecordNodeMetric(nodeID, metricInfo) + }) return h } @@ -195,6 +220,23 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement) mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement) + mux.HandleFunc("/api/v1/monitor/access", h.monitorAccessHandler) + 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/", h.monitorTunnelMetrics) + mux.HandleFunc("/api/v1/monitor/services", h.monitorServiceListHandler) + mux.HandleFunc("/api/v1/monitor/services/create", h.monitorServiceCreate) + mux.HandleFunc("/api/v1/monitor/services/update", h.monitorServiceUpdate) + mux.HandleFunc("/api/v1/monitor/services/delete", h.monitorServiceDelete) + mux.HandleFunc("/api/v1/monitor/services/run", h.monitorServiceRun) + mux.HandleFunc("/api/v1/monitor/services/latest-results", h.monitorServiceLatestResultsHandler) + mux.HandleFunc("/api/v1/monitor/services/limits", h.monitorServiceLimitsHandler) + mux.HandleFunc("/api/v1/monitor/services/", h.monitorServiceResultsHandler) + mux.HandleFunc("/api/v1/monitor/permission/list", h.monitorPermissionList) + mux.HandleFunc("/api/v1/monitor/permission/assign", h.monitorPermissionAssign) + mux.HandleFunc("/api/v1/monitor/permission/remove", h.monitorPermissionRemove) + mux.HandleFunc("/flow/test", h.flowTest) mux.HandleFunc("/flow/config", h.flowConfig) mux.HandleFunc("/flow/upload", h.flowUpload) @@ -723,6 +765,8 @@ func (h *Handler) flowUpload(w http.ResponseWriter, r *http.Request) { if err == nil && strings.TrimSpace(raw) != "" { var items []flowItem if json.Unmarshal([]byte(raw), &items) == nil { + nowMs := time.Now().UnixMilli() + h.recordTunnelMetricsFromFlowItems(node.ID, items, nowMs) for _, item := range items { h.processFlowItem(node.ID, item) } diff --git a/go-backend/internal/http/handler/jobs.go b/go-backend/internal/http/handler/jobs.go index 32fb456..a169b4f 100644 --- a/go-backend/internal/http/handler/jobs.go +++ b/go-backend/internal/http/handler/jobs.go @@ -18,12 +18,14 @@ func (h *Handler) StartBackgroundJobs() { ctx, cancel := context.WithCancel(context.Background()) h.jobsCancel = cancel h.jobsStarted = true - h.jobsWG.Add(3) + h.jobsWG.Add(5) h.jobsMu.Unlock() go h.runHourlyStatsLoop(ctx) go h.runDailyMaintenanceLoop(ctx) go h.runNodeRenewalCycleLoop(ctx) + go h.runMetricsIngestion(ctx) + go h.runHealthChecks(ctx) } func (h *Handler) StopBackgroundJobs() { @@ -47,6 +49,20 @@ func (h *Handler) StopBackgroundJobs() { h.jobsWG.Wait() } +func (h *Handler) runMetricsIngestion(ctx context.Context) { + defer h.jobsWG.Done() + if h.metrics != nil { + h.metrics.Start(ctx) + } +} + +func (h *Handler) runHealthChecks(ctx context.Context) { + defer h.jobsWG.Done() + if h.healthCheck != nil { + h.healthCheck.Start(ctx) + } +} + func (h *Handler) runHourlyStatsLoop(ctx context.Context) { defer h.jobsWG.Done() diff --git a/go-backend/internal/http/handler/monitoring.go b/go-backend/internal/http/handler/monitoring.go new file mode 100644 index 0000000..1123faf --- /dev/null +++ b/go-backend/internal/http/handler/monitoring.go @@ -0,0 +1,795 @@ +package handler + +import ( + "log" + "net/http" + "strconv" + "strings" + "time" + + "go-backend/internal/http/response" + "go-backend/internal/monitoring" + "go-backend/internal/store/model" +) + +const ( + defaultMetricsRangeMs = int64(60 * 60 * 1000) // 1h + maxMetricsRangeMs = int64(24 * 60 * 60 * 1000) // 24h +) + +func (h *Handler) resolveServiceMonitorLimits() monitoring.ServiceMonitorLimits { + defaults := monitoring.DefaultServiceMonitorLimits() + if h == nil || h.repo == nil { + return defaults + } + cfg, err := h.repo.GetConfigsByNames([]string{ + monitoring.ConfigServiceMonitorCheckerScanIntervalSec, + monitoring.ConfigServiceMonitorWorkerLimit, + monitoring.ConfigServiceMonitorMinIntervalSec, + monitoring.ConfigServiceMonitorDefaultIntervalSec, + monitoring.ConfigServiceMonitorMinTimeoutSec, + monitoring.ConfigServiceMonitorDefaultTimeoutSec, + monitoring.ConfigServiceMonitorMaxTimeoutSec, + }) + if err != nil { + return defaults + } + return monitoring.ServiceMonitorLimitsFromConfigMap(cfg) +} + +func (h *Handler) monitorNodeMetricsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + path := r.URL.Path + prefix := "/api/v1/monitor/nodes/" + if !strings.HasPrefix(path, prefix) { + response.WriteJSON(w, response.ErrDefault("无效的路径")) + return + } + + rest := strings.TrimPrefix(path, prefix) + if strings.HasSuffix(rest, "/metrics/latest") { + h.handleNodeMetricsLatest(w, r, strings.TrimSuffix(rest, "/metrics/latest")) + return + } + if strings.HasSuffix(rest, "/metrics") { + h.handleNodeMetrics(w, r, strings.TrimSuffix(rest, "/metrics")) + return + } + + response.WriteJSON(w, response.ErrDefault("无效的路径")) +} + +type monitorNodeListItem struct { + ID int64 `json:"id"` + Inx int `json:"inx"` + Name string `json:"name"` + Status int `json:"status"` + UpdatedTime int64 `json:"updatedTime"` +} + +func (h *Handler) monitorNodeListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + nodes, err := h.repo.ListMonitorNodes() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + items := make([]monitorNodeListItem, 0, len(nodes)) + for _, n := range nodes { + updated := int64(0) + if n.UpdatedTime.Valid { + updated = n.UpdatedTime.Int64 + } + items = append(items, monitorNodeListItem{ + ID: n.ID, + Inx: n.Inx, + Name: n.Name, + Status: n.Status, + UpdatedTime: updated, + }) + } + + response.WriteJSON(w, response.OK(items)) +} + +type monitorTunnelListItem struct { + ID int64 `json:"id"` + Inx int `json:"inx"` + Name string `json:"name"` + Status int `json:"status"` + UpdatedTime int64 `json:"updatedTime"` +} + +func (h *Handler) monitorTunnelListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + tunnels, err := h.repo.ListMonitorTunnels() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + items := make([]monitorTunnelListItem, 0, len(tunnels)) + for _, t := range tunnels { + items = append(items, monitorTunnelListItem{ + ID: t.ID, + Inx: t.Inx, + Name: t.Name, + Status: t.Status, + UpdatedTime: t.UpdatedTime, + }) + } + + response.WriteJSON(w, response.OK(items)) +} + +func (h *Handler) handleNodeMetrics(w http.ResponseWriter, r *http.Request, nodeIDStr string) { + nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64) + if err != nil || nodeID <= 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 { + response.WriteJSON(w, response.ErrDefault("无效的时间范围")) + return + } + if endMs < startMs { + response.WriteJSON(w, response.ErrDefault("无效的时间范围")) + return + } + if endMs-startMs > maxMetricsRangeMs { + response.WriteJSON(w, response.ErrDefault("时间范围过大")) + return + } + + metrics, err := h.repo.GetNodeMetrics(nodeID, startMs, endMs) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(metrics)) +} + +func (h *Handler) handleNodeMetricsLatest(w http.ResponseWriter, _ *http.Request, nodeIDStr string) { + nodeID, err := strconv.ParseInt(nodeIDStr, 10, 64) + if err != nil || nodeID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的节点ID")) + return + } + + metric, err := h.repo.GetLatestNodeMetric(nodeID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if metric == nil { + response.WriteJSON(w, response.OK(nil)) + return + } + + response.WriteJSON(w, response.OK(metric)) +} + +func (h *Handler) monitorTunnelMetrics(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/", "/metrics") + 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 { + response.WriteJSON(w, response.ErrDefault("无效的时间范围")) + return + } + if endMs < startMs { + response.WriteJSON(w, response.ErrDefault("无效的时间范围")) + return + } + if endMs-startMs > maxMetricsRangeMs { + response.WriteJSON(w, response.ErrDefault("时间范围过大")) + return + } + + metrics, err := h.repo.GetTunnelMetricsAggregated(tunnelID, startMs, endMs) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(metrics)) +} + +func (h *Handler) monitorServiceListHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + monitors, err := h.repo.ListServiceMonitors() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(monitors)) +} + +type createServiceMonitorRequest struct { + Name string `json:"name"` + Type string `json:"type"` + Target string `json:"target"` + IntervalSec int `json:"intervalSec"` + TimeoutSec int `json:"timeoutSec"` + NodeID int64 `json:"nodeId"` + Enabled *int `json:"enabled"` +} + +func (h *Handler) monitorServiceCreate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + var req createServiceMonitorRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + name := strings.TrimSpace(req.Name) + if name == "" { + response.WriteJSON(w, response.ErrDefault("名称不能为空")) + return + } + + monitorType := strings.ToLower(strings.TrimSpace(req.Type)) + if monitorType != "tcp" && monitorType != "icmp" { + response.WriteJSON(w, response.ErrDefault("类型必须是 tcp 或 icmp")) + return + } + + target := strings.TrimSpace(req.Target) + if target == "" { + response.WriteJSON(w, response.ErrDefault("目标地址不能为空")) + return + } + + limits := h.resolveServiceMonitorLimits() + + intervalSec := req.IntervalSec + if intervalSec <= 0 { + intervalSec = limits.DefaultIntervalSec + } + if intervalSec < limits.MinIntervalSec { + intervalSec = limits.MinIntervalSec + } + + timeoutSec := req.TimeoutSec + if timeoutSec <= 0 { + timeoutSec = limits.DefaultTimeoutSec + } + if timeoutSec < limits.MinTimeoutSec { + timeoutSec = limits.MinTimeoutSec + } + if timeoutSec > limits.MaxTimeoutSec { + timeoutSec = limits.MaxTimeoutSec + } + + enabled := 1 + if req.Enabled != nil { + if *req.Enabled == 0 || *req.Enabled == 1 { + enabled = *req.Enabled + } + } + + now := time.Now().UnixMilli() + if req.NodeID > 0 { + n, err := h.repo.GetNodeByID(req.NodeID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if n == nil { + response.WriteJSON(w, response.ErrDefault("节点不存在")) + return + } + } + m := &model.ServiceMonitor{ + Name: name, + Type: monitorType, + Target: target, + IntervalSec: intervalSec, + TimeoutSec: timeoutSec, + NodeID: req.NodeID, + Enabled: enabled, + CreatedTime: now, + UpdatedTime: now, + } + if m.Type == "icmp" && m.NodeID <= 0 { + response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点")) + return + } + // enabled is already normalized above. + + if err := h.repo.CreateServiceMonitor(m); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(m)) +} + +type updateServiceMonitorRequest struct { + ID int64 `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Target string `json:"target"` + IntervalSec int `json:"intervalSec"` + TimeoutSec int `json:"timeoutSec"` + NodeID *int64 `json:"nodeId"` + Enabled *int `json:"enabled"` +} + +func (h *Handler) monitorServiceUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + var req updateServiceMonitorRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的监控ID")) + return + } + + existing, err := h.repo.GetServiceMonitor(req.ID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if existing == nil { + response.WriteJSON(w, response.ErrDefault("监控不存在")) + return + } + + name := strings.TrimSpace(req.Name) + if name != "" { + existing.Name = name + } + + monitorType := strings.ToLower(strings.TrimSpace(req.Type)) + if monitorType == "tcp" || monitorType == "icmp" { + existing.Type = monitorType + } + + target := strings.TrimSpace(req.Target) + if target != "" { + existing.Target = target + } + + limits := h.resolveServiceMonitorLimits() + + if req.IntervalSec > 0 { + intervalSec := req.IntervalSec + if intervalSec < limits.MinIntervalSec { + intervalSec = limits.MinIntervalSec + } + existing.IntervalSec = intervalSec + } + if req.TimeoutSec > 0 { + timeoutSec := req.TimeoutSec + if timeoutSec < limits.MinTimeoutSec { + timeoutSec = limits.MinTimeoutSec + } + if timeoutSec > limits.MaxTimeoutSec { + timeoutSec = limits.MaxTimeoutSec + } + existing.TimeoutSec = timeoutSec + } + + if req.NodeID != nil { + existing.NodeID = *req.NodeID + } + if req.Enabled != nil { + if *req.Enabled == 0 || *req.Enabled == 1 { + existing.Enabled = *req.Enabled + } + } + + existing.UpdatedTime = time.Now().UnixMilli() + if existing.Type == "icmp" && existing.NodeID <= 0 { + response.WriteJSON(w, response.ErrDefault("ICMP 监控必须选择执行节点")) + return + } + if existing.NodeID > 0 { + n, err := h.repo.GetNodeByID(existing.NodeID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if n == nil { + response.WriteJSON(w, response.ErrDefault("节点不存在")) + return + } + } + + if err := h.repo.UpdateServiceMonitor(existing); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(existing)) +} + +type deleteServiceMonitorRequest struct { + ID int64 `json:"id"` +} + +func (h *Handler) monitorServiceDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + var req deleteServiceMonitorRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的监控ID")) + return + } + + if err := h.repo.DeleteServiceMonitor(req.ID); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) monitorServiceRun(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + if h.healthCheck == nil { + response.WriteJSON(w, response.ErrDefault("监控服务不可用")) + return + } + + var req deleteServiceMonitorRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.ID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的监控ID")) + return + } + + m, err := h.repo.GetServiceMonitor(req.ID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if m == nil { + response.WriteJSON(w, response.ErrDefault("监控不存在")) + return + } + + res, err := h.healthCheck.RunOnce(m) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if err := h.repo.InsertServiceMonitorResult(res); err != nil { + log.Printf("monitoring write failed op=service_monitor_result.manual_insert monitor_id=%d err=%v", res.MonitorID, err) + } + response.WriteJSON(w, response.OK(res)) +} + +func (h *Handler) monitorServiceResultsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + monitorIDStr := extractPathParam(r.URL.Path, "/api/v1/monitor/services/", "/results") + monitorID, err := strconv.ParseInt(monitorIDStr, 10, 64) + if err != nil || monitorID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的监控ID")) + return + } + + limit := 100 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil && v > 0 && v <= 1000 { + limit = v + } + } + + results, err := h.repo.GetServiceMonitorResults(monitorID, limit) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + + response.WriteJSON(w, response.OK(results)) +} + +func (h *Handler) monitorServiceLatestResultsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + + results, err := h.repo.GetLatestServiceMonitorResults() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OK(results)) +} + +func (h *Handler) monitorServiceLimitsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureMonitoringAccess(w, r) { + return + } + response.WriteJSON(w, response.OK(h.resolveServiceMonitorLimits())) +} + +func extractPathParam(path, prefix, suffix string) string { + if !strings.HasPrefix(path, prefix) { + return "" + } + rest := strings.TrimPrefix(path, prefix) + if suffix != "" { + rest = strings.TrimSuffix(rest, suffix) + } + return rest +} + +type monitorAccessData struct { + Allowed bool `json:"allowed"` + Reason string `json:"reason,omitempty"` +} + +// monitorAccessHandler is a lightweight capability check for frontend navigation. +// It does NOT replace authorization on the actual monitoring endpoints. +func (h *Handler) monitorAccessHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + userID, roleID, err := userRoleFromRequest(r) + if err != nil { + response.WriteJSON(w, response.Err(401, "未登录或token已过期")) + return + } + if roleID == 0 { + response.WriteJSON(w, response.OK(monitorAccessData{Allowed: true})) + return + } + + allowed, err := h.repo.HasMonitorPermission(userID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + data := monitorAccessData{Allowed: allowed} + if !allowed { + data.Reason = "need_admin_grant" + } + response.WriteJSON(w, response.OK(data)) +} + +func (h *Handler) ensureAdminAccess(w http.ResponseWriter, r *http.Request) bool { + _, roleID, err := userRoleFromRequest(r) + if err != nil { + response.WriteJSON(w, response.Err(401, "未登录或token已过期")) + return false + } + if roleID != 0 { + response.WriteJSON(w, response.Err(403, "权限不足,仅管理员可操作")) + return false + } + return true +} + +func (h *Handler) ensureMonitoringAccess(w http.ResponseWriter, r *http.Request) bool { + userID, roleID, err := userRoleFromRequest(r) + if err != nil { + response.WriteJSON(w, response.Err(401, "未登录或token已过期")) + return false + } + if roleID == 0 { + return true + } + allowed, err := h.repo.HasMonitorPermission(userID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return false + } + if !allowed { + response.WriteJSON(w, response.Err(403, "权限不足:当前账户非管理员,且未被授予监控权限。请联系管理员在用户管理中授权监控权限。")) + return false + } + return true +} + +func (h *Handler) monitorPermissionList(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureAdminAccess(w, r) { + return + } + + items, err := h.repo.ListMonitorPermissions() + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OK(items)) +} + +type monitorPermissionMutationRequest struct { + UserID int64 `json:"userId"` +} + +func (h *Handler) monitorPermissionAssign(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureAdminAccess(w, r) { + return + } + + var req monitorPermissionMutationRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.UserID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的用户ID")) + return + } + + u, err := h.repo.GetUserByID(req.UserID) + if err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + if u == nil { + response.WriteJSON(w, response.ErrDefault("用户不存在")) + return + } + + if err := h.repo.InsertMonitorPermission(req.UserID, time.Now().UnixMilli()); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} + +func (h *Handler) monitorPermissionRemove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + if !h.ensureAdminAccess(w, r) { + return + } + + var req monitorPermissionMutationRequest + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.ErrDefault("请求参数错误")) + return + } + if req.UserID <= 0 { + response.WriteJSON(w, response.ErrDefault("无效的用户ID")) + return + } + + if err := h.repo.DeleteMonitorPermission(req.UserID); err != nil { + response.WriteJSON(w, response.Err(-2, err.Error())) + return + } + response.WriteJSON(w, response.OKEmpty()) +} diff --git a/go-backend/internal/http/handler/mutations.go b/go-backend/internal/http/handler/mutations.go index 5733eed..0e10fc4 100644 --- a/go-backend/internal/http/handler/mutations.go +++ b/go-backend/internal/http/handler/mutations.go @@ -809,7 +809,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) { newEntryNodeIDs = append(newEntryNodeIDs, in.NodeID) } } - if err := h.validateTunnelEntryPortConflictsForNewEntries(id, oldEntryNodeIDs, newEntryNodeIDs); err != nil { + if err := h.validateTunnelEntryPortConflictsForNewEntriesTx(tx, id, oldEntryNodeIDs, newEntryNodeIDs); err != nil { response.WriteJSON(w, response.ErrDefault(err.Error())) return } @@ -997,8 +997,22 @@ func (h *Handler) cleanupTunnelForwardRuntimesOnRemovedEntryNodes(tunnelID int64 } } -func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error { - if h == nil || h.repo == nil || tunnelID <= 0 { +func (h *Handler) validateForwardPortAvailabilityTx(tx *gorm.DB, node *nodeRecord, port int, currentForwardID int64) error { + if h == nil || h.repo == nil || tx == nil || node == nil || port <= 0 { + return nil + } + occupied, err := h.repo.HasOtherForwardOnNodePortTx(tx, node.ID, port, currentForwardID) + if err != nil { + return err + } + if occupied { + return fmt.Errorf("节点 %s 端口 %d 已被其他转发占用", node.Name, port) + } + return nil +} + +func (h *Handler) validateTunnelEntryPortConflictsForNewEntriesTx(tx *gorm.DB, tunnelID int64, oldEntryNodeIDs, newEntryNodeIDs []int64) error { + if h == nil || h.repo == nil || tx == nil || tunnelID <= 0 { return nil } @@ -1007,7 +1021,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, return nil } - forwards, err := h.listForwardsByTunnel(tunnelID) + forwards, err := h.repo.ListForwardsByTunnelTx(tx, tunnelID) if err != nil || len(forwards) == 0 { return nil } @@ -1017,7 +1031,7 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, if f == nil { continue } - oldPorts, portsErr := h.listForwardPorts(f.ID) + oldPorts, portsErr := h.repo.ListForwardPortsTx(tx, f.ID) if portsErr != nil { continue } @@ -1027,14 +1041,11 @@ func (h *Handler) validateTunnelEntryPortConflictsForNewEntries(tunnelID int64, } for _, nodeID := range addedNodeIDs { - node, nodeErr := h.getNodeRecord(nodeID) + node, nodeErr := h.repo.GetNodeRecordTx(tx, nodeID) if nodeErr != nil { continue } - if err := validateLocalNodePort(node, port); err != nil { - return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err) - } - if err := h.validateForwardPortAvailability(node, port, f.ID); err != nil { + if err := h.validateForwardPortAvailabilityTx(tx, node, port, f.ID); err != nil { return fmt.Errorf("转发 %s 入口端口冲突: %w", f.Name, err) } } diff --git a/go-backend/internal/http/handler/tunnel_metrics_ingestion.go b/go-backend/internal/http/handler/tunnel_metrics_ingestion.go new file mode 100644 index 0000000..be01196 --- /dev/null +++ b/go-backend/internal/http/handler/tunnel_metrics_ingestion.go @@ -0,0 +1,111 @@ +package handler + +import ( + "log" + "strings" + "time" + + "go-backend/internal/store/model" +) + +type tunnelTrafficDelta struct { + bytesIn int64 + bytesOut int64 +} + +func unixMilliBucketMinute(nowMs int64) int64 { + if nowMs <= 0 { + return 0 + } + const minuteMs = int64(time.Minute / time.Millisecond) + return nowMs - (nowMs % minuteMs) +} + +func (h *Handler) recordTunnelMetricsFromFlowItems(nodeID int64, items []flowItem, nowMs int64) { + if h == nil || h.repo == nil { + return + } + if nodeID <= 0 || len(items) == 0 { + return + } + + bucketTs := unixMilliBucketMinute(nowMs) + if bucketTs <= 0 { + return + } + + forwardDeltas := make(map[int64]tunnelTrafficDelta) + for _, item := range items { + name := strings.TrimSpace(item.N) + if name == "" || name == "web_api" { + continue + } + forwardID, _, _, ok := parseFlowServiceIDs(name) + if !ok { + continue + } + if item.D == 0 && item.U == 0 { + continue + } + d := forwardDeltas[forwardID] + d.bytesIn += item.D + d.bytesOut += item.U + forwardDeltas[forwardID] = d + } + if len(forwardDeltas) == 0 { + return + } + + forwardIDs := make([]int64, 0, len(forwardDeltas)) + for id := range forwardDeltas { + forwardIDs = append(forwardIDs, id) + } + + forwardTunnelMap, err := h.repo.MapForwardIDsToTunnelIDs(forwardIDs) + if err != nil { + log.Printf("monitoring write skipped op=tunnel_metric.map_forward_to_tunnel node_id=%d err=%v", nodeID, err) + return + } + if len(forwardTunnelMap) == 0 { + return + } + + tunnelAgg := make(map[int64]tunnelTrafficDelta) + for forwardID, delta := range forwardDeltas { + tunnelID := forwardTunnelMap[forwardID] + if tunnelID <= 0 { + continue + } + a := tunnelAgg[tunnelID] + a.bytesIn += delta.bytesIn + a.bytesOut += delta.bytesOut + tunnelAgg[tunnelID] = a + } + if len(tunnelAgg) == 0 { + return + } + + metrics := make([]*model.TunnelMetric, 0, len(tunnelAgg)) + for tunnelID, delta := range tunnelAgg { + if delta.bytesIn == 0 && delta.bytesOut == 0 { + continue + } + metrics = append(metrics, &model.TunnelMetric{ + TunnelID: tunnelID, + NodeID: nodeID, + Timestamp: bucketTs, + BytesIn: delta.bytesIn, + BytesOut: delta.bytesOut, + Connections: 0, + Errors: 0, + AvgLatencyMs: 0, + }) + } + if len(metrics) == 0 { + return + } + + if err := h.repo.UpsertTunnelMetricBuckets(metrics); err != nil { + log.Printf("monitoring write failed op=tunnel_metric.upsert_buckets node_id=%d bucket_ts=%d count=%d err=%v", nodeID, bucketTs, len(metrics), err) + } +} diff --git a/go-backend/internal/http/middleware/auth.go b/go-backend/internal/http/middleware/auth.go index 68a1220..68ac48b 100644 --- a/go-backend/internal/http/middleware/auth.go +++ b/go-backend/internal/http/middleware/auth.go @@ -101,6 +101,10 @@ func shouldSkip(path string) bool { } func requiresAdmin(path string) bool { + if strings.HasPrefix(path, "/api/v1/monitor/permission/") { + return true + } + if strings.HasPrefix(path, "/api/v1/group/") { return true } diff --git a/go-backend/internal/metrics/ingestion.go b/go-backend/internal/metrics/ingestion.go new file mode 100644 index 0000000..b076577 --- /dev/null +++ b/go-backend/internal/metrics/ingestion.go @@ -0,0 +1,135 @@ +package metrics + +import ( + "context" + "log" + "sync" + "time" + + "go-backend/internal/store/model" + "go-backend/internal/store/repo" +) + +type SystemInfo struct { + Uptime uint64 `json:"uptime"` + BytesReceived uint64 `json:"bytes_received"` + BytesTransmitted uint64 `json:"bytes_transmitted"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + TCPConns int64 `json:"tcp_conns"` + UDPConns int64 `json:"udp_conns"` + NetInSpeed int64 `json:"net_in_speed"` + NetOutSpeed int64 `json:"net_out_speed"` +} + +type IngestionService struct { + repo *repo.Repository + nodeBuffer []*model.NodeMetric + nodeBufferMu sync.Mutex + flushInterval time.Duration + retentionDays int +} + +func NewIngestionService(repo *repo.Repository) *IngestionService { + return &IngestionService{ + repo: repo, + nodeBuffer: make([]*model.NodeMetric, 0, 500), + flushInterval: 30 * time.Second, + retentionDays: 7, + } +} + +func (s *IngestionService) Start(ctx context.Context) { + flushTicker := time.NewTicker(s.flushInterval) + defer flushTicker.Stop() + + pruneTicker := time.NewTicker(1 * time.Hour) + defer pruneTicker.Stop() + + for { + select { + case <-ctx.Done(): + s.flushNodeMetrics() + return + case <-flushTicker.C: + s.flushNodeMetrics() + case <-pruneTicker.C: + s.pruneMetrics() + } + } +} + +func (s *IngestionService) RecordNodeMetric(nodeID int64, info SystemInfo) { + m := &model.NodeMetric{ + NodeID: nodeID, + Timestamp: time.Now().UnixMilli(), + CPUUsage: info.CPUUsage, + MemUsage: info.MemoryUsage, + DiskUsage: info.DiskUsage, + NetInBytes: int64(info.BytesReceived), + NetOutBytes: int64(info.BytesTransmitted), + NetInSpeed: info.NetInSpeed, + NetOutSpeed: info.NetOutSpeed, + Load1: info.Load1, + Load5: info.Load5, + Load15: info.Load15, + TCPConns: info.TCPConns, + UDPConns: info.UDPConns, + Uptime: int64(info.Uptime), + } + + s.nodeBufferMu.Lock() + s.nodeBuffer = append(s.nodeBuffer, m) + shouldFlush := len(s.nodeBuffer) >= 200 + s.nodeBufferMu.Unlock() + + if shouldFlush { + go s.flushNodeMetrics() + } +} + +func (s *IngestionService) flushNodeMetrics() { + s.nodeBufferMu.Lock() + if len(s.nodeBuffer) == 0 { + s.nodeBufferMu.Unlock() + return + } + buffer := s.nodeBuffer + s.nodeBuffer = make([]*model.NodeMetric, 0, 500) + s.nodeBufferMu.Unlock() + + if s.repo == nil { + return + } + if err := s.repo.InsertNodeMetricBatch(buffer); err != nil { + log.Printf("monitoring write failed op=node_metric.flush count=%d err=%v", len(buffer), err) + } +} + +func (s *IngestionService) pruneMetrics() { + cutoff := time.Now().Add(-time.Duration(s.retentionDays) * 24 * time.Hour).UnixMilli() + if s.repo == nil { + return + } + if err := s.repo.PruneNodeMetrics(cutoff); err != nil { + log.Printf("monitoring prune failed op=node_metric cutoff=%d err=%v", cutoff, err) + } + if err := s.repo.PruneTunnelMetrics(cutoff); err != nil { + log.Printf("monitoring prune failed op=tunnel_metric cutoff=%d err=%v", cutoff, err) + } + if err := s.repo.PruneServiceMonitorResults(cutoff); err != nil { + log.Printf("monitoring prune failed op=service_monitor_result cutoff=%d err=%v", cutoff, err) + } +} + +func (s *IngestionService) GetLatestMetric(nodeID int64) (*model.NodeMetric, error) { + return s.repo.GetLatestNodeMetric(nodeID) +} + +func (s *IngestionService) GetMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) { + return s.repo.GetNodeMetrics(nodeID, startMs, endMs) +} diff --git a/go-backend/internal/metrics/ingestion_test.go b/go-backend/internal/metrics/ingestion_test.go new file mode 100644 index 0000000..3d5f679 --- /dev/null +++ b/go-backend/internal/metrics/ingestion_test.go @@ -0,0 +1,294 @@ +package metrics + +import ( + "context" + "testing" + "time" + + "go-backend/internal/store/repo" +) + +func TestRecordNodeMetric(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + + info := SystemInfo{ + Uptime: 86400, + BytesReceived: 1024000, + BytesTransmitted: 2048000, + CPUUsage: 45.5, + MemoryUsage: 60.2, + DiskUsage: 30.1, + Load1: 1.5, + Load5: 1.2, + Load15: 0.9, + TCPConns: 100, + UDPConns: 50, + NetInSpeed: 51200, + NetOutSpeed: 102400, + } + + svc.RecordNodeMetric(1, info) + svc.flushNodeMetrics() + + metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 metric, got %d", len(metrics)) + } + + m := metrics[0] + if m.CPUUsage != 45.5 { + t.Fatalf("expected CPUUsage 45.5, got %f", m.CPUUsage) + } + if m.MemUsage != 60.2 { + t.Fatalf("expected MemUsage 60.2, got %f", m.MemUsage) + } + if m.DiskUsage != 30.1 { + t.Fatalf("expected DiskUsage 30.1, got %f", m.DiskUsage) + } + if m.Load1 != 1.5 { + t.Fatalf("expected Load1 1.5, got %f", m.Load1) + } + if m.TCPConns != 100 { + t.Fatalf("expected TCPConns 100, got %d", m.TCPConns) + } + if m.UDPConns != 50 { + t.Fatalf("expected UDPConns 50, got %d", m.UDPConns) + } +} + +func TestRecordNodeMetricAutoFlush(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + + info := SystemInfo{ + CPUUsage: 50.0, + MemoryUsage: 60.0, + DiskUsage: 30.0, + } + + for i := 0; i < 250; i++ { + svc.RecordNodeMetric(1, info) + } + + time.Sleep(100 * time.Millisecond) + + metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(metrics) < 200 { + t.Fatalf("expected at least 200 metrics after auto-flush, got %d", len(metrics)) + } +} + +func TestIngestionServiceStart(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + svc.flushInterval = 100 * time.Millisecond + + ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) + defer cancel() + + info := SystemInfo{ + CPUUsage: 45.0, + MemoryUsage: 55.0, + DiskUsage: 35.0, + } + + go svc.Start(ctx) + + for i := 0; i < 10; i++ { + svc.RecordNodeMetric(1, info) + time.Sleep(50 * time.Millisecond) + } + + <-ctx.Done() + + metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(metrics) == 0 { + t.Fatalf("expected metrics after service run") + } +} + +func TestGetLatestMetric(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + + now := time.Now().UnixMilli() + + info1 := SystemInfo{CPUUsage: 40.0, MemoryUsage: 50.0, DiskUsage: 30.0} + svc.RecordNodeMetric(1, info1) + + time.Sleep(5 * time.Millisecond) + + info2 := SystemInfo{CPUUsage: 60.0, MemoryUsage: 70.0, DiskUsage: 40.0} + svc.RecordNodeMetric(1, info2) + + svc.flushNodeMetrics() + + latest, err := svc.GetLatestMetric(1) + if err != nil { + t.Fatalf("get latest: %v", err) + } + if latest == nil { + t.Fatalf("expected latest metric") + } + if latest.CPUUsage != 60.0 { + t.Fatalf("expected latest CPUUsage 60.0, got %f", latest.CPUUsage) + } + + _ = now + + latestNone, err := svc.GetLatestMetric(999) + if err != nil { + t.Fatalf("get latest for non-existent: %v", err) + } + if latestNone != nil { + t.Fatalf("expected nil for non-existent node") + } +} + +func TestGetMetricsWithTimeRange(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + + now := time.Now().UnixMilli() + + for i := 0; i < 5; i++ { + info := SystemInfo{ + CPUUsage: float64(40 + i*5), + MemoryUsage: 50.0, + DiskUsage: 30.0, + } + svc.RecordNodeMetric(1, info) + time.Sleep(10 * time.Millisecond) + } + + svc.flushNodeMetrics() + + metrics, err := svc.GetMetrics(1, 0, now+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(metrics) != 5 { + t.Fatalf("expected 5 metrics, got %d", len(metrics)) + } +} + +func TestPruneMetrics(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + svc.retentionDays = 1 + + info := SystemInfo{CPUUsage: 50.0, MemoryUsage: 60.0, DiskUsage: 30.0} + + svc.RecordNodeMetric(1, info) + svc.flushNodeMetrics() + + svc.pruneMetrics() + + metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 metric (not pruned), got %d", len(metrics)) + } +} + +func TestMultipleNodes(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + + info := SystemInfo{ + CPUUsage: 50.0, + MemoryUsage: 60.0, + DiskUsage: 30.0, + } + + svc.RecordNodeMetric(1, info) + svc.RecordNodeMetric(2, info) + svc.RecordNodeMetric(3, info) + + svc.flushNodeMetrics() + + for nodeID := int64(1); nodeID <= 3; nodeID++ { + metrics, err := r.GetNodeMetrics(nodeID, 0, time.Now().UnixMilli()+1000) + if err != nil { + t.Fatalf("get metrics for node %d: %v", nodeID, err) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 metric for node %d, got %d", nodeID, len(metrics)) + } + } +} + +func TestZeroValues(t *testing.T) { + r, err := repo.Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + svc := NewIngestionService(r) + + info := SystemInfo{} + + svc.RecordNodeMetric(1, info) + svc.flushNodeMetrics() + + metrics, err := r.GetNodeMetrics(1, 0, time.Now().UnixMilli()+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 metric, got %d", len(metrics)) + } + + m := metrics[0] + if m.CPUUsage != 0 || m.MemUsage != 0 || m.DiskUsage != 0 { + t.Fatalf("expected zero values, got CPU=%f Mem=%f Disk=%f", m.CPUUsage, m.MemUsage, m.DiskUsage) + } +} diff --git a/go-backend/internal/monitoring/limits.go b/go-backend/internal/monitoring/limits.go new file mode 100644 index 0000000..e9c8bc4 --- /dev/null +++ b/go-backend/internal/monitoring/limits.go @@ -0,0 +1,114 @@ +package monitoring + +import ( + "strconv" + "strings" +) + +type ServiceMonitorLimits struct { + CheckerScanIntervalSec int `json:"checkerScanIntervalSec"` + WorkerLimit int `json:"workerLimit"` + + MinIntervalSec int `json:"minIntervalSec"` + DefaultIntervalSec int `json:"defaultIntervalSec"` + + MinTimeoutSec int `json:"minTimeoutSec"` + DefaultTimeoutSec int `json:"defaultTimeoutSec"` + MaxTimeoutSec int `json:"maxTimeoutSec"` +} + +const ( + ConfigServiceMonitorCheckerScanIntervalSec = "service_monitor_checker_scan_interval_sec" + ConfigServiceMonitorWorkerLimit = "service_monitor_worker_limit" + ConfigServiceMonitorMinIntervalSec = "service_monitor_min_interval_sec" + ConfigServiceMonitorDefaultIntervalSec = "service_monitor_default_interval_sec" + ConfigServiceMonitorMinTimeoutSec = "service_monitor_min_timeout_sec" + ConfigServiceMonitorDefaultTimeoutSec = "service_monitor_default_timeout_sec" + ConfigServiceMonitorMaxTimeoutSec = "service_monitor_max_timeout_sec" +) + +func DefaultServiceMonitorLimits() ServiceMonitorLimits { + return ServiceMonitorLimits{ + CheckerScanIntervalSec: 30, + WorkerLimit: 5, + MinIntervalSec: 30, + DefaultIntervalSec: 60, + MinTimeoutSec: 1, + DefaultTimeoutSec: 5, + MaxTimeoutSec: 60, + } +} + +// ServiceMonitorLimitsFromConfigMap parses limits from vite_config values. +// Missing/invalid values fall back to defaults. +func ServiceMonitorLimitsFromConfigMap(cfg map[string]string) ServiceMonitorLimits { + limits := DefaultServiceMonitorLimits() + if cfg == nil { + return limits + } + + limits.CheckerScanIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorCheckerScanIntervalSec, limits.CheckerScanIntervalSec) + limits.WorkerLimit = parseConfigInt(cfg, ConfigServiceMonitorWorkerLimit, limits.WorkerLimit) + limits.MinIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorMinIntervalSec, limits.MinIntervalSec) + limits.DefaultIntervalSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultIntervalSec, limits.DefaultIntervalSec) + limits.MinTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMinTimeoutSec, limits.MinTimeoutSec) + limits.DefaultTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorDefaultTimeoutSec, limits.DefaultTimeoutSec) + limits.MaxTimeoutSec = parseConfigInt(cfg, ConfigServiceMonitorMaxTimeoutSec, limits.MaxTimeoutSec) + + return normalizeServiceMonitorLimits(limits) +} + +func normalizeServiceMonitorLimits(limits ServiceMonitorLimits) ServiceMonitorLimits { + if limits.CheckerScanIntervalSec <= 0 { + limits.CheckerScanIntervalSec = 30 + } + if limits.WorkerLimit <= 0 { + limits.WorkerLimit = 5 + } + if limits.WorkerLimit > 50 { + limits.WorkerLimit = 50 + } + + if limits.MinIntervalSec <= 0 { + limits.MinIntervalSec = limits.CheckerScanIntervalSec + } + if limits.MinIntervalSec < limits.CheckerScanIntervalSec { + limits.MinIntervalSec = limits.CheckerScanIntervalSec + } + if limits.DefaultIntervalSec <= 0 { + limits.DefaultIntervalSec = 60 + } + if limits.DefaultIntervalSec < limits.MinIntervalSec { + limits.DefaultIntervalSec = limits.MinIntervalSec + } + + if limits.MinTimeoutSec <= 0 { + limits.MinTimeoutSec = 1 + } + if limits.DefaultTimeoutSec <= 0 { + limits.DefaultTimeoutSec = 5 + } + if limits.DefaultTimeoutSec < limits.MinTimeoutSec { + limits.DefaultTimeoutSec = limits.MinTimeoutSec + } + if limits.MaxTimeoutSec <= 0 { + limits.MaxTimeoutSec = 60 + } + if limits.MaxTimeoutSec < limits.DefaultTimeoutSec { + limits.MaxTimeoutSec = limits.DefaultTimeoutSec + } + + return limits +} + +func parseConfigInt(cfg map[string]string, key string, fallback int) int { + v := strings.TrimSpace(cfg[key]) + if v == "" { + return fallback + } + n, err := strconv.Atoi(v) + if err != nil { + return fallback + } + return n +} diff --git a/go-backend/internal/store/model/model.go b/go-backend/internal/store/model/model.go index 0d03ee7..0b74721 100644 --- a/go-backend/internal/store/model/model.go +++ b/go-backend/internal/store/model/model.go @@ -234,6 +234,16 @@ type GroupPermissionGrant struct { func (GroupPermissionGrant) TableName() string { return "group_permission_grant" } +// MonitorPermission grants a non-admin user access to monitoring endpoints. +// One row per user_id. +type MonitorPermission struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + UserID int64 `gorm:"column:user_id;not null;uniqueIndex:idx_monitor_permission_user" json:"userId"` + CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"` +} + +func (MonitorPermission) TableName() string { return "monitor_permission" } + type ViteConfig struct { ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` Name string `gorm:"type:varchar(200);not null;uniqueIndex" json:"name"` @@ -640,3 +650,66 @@ type UserForwardDetail struct { Status int CreatedAt int64 } + +type NodeMetric struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + NodeID int64 `gorm:"column:node_id;not null;index:idx_node_metric_node_time,priority:1" json:"nodeId"` + Timestamp int64 `gorm:"not null;index:idx_node_metric_node_time,priority:2;index:idx_node_metric_time" json:"timestamp"` + CPUUsage float64 `gorm:"column:cpu_usage" json:"cpuUsage"` + MemUsage float64 `gorm:"column:mem_usage" json:"memoryUsage"` + DiskUsage float64 `gorm:"column:disk_usage" json:"diskUsage"` + NetInBytes int64 `gorm:"column:net_in_bytes" json:"netInBytes"` + NetOutBytes int64 `gorm:"column:net_out_bytes" json:"netOutBytes"` + NetInSpeed int64 `gorm:"column:net_in_speed" json:"netInSpeed"` + NetOutSpeed int64 `gorm:"column:net_out_speed" json:"netOutSpeed"` + Load1 float64 `gorm:"column:load1" json:"load1"` + Load5 float64 `gorm:"column:load5" json:"load5"` + Load15 float64 `gorm:"column:load15" json:"load15"` + TCPConns int64 `gorm:"column:tcp_conns" json:"tcpConns"` + UDPConns int64 `gorm:"column:udp_conns" json:"udpConns"` + Uptime int64 `gorm:"column:uptime" json:"uptime"` +} + +func (NodeMetric) TableName() string { return "node_metric" } + +type TunnelMetric struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + TunnelID int64 `gorm:"column:tunnel_id;not null;index:idx_tunnel_metric_tunnel_time,priority:1" json:"tunnelId"` + NodeID int64 `gorm:"column:node_id;not null;index:idx_tunnel_metric_tunnel_time,priority:2" json:"nodeId"` + Timestamp int64 `gorm:"not null;index:idx_tunnel_metric_tunnel_time,priority:3;index:idx_tunnel_metric_time" json:"timestamp"` + BytesIn int64 `gorm:"column:bytes_in" json:"bytesIn"` + BytesOut int64 `gorm:"column:bytes_out" json:"bytesOut"` + Connections int64 `gorm:"column:connections" json:"connections"` + Errors int64 `gorm:"column:errors" json:"errors"` + AvgLatencyMs float64 `gorm:"column:avg_latency_ms" json:"avgLatencyMs"` +} + +func (TunnelMetric) TableName() string { return "tunnel_metric" } + +type ServiceMonitor struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + Name string `gorm:"type:varchar(100);not null" json:"name"` + Type string `gorm:"type:varchar(20);not null" json:"type"` + Target string `gorm:"type:text;not null" json:"target"` + IntervalSec int `gorm:"column:interval_sec;not null;default:60" json:"intervalSec"` + TimeoutSec int `gorm:"column:timeout_sec;not null;default:5" json:"timeoutSec"` + NodeID int64 `gorm:"column:node_id;index" json:"nodeId"` + Enabled int `gorm:"not null;default:1" json:"enabled"` + CreatedTime int64 `gorm:"column:created_time;not null" json:"createdTime"` + UpdatedTime int64 `gorm:"column:updated_time;not null" json:"updatedTime"` +} + +func (ServiceMonitor) TableName() string { return "service_monitor" } + +type ServiceMonitorResult struct { + ID int64 `gorm:"primaryKey;autoIncrement" json:"id"` + MonitorID int64 `gorm:"column:monitor_id;not null;index:idx_monitor_result_monitor_time,priority:1" json:"monitorId"` + NodeID int64 `gorm:"column:node_id;not null;index" json:"nodeId"` + Timestamp int64 `gorm:"not null;index:idx_monitor_result_monitor_time,priority:2" json:"timestamp"` + Success int `gorm:"not null" json:"success"` + LatencyMs float64 `gorm:"column:latency_ms" json:"latencyMs"` + StatusCode int `gorm:"column:status_code" json:"statusCode"` + ErrorMessage string `gorm:"column:error_message;type:text" json:"errorMessage"` +} + +func (ServiceMonitorResult) TableName() string { return "service_monitor_result" } diff --git a/go-backend/internal/store/repo/repository.go b/go-backend/internal/store/repo/repository.go index 445d565..8de9370 100644 --- a/go-backend/internal/store/repo/repository.go +++ b/go-backend/internal/store/repo/repository.go @@ -47,6 +47,10 @@ type UserGroupBackup = model.UserGroupBackup type PermissionBackup = model.PermissionBackup type PermissionGrantBackup = model.PermissionGrantBackup type ImportResult = model.ImportResult +type NodeMetric = model.NodeMetric +type TunnelMetric = model.TunnelMetric +type ServiceMonitor = model.ServiceMonitor +type ServiceMonitorResult = model.ServiceMonitorResult // ─── Repository ────────────────────────────────────────────────────── @@ -176,12 +180,17 @@ func autoMigrateAll(db *gorm.DB) error { &model.UserGroupUser{}, &model.GroupPermission{}, &model.GroupPermissionGrant{}, + &model.MonitorPermission{}, &model.ViteConfig{}, &model.PeerShare{}, &model.PeerShareRuntime{}, &model.FederationTunnelBinding{}, &model.Announcement{}, &model.SchemaVersion{}, + &model.NodeMetric{}, + &model.TunnelMetric{}, + &model.ServiceMonitor{}, + &model.ServiceMonitorResult{}, } if db.Dialector.Name() != "sqlite" { @@ -394,6 +403,24 @@ func (r *Repository) ListConfigs() (map[string]string, error) { return result, nil } +func (r *Repository) GetConfigsByNames(names []string) (map[string]string, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + if len(names) == 0 { + return map[string]string{}, nil + } + var configs []model.ViteConfig + if err := r.db.Select("name", "value").Where("name IN ?", names).Find(&configs).Error; err != nil { + return nil, err + } + result := make(map[string]string, len(configs)) + for _, c := range configs { + result[c.Name] = c.Value + } + return result, nil +} + func (r *Repository) UpsertConfig(name, value string, now int64) error { if r == nil || r.db == nil { return errors.New("repository not initialized") @@ -2689,12 +2716,13 @@ func (r *Repository) GetUserTunnelByID(id int64) (*model.UserTunnel, error) { // ─── Migration ─────────────────────────────────────────────────────── -const currentSchemaVersion = 5 +const currentSchemaVersion = 6 var ensurePostgresIDDefaultsFn = ensurePostgresIDDefaults var migrateViteConfigValueColumnTypeFn = migrateViteConfigValueColumnType var migrateSpeedLimitTunnelBindingFn = migrateSpeedLimitTunnelBinding var migratePostgresTrafficInt64ColumnsFn = migratePostgresTrafficInt64Columns +var migrateTunnelMetricBucketUniqueIndexFn = migrateTunnelMetricBucketUniqueIndex func getSchemaVersion(db *gorm.DB) int { var v model.SchemaVersion @@ -2764,6 +2792,12 @@ func migrateSchema(db *gorm.DB) error { } } + if ver < 6 { + if err := migrateTunnelMetricBucketUniqueIndexFn(db); err != nil { + return err + } + } + setSchemaVersion(db, currentSchemaVersion) return nil } @@ -2867,6 +2901,130 @@ func migratePostgresTrafficInt64Columns(db *gorm.DB) error { return nil } +func migrateTunnelMetricBucketUniqueIndex(db *gorm.DB) error { + if db == nil { + return errors.New("nil db") + } + + if !db.Migrator().HasTable(&model.TunnelMetric{}) { + return nil + } + + return db.Transaction(func(tx *gorm.DB) error { + // Only do the heavier dedupe work when needed. + var dupGroups int64 + q := ` + SELECT COUNT(1) AS cnt + FROM ( + SELECT 1 + FROM tunnel_metric + GROUP BY tunnel_id, node_id, timestamp + HAVING COUNT(*) > 1 + ) t + ` + if err := tx.Raw(q).Scan(&dupGroups).Error; err != nil { + return fmt.Errorf("inspect tunnel_metric duplicates: %w", err) + } + + if dupGroups > 0 { + switch tx.Dialector.Name() { + case "postgres": + sql := ` + WITH agg AS ( + SELECT MIN(id) AS keep_id, + tunnel_id, + node_id, + timestamp, + SUM(bytes_in) AS bytes_in, + SUM(bytes_out) AS bytes_out, + SUM(connections) AS connections, + SUM(errors) AS errors, + AVG(avg_latency_ms) AS avg_latency_ms + FROM tunnel_metric + GROUP BY tunnel_id, node_id, timestamp + HAVING COUNT(*) > 1 + ), updated AS ( + UPDATE tunnel_metric tm + SET bytes_in = agg.bytes_in, + bytes_out = agg.bytes_out, + connections = agg.connections, + errors = agg.errors, + avg_latency_ms = agg.avg_latency_ms + FROM agg + WHERE tm.id = agg.keep_id + RETURNING tm.id + ) + DELETE FROM tunnel_metric tm + USING agg + WHERE tm.tunnel_id = agg.tunnel_id + AND tm.node_id = agg.node_id + AND tm.timestamp = agg.timestamp + AND tm.id <> agg.keep_id + ` + if err := tx.Exec(sql).Error; err != nil { + return fmt.Errorf("dedupe tunnel_metric buckets: %w", err) + } + default: + // SQLite (and other) path. + if err := tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error; err != nil { + return fmt.Errorf("prepare tunnel_metric dedupe table: %w", err) + } + if err := tx.Exec(` + CREATE TEMP TABLE tunnel_metric_dedupe AS + SELECT MIN(id) AS keep_id, + tunnel_id, + node_id, + timestamp, + SUM(bytes_in) AS bytes_in, + SUM(bytes_out) AS bytes_out, + SUM(connections) AS connections, + SUM(errors) AS errors, + AVG(avg_latency_ms) AS avg_latency_ms + FROM tunnel_metric + GROUP BY tunnel_id, node_id, timestamp + HAVING COUNT(*) > 1 + `).Error; err != nil { + return fmt.Errorf("build tunnel_metric dedupe table: %w", err) + } + if err := tx.Exec(` + UPDATE tunnel_metric + SET bytes_in = (SELECT bytes_in FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), + bytes_out = (SELECT bytes_out FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), + connections = (SELECT connections FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), + errors = (SELECT errors FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id), + avg_latency_ms = (SELECT avg_latency_ms FROM tunnel_metric_dedupe d WHERE d.keep_id = tunnel_metric.id) + WHERE id IN (SELECT keep_id FROM tunnel_metric_dedupe) + `).Error; err != nil { + return fmt.Errorf("update tunnel_metric deduped rows: %w", err) + } + if err := tx.Exec(` + DELETE FROM tunnel_metric + WHERE id IN ( + SELECT tm.id + FROM tunnel_metric tm + JOIN tunnel_metric_dedupe d + ON tm.tunnel_id = d.tunnel_id + AND tm.node_id = d.node_id + AND tm.timestamp = d.timestamp + WHERE tm.id <> d.keep_id + ) + `).Error; err != nil { + return fmt.Errorf("delete tunnel_metric duplicates: %w", err) + } + _ = tx.Exec(`DROP TABLE IF EXISTS tunnel_metric_dedupe`).Error + } + } + + // Uniqueness is required for safe upsert on (tunnel_id, node_id, timestamp). + if err := tx.Exec( + `CREATE UNIQUE INDEX IF NOT EXISTS uidx_tunnel_metric_bucket ON tunnel_metric(tunnel_id, node_id, timestamp)`, + ).Error; err != nil { + return fmt.Errorf("create tunnel_metric unique index: %w", err) + } + return nil + }) +} + func alterPostgresColumnToBigIntIfNeeded(db *gorm.DB, tableName, columnName string) error { if db == nil { return errors.New("nil db") @@ -3140,3 +3298,331 @@ var osMkdirAll = func(path string) error { // Suppress unused import warning for log var _ = log.Printf + +func (r *Repository) InsertNodeMetric(m *model.NodeMetric) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Create(m).Error +} + +func (r *Repository) InsertNodeMetricBatch(metrics []*model.NodeMetric) error { + if r == nil || r.db == nil || len(metrics) == 0 { + return nil + } + return r.db.CreateInBatches(metrics, 100).Error +} + +func (r *Repository) GetNodeMetrics(nodeID int64, startMs, endMs int64) ([]model.NodeMetric, error) { + if r == nil || r.db == nil { + return nil, nil + } + var metrics []model.NodeMetric + err := r.db.Where("node_id = ? AND timestamp >= ? AND timestamp <= ?", nodeID, startMs, endMs). + Order("timestamp DESC"). + Limit(5000). + Find(&metrics).Error + if len(metrics) > 1 { + for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 { + metrics[i], metrics[j] = metrics[j], metrics[i] + } + } + return metrics, err +} + +func (r *Repository) GetLatestNodeMetric(nodeID int64) (*model.NodeMetric, error) { + if r == nil || r.db == nil { + return nil, nil + } + var m model.NodeMetric + err := r.db.Where("node_id = ?", nodeID).Order("timestamp DESC").First(&m).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &m, nil +} + +func (r *Repository) PruneNodeMetrics(olderThanMs int64) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.NodeMetric{}).Error +} + +func (r *Repository) InsertTunnelMetric(m *model.TunnelMetric) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Create(m).Error +} + +func (r *Repository) InsertTunnelMetricBatch(metrics []*model.TunnelMetric) error { + if r == nil || r.db == nil || len(metrics) == 0 { + return nil + } + return r.db.CreateInBatches(metrics, 100).Error +} + +// UpsertTunnelMetricBuckets adds the provided metric deltas into per-minute buckets. +// Requires a unique index on (tunnel_id, node_id, timestamp) for safe upserts. +func (r *Repository) UpsertTunnelMetricBuckets(metrics []*model.TunnelMetric) error { + if r == nil || r.db == nil || len(metrics) == 0 { + return nil + } + + // Postgres rejects a single INSERT ... ON CONFLICT when the input contains + // duplicate conflict keys. Pre-aggregate within this batch to keep inserts safe. + type bucketKey struct { + tunnelID int64 + nodeID int64 + timestamp int64 + } + + agg := make(map[bucketKey]*model.TunnelMetric, len(metrics)) + for _, m := range metrics { + if m == nil { + continue + } + if m.TunnelID <= 0 || m.NodeID <= 0 || m.Timestamp <= 0 { + continue + } + if m.BytesIn == 0 && m.BytesOut == 0 && m.Connections == 0 && m.Errors == 0 { + continue + } + + k := bucketKey{tunnelID: m.TunnelID, nodeID: m.NodeID, timestamp: m.Timestamp} + if existing, ok := agg[k]; ok { + existing.BytesIn += m.BytesIn + existing.BytesOut += m.BytesOut + existing.Connections += m.Connections + existing.Errors += m.Errors + if existing.AvgLatencyMs == 0 && m.AvgLatencyMs != 0 { + existing.AvgLatencyMs = m.AvgLatencyMs + } + continue + } + cp := *m + agg[k] = &cp + } + if len(agg) == 0 { + return nil + } + + rows := make([]*model.TunnelMetric, 0, len(agg)) + for _, v := range agg { + rows = append(rows, v) + } + + return r.db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "tunnel_id"}, {Name: "node_id"}, {Name: "timestamp"}}, + DoUpdates: clause.Assignments(map[string]interface{}{ + "bytes_in": gorm.Expr("bytes_in + excluded.bytes_in"), + "bytes_out": gorm.Expr("bytes_out + excluded.bytes_out"), + "connections": gorm.Expr("connections + excluded.connections"), + "errors": gorm.Expr("errors + excluded.errors"), + // avg_latency_ms is not additive; keep the existing bucket value. + }), + }).CreateInBatches(rows, 100).Error +} + +func (r *Repository) GetTunnelMetrics(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) { + if r == nil || r.db == nil { + return nil, nil + } + var metrics []model.TunnelMetric + err := r.db.Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs). + Order("timestamp DESC"). + Limit(5000). + Find(&metrics).Error + if len(metrics) > 1 { + for i, j := 0, len(metrics)-1; i < j; i, j = i+1, j-1 { + metrics[i], metrics[j] = metrics[j], metrics[i] + } + } + return metrics, err +} + +// GetTunnelMetricsAggregated returns tunnel-level aggregated series (one point per timestamp). +// Storage remains per (tunnel_id, node_id, timestamp) for future drill-down. +func (r *Repository) GetTunnelMetricsAggregated(tunnelID int64, startMs, endMs int64) ([]model.TunnelMetric, error) { + if r == nil || r.db == nil { + return nil, nil + } + + var metrics []model.TunnelMetric + err := r.db.Model(&model.TunnelMetric{}). + Select( + "tunnel_id, 0 AS node_id, timestamp, "+ + "SUM(bytes_in) AS bytes_in, "+ + "SUM(bytes_out) AS bytes_out, "+ + "SUM(connections) AS connections, "+ + "SUM(errors) AS errors, "+ + "AVG(avg_latency_ms) AS avg_latency_ms", + ). + Where("tunnel_id = ? AND timestamp >= ? AND timestamp <= ?", tunnelID, startMs, endMs). + Group("tunnel_id, timestamp"). + Order("timestamp ASC"). + Limit(5000). + Scan(&metrics).Error + if metrics == nil { + metrics = make([]model.TunnelMetric, 0) + } + return metrics, err +} + +func (r *Repository) PruneTunnelMetrics(olderThanMs int64) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.TunnelMetric{}).Error +} + +func (r *Repository) ListServiceMonitors() ([]model.ServiceMonitor, error) { + if r == nil || r.db == nil { + return nil, nil + } + var monitors []model.ServiceMonitor + err := r.db.Order("id ASC").Find(&monitors).Error + return monitors, err +} + +func (r *Repository) ListEnabledServiceMonitors() ([]model.ServiceMonitor, error) { + if r == nil || r.db == nil { + return nil, nil + } + var monitors []model.ServiceMonitor + err := r.db.Where("enabled = 1 AND type IN (?)", []string{"tcp", "icmp"}).Order("id ASC").Find(&monitors).Error + return monitors, err +} + +func (r *Repository) GetServiceMonitor(id int64) (*model.ServiceMonitor, error) { + if r == nil || r.db == nil { + return nil, nil + } + var m model.ServiceMonitor + err := r.db.First(&m, id).Error + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil + } + return nil, err + } + return &m, nil +} + +func (r *Repository) CreateServiceMonitor(m *model.ServiceMonitor) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Create(m).Error +} + +func (r *Repository) UpdateServiceMonitor(m *model.ServiceMonitor) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Save(m).Error +} + +func (r *Repository) DeleteServiceMonitor(id int64) error { + if r == nil || r.db == nil { + return nil + } + if id <= 0 { + return nil + } + + // Keep API/UI semantics simple: deleting a monitor also deletes its history. + return r.db.Transaction(func(tx *gorm.DB) error { + if err := tx.Where("monitor_id = ?", id).Delete(&model.ServiceMonitorResult{}).Error; err != nil { + return err + } + return tx.Delete(&model.ServiceMonitor{}, id).Error + }) +} + +func (r *Repository) InsertServiceMonitorResult(result *model.ServiceMonitorResult) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Create(result).Error +} + +func (r *Repository) GetServiceMonitorResults(monitorID int64, limit int) ([]model.ServiceMonitorResult, error) { + if r == nil || r.db == nil { + return nil, nil + } + if limit <= 0 { + limit = 100 + } + var results []model.ServiceMonitorResult + err := r.db.Where("monitor_id = ?", monitorID). + Order("timestamp DESC"). + Limit(limit). + Find(&results).Error + return results, err +} + +// GetLatestServiceMonitorResults returns the newest result per monitor_id. +// This is intended for list rendering (avoid N+1 queries). +func (r *Repository) GetLatestServiceMonitorResults() ([]model.ServiceMonitorResult, error) { + if r == nil || r.db == nil { + return nil, nil + } + + var results []model.ServiceMonitorResult + + // Prefer a window-function query (works on modern SQLite + Postgres). + q1 := ` + SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message + FROM ( + SELECT *, ROW_NUMBER() OVER (PARTITION BY monitor_id ORDER BY timestamp DESC, id DESC) AS rn + FROM service_monitor_result + ) t + WHERE rn = 1 + ORDER BY monitor_id ASC + ` + if err := r.db.Raw(q1).Scan(&results).Error; err == nil { + return results, nil + } + + // Fallback: just return newest rows (best-effort). This avoids hard failure on older SQLite builds. + // Note: This may not include all monitors if the table is extremely large and skewed. + results = nil + q2 := ` + SELECT id, monitor_id, node_id, timestamp, success, latency_ms, status_code, error_message + FROM service_monitor_result + ORDER BY timestamp DESC, id DESC + LIMIT 5000 + ` + err := r.db.Raw(q2).Scan(&results).Error + if err != nil { + return nil, err + } + + seen := make(map[int64]struct{}, len(results)) + out := make([]model.ServiceMonitorResult, 0, len(results)) + for _, row := range results { + if row.MonitorID <= 0 { + continue + } + if _, ok := seen[row.MonitorID]; ok { + continue + } + seen[row.MonitorID] = struct{}{} + out = append(out, row) + } + // Keep response stable for the frontend. + sort.Slice(out, func(i, j int) bool { return out[i].MonitorID < out[j].MonitorID }) + return out, nil +} + +func (r *Repository) PruneServiceMonitorResults(olderThanMs int64) error { + if r == nil || r.db == nil { + return nil + } + return r.db.Where("timestamp < ?", olderThanMs).Delete(&model.ServiceMonitorResult{}).Error +} diff --git a/go-backend/internal/store/repo/repository_control.go b/go-backend/internal/store/repo/repository_control.go index 7bc0922..3739813 100644 --- a/go-backend/internal/store/repo/repository_control.go +++ b/go-backend/internal/store/repo/repository_control.go @@ -57,6 +57,37 @@ func (r *Repository) ListForwardsByTunnel(tunnelID int64) ([]model.ForwardRecord return rows, nil } +func (r *Repository) ListForwardsByTunnelTx(tx *gorm.DB, tunnelID int64) ([]model.ForwardRecord, error) { + if tx == nil { + return nil, errors.New("database unavailable") + } + var forwards []model.Forward + err := tx.Where("tunnel_id = ?", tunnelID).Order("id ASC").Find(&forwards).Error + if err != nil { + return nil, err + } + rows := make([]model.ForwardRecord, 0, len(forwards)) + for _, f := range forwards { + rows = append(rows, model.ForwardRecord{ + ID: f.ID, + UserID: f.UserID, + UserName: f.UserName, + Name: f.Name, + TunnelID: f.TunnelID, + RemoteAddr: f.RemoteAddr, + Strategy: f.Strategy, + Status: f.Status, + SpeedID: f.SpeedID, + }) + } + for i := range rows { + if strings.TrimSpace(rows[i].Strategy) == "" { + rows[i].Strategy = "fifo" + } + } + return rows, nil +} + func (r *Repository) ListActiveTunnelIDsByNode(nodeID int64) ([]int64, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") @@ -111,6 +142,26 @@ func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecor return rows, nil } +func (r *Repository) ListForwardPortsTx(tx *gorm.DB, forwardID int64) ([]model.ForwardPortRecord, error) { + if tx == nil { + return nil, errors.New("database unavailable") + } + var ports []model.ForwardPort + err := tx.Where("forward_id = ?", forwardID).Order("id ASC").Find(&ports).Error + if err != nil { + return nil, err + } + rows := make([]model.ForwardPortRecord, 0, len(ports)) + for _, p := range ports { + inIP := "" + if p.InIP.Valid { + inIP = p.InIP.String + } + rows = append(rows, model.ForwardPortRecord{NodeID: p.NodeID, Port: p.Port, InIP: inIP}) + } + return rows, nil +} + func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentForwardID int64) (bool, error) { if r == nil || r.db == nil { return false, errors.New("repository not initialized") @@ -130,6 +181,25 @@ func (r *Repository) HasOtherForwardOnNodePort(nodeID int64, port int, currentFo return count > 0, nil } +func (r *Repository) HasOtherForwardOnNodePortTx(tx *gorm.DB, nodeID int64, port int, currentForwardID int64) (bool, error) { + if tx == nil { + return false, errors.New("database unavailable") + } + if nodeID <= 0 || port <= 0 { + return false, nil + } + + var count int64 + err := tx.Model(&model.ForwardPort{}). + Where("node_id = ? AND port = ? AND forward_id <> ?", nodeID, port, currentForwardID). + Count(&count).Error + if err != nil { + return false, err + } + + return count > 0, nil +} + func (r *Repository) GetTunnelOutProtocol(tunnelID int64) (string, error) { if r == nil || r.db == nil { return "", errors.New("repository not initialized") diff --git a/go-backend/internal/store/repo/repository_flow.go b/go-backend/internal/store/repo/repository_flow.go index 15c9762..dcde7a1 100644 --- a/go-backend/internal/store/repo/repository_flow.go +++ b/go-backend/internal/store/repo/repository_flow.go @@ -161,6 +161,64 @@ func (r *Repository) ForwardExists(forwardID int64) (bool, error) { return count > 0, nil } +// MapForwardIDsToTunnelIDs returns a mapping from forward.id to forward.tunnel_id. +// Missing forward IDs are omitted from the returned map. +func (r *Repository) MapForwardIDsToTunnelIDs(forwardIDs []int64) (map[int64]int64, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + if len(forwardIDs) == 0 { + return map[int64]int64{}, nil + } + + // Deduplicate and filter invalid IDs. + ids := make([]int64, 0, len(forwardIDs)) + seen := make(map[int64]struct{}, len(forwardIDs)) + for _, id := range forwardIDs { + if id <= 0 { + continue + } + if _, ok := seen[id]; ok { + continue + } + seen[id] = struct{}{} + ids = append(ids, id) + } + if len(ids) == 0 { + return map[int64]int64{}, nil + } + + type row struct { + ID int64 `gorm:"column:id"` + TunnelID int64 `gorm:"column:tunnel_id"` + } + + out := make(map[int64]int64, len(ids)) + const chunkSize = 500 + for start := 0; start < len(ids); start += chunkSize { + end := start + chunkSize + if end > len(ids) { + end = len(ids) + } + + var rows []row + if err := r.db.Model(&model.Forward{}). + Select("id", "tunnel_id"). + Where("id IN ?", ids[start:end]). + Find(&rows).Error; err != nil { + return nil, err + } + for _, r := range rows { + if r.ID <= 0 || r.TunnelID <= 0 { + continue + } + out[r.ID] = r.TunnelID + } + } + + return out, nil +} + func (r *Repository) SpeedLimitExists(id int64) (bool, error) { if r == nil || r.db == nil { return false, errors.New("repository not initialized") diff --git a/go-backend/internal/store/repo/repository_monitor_nodes.go b/go-backend/internal/store/repo/repository_monitor_nodes.go new file mode 100644 index 0000000..c7b0bbd --- /dev/null +++ b/go-backend/internal/store/repo/repository_monitor_nodes.go @@ -0,0 +1,18 @@ +package repo + +import ( + "errors" + + "go-backend/internal/store/model" +) + +func (r *Repository) ListMonitorNodes() ([]model.Node, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var nodes []model.Node + err := r.db.Select("id", "inx", "name", "status", "updated_time"). + Order("inx ASC, id ASC"). + Find(&nodes).Error + return nodes, err +} diff --git a/go-backend/internal/store/repo/repository_monitor_permission.go b/go-backend/internal/store/repo/repository_monitor_permission.go new file mode 100644 index 0000000..e65bfc7 --- /dev/null +++ b/go-backend/internal/store/repo/repository_monitor_permission.go @@ -0,0 +1,54 @@ +package repo + +import ( + "errors" + + "go-backend/internal/store/model" + + "gorm.io/gorm/clause" +) + +func (r *Repository) InsertMonitorPermission(userID int64, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + if userID <= 0 { + return nil + } + row := model.MonitorPermission{UserID: userID, CreatedTime: now} + return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error +} + +func (r *Repository) DeleteMonitorPermission(userID int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + if userID <= 0 { + return nil + } + return r.db.Where("user_id = ?", userID).Delete(&model.MonitorPermission{}).Error +} + +func (r *Repository) HasMonitorPermission(userID int64) (bool, error) { + if r == nil || r.db == nil { + return false, errors.New("repository not initialized") + } + if userID <= 0 { + return false, nil + } + var count int64 + err := r.db.Model(&model.MonitorPermission{}).Where("user_id = ?", userID).Count(&count).Error + if err != nil { + return false, err + } + return count > 0, nil +} + +func (r *Repository) ListMonitorPermissions() ([]model.MonitorPermission, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var items []model.MonitorPermission + err := r.db.Order("id ASC").Find(&items).Error + return items, err +} diff --git a/go-backend/internal/store/repo/repository_monitor_tunnels.go b/go-backend/internal/store/repo/repository_monitor_tunnels.go new file mode 100644 index 0000000..742b937 --- /dev/null +++ b/go-backend/internal/store/repo/repository_monitor_tunnels.go @@ -0,0 +1,18 @@ +package repo + +import ( + "errors" + + "go-backend/internal/store/model" +) + +func (r *Repository) ListMonitorTunnels() ([]model.Tunnel, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + var tunnels []model.Tunnel + err := r.db.Select("id", "inx", "name", "status", "updated_time"). + Order("inx ASC, id ASC"). + Find(&tunnels).Error + return tunnels, err +} diff --git a/go-backend/internal/store/repo/repository_monitoring_test.go b/go-backend/internal/store/repo/repository_monitoring_test.go new file mode 100644 index 0000000..2d624ff --- /dev/null +++ b/go-backend/internal/store/repo/repository_monitoring_test.go @@ -0,0 +1,134 @@ +package repo + +import ( + "sync" + "testing" + "time" + + "go-backend/internal/store/model" +) + +func TestGetTunnelMetricsAggregatedSumsAcrossNodes(t *testing.T) { + r, err := Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + ts := time.Now().UnixMilli() + + if err := r.InsertTunnelMetric(&model.TunnelMetric{ + TunnelID: 1, + NodeID: 1, + Timestamp: ts, + BytesIn: 100, + BytesOut: 200, + }); err != nil { + t.Fatalf("insert tunnel metric n1: %v", err) + } + if err := r.InsertTunnelMetric(&model.TunnelMetric{ + TunnelID: 1, + NodeID: 2, + Timestamp: ts, + BytesIn: 300, + BytesOut: 400, + }); err != nil { + t.Fatalf("insert tunnel metric n2: %v", err) + } + + metrics, err := r.GetTunnelMetricsAggregated(1, ts-1000, ts+1000) + if err != nil { + t.Fatalf("get aggregated tunnel metrics: %v", err) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 aggregated point, got %d", len(metrics)) + } + if metrics[0].Timestamp != ts { + t.Fatalf("expected timestamp %d, got %d", ts, metrics[0].Timestamp) + } + if metrics[0].BytesIn != 400 { + t.Fatalf("expected bytesIn 400, got %d", metrics[0].BytesIn) + } + if metrics[0].BytesOut != 600 { + t.Fatalf("expected bytesOut 600, got %d", metrics[0].BytesOut) + } +} + +func TestUpsertTunnelMetricBucketsAggregatesDuplicateKeysInBatch(t *testing.T) { + r, err := Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + ts := time.Now().UnixMilli() + + items := []*model.TunnelMetric{ + {TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 10, BytesOut: 20}, + {TunnelID: 1, NodeID: 1, Timestamp: ts, BytesIn: 30, BytesOut: 40}, + } + if err := r.UpsertTunnelMetricBuckets(items); err != nil { + t.Fatalf("upsert buckets: %v", err) + } + + rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000) + if err != nil { + t.Fatalf("get tunnel metrics: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 stored row, got %d", len(rows)) + } + if rows[0].BytesIn != 40 { + t.Fatalf("expected bytesIn 40, got %d", rows[0].BytesIn) + } + if rows[0].BytesOut != 60 { + t.Fatalf("expected bytesOut 60, got %d", rows[0].BytesOut) + } +} + +func TestUpsertTunnelMetricBucketsIsSafeUnderConcurrency(t *testing.T) { + r, err := Open(":memory:") + if err != nil { + t.Fatalf("open repo: %v", err) + } + defer r.Close() + + ts := time.Now().UnixMilli() + + const workers = 20 + const perWorkerIn = int64(5) + const perWorkerOut = int64(7) + + var wg sync.WaitGroup + wg.Add(workers) + for i := 0; i < workers; i++ { + go func() { + defer wg.Done() + _ = r.UpsertTunnelMetricBuckets([]*model.TunnelMetric{{ + TunnelID: 1, + NodeID: 1, + Timestamp: ts, + BytesIn: perWorkerIn, + BytesOut: perWorkerOut, + }}) + }() + } + wg.Wait() + + rows, err := r.GetTunnelMetrics(1, ts-1000, ts+1000) + if err != nil { + t.Fatalf("get tunnel metrics: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 stored row, got %d", len(rows)) + } + + wantIn := int64(workers) * perWorkerIn + wantOut := int64(workers) * perWorkerOut + if rows[0].BytesIn != wantIn { + t.Fatalf("expected bytesIn %d, got %d", wantIn, rows[0].BytesIn) + } + if rows[0].BytesOut != wantOut { + t.Fatalf("expected bytesOut %d, got %d", wantOut, rows[0].BytesOut) + } +} diff --git a/go-backend/internal/ws/server.go b/go-backend/internal/ws/server.go index 7633ef3..d92e400 100644 --- a/go-backend/internal/ws/server.go +++ b/go-backend/internal/ws/server.go @@ -72,6 +72,7 @@ type Server struct { jwtSecret string upgrader websocket.Upgrader onNodeOnline func(nodeID int64) + onNodeMetric func(nodeID int64, info SystemInfo) mu sync.RWMutex admins map[*connWrap]struct{} @@ -80,6 +81,22 @@ type Server struct { pending map[string]pendingRequest } +type SystemInfo struct { + Uptime uint64 `json:"uptime"` + BytesReceived uint64 `json:"bytes_received"` + BytesTransmitted uint64 `json:"bytes_transmitted"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + TCPConns int64 `json:"tcp_conns"` + UDPConns int64 `json:"udp_conns"` + NetInSpeed int64 `json:"net_in_speed"` + NetOutSpeed int64 `json:"net_out_speed"` +} + func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) { if s == nil { return @@ -89,6 +106,15 @@ func (s *Server) SetNodeOnlineHook(fn func(nodeID int64)) { s.mu.Unlock() } +func (s *Server) SetNodeMetricHook(fn func(nodeID int64, info SystemInfo)) { + if s == nil { + return + } + s.mu.Lock() + s.onNodeMetric = fn + s.mu.Unlock() +} + func NewServer(repo *repo.Repository, jwtSecret string) *Server { return &Server{ repo: repo, @@ -231,12 +257,63 @@ func (s *Server) handleNode(w http.ResponseWriter, r *http.Request, nodeID int64 var parsed struct { Type string `json:"type"` } - if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type == "UpgradeProgress" { - s.broadcastTyped(nodeID, "upgrade_progress", msg) - } else { - s.broadcastInfo(nodeID, msg) + if json.Unmarshal([]byte(msg), &parsed) == nil && parsed.Type != "" { + if parsed.Type == "UpgradeProgress" { + s.broadcastTyped(nodeID, "upgrade_progress", msg) + } + continue + } + + if looksLikeSystemInfoMessage(msg) { + var sysInfo SystemInfo + if err := json.Unmarshal([]byte(msg), &sysInfo); err == nil { + s.mu.RLock() + onMetric := s.onNodeMetric + s.mu.RUnlock() + if onMetric != nil { + go onMetric(nodeID, sysInfo) + } + s.broadcastTyped(nodeID, "metric", msg) + continue + } + } + + s.broadcastInfo(nodeID, msg) + } +} + +func looksLikeSystemInfoMessage(msg string) bool { + // Keep this as a cheap heuristic so that arbitrary JSON objects don't get + // misclassified as metrics (SystemInfo unmarshal would otherwise succeed with + // all-zero values). + if strings.TrimSpace(msg) == "" { + return false + } + if !strings.Contains(msg, "{") { + return false + } + + keys := []string{ + "\"uptime\"", + "\"cpu_usage\"", + "\"memory_usage\"", + "\"disk_usage\"", + "\"bytes_received\"", + "\"bytes_transmitted\"", + "\"net_in_speed\"", + "\"net_out_speed\"", + "\"tcp_conns\"", + "\"udp_conns\"", + "\"load1\"", + "\"load5\"", + "\"load15\"", + } + for _, k := range keys { + if strings.Contains(msg, k) { + return true } } + return false } func (s *Server) SendCommand(nodeID int64, cmdType string, data interface{}, timeout time.Duration) (CommandResult, error) { diff --git a/go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go b/go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go index 0d57aaf..9c50682 100644 --- a/go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go +++ b/go-backend/tests/contract/issue313_entry_port_conflict_contract_test.go @@ -113,6 +113,12 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) { t.Fatalf("insert forward_port a: %v", err) } + // Simulate legacy dirty data: tunnel A already occupies port 2000 on entryB2. + // When tunnel B adds entryB2, the inherited forward port should conflict cross-tunnel. + if err := repo.DB().Exec(`INSERT INTO forward_port(forward_id, node_id, port) VALUES(?, ?, ?)`, forwardAID, entryB2, 2000).Error; err != nil { + t.Fatalf("insert forward_port a on entryB2: %v", err) + } + if err := repo.DB().Exec(` INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status) VALUES(3132, 1, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, 1) @@ -171,7 +177,8 @@ func TestIssue313_EntryPortCrossTunnelConflictContract(t *testing.T) { t.Fatalf("expected update failure due to cross-tunnel port conflict, got success with code 0") } - if !bytes.Contains(res.Body.Bytes(), []byte("端口")) && !bytes.Contains(res.Body.Bytes(), []byte("占用")) { + msgBytes := []byte(out.Msg) + if !bytes.Contains(msgBytes, []byte("端口")) && !bytes.Contains(msgBytes, []byte("占用")) { t.Fatalf("expected port conflict error message, got %q", out.Msg) } diff --git a/go-backend/tests/contract/monitoring_contract_test.go b/go-backend/tests/contract/monitoring_contract_test.go new file mode 100644 index 0000000..8dd3b4d --- /dev/null +++ b/go-backend/tests/contract/monitoring_contract_test.go @@ -0,0 +1,1448 @@ +package contract_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go-backend/internal/auth" + "go-backend/internal/http/response" + "go-backend/internal/store/model" +) + +func TestNodeMetricsEndpoints(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + + t.Run("insert and query node metrics", func(t *testing.T) { + metric := &model.NodeMetric{ + NodeID: 1, + Timestamp: now - 1000, + CPUUsage: 45.5, + MemUsage: 60.2, + DiskUsage: 30.1, + NetInBytes: 1024000, + NetOutBytes: 2048000, + NetInSpeed: 51200, + NetOutSpeed: 102400, + Load1: 1.5, + Load5: 1.2, + Load15: 0.9, + TCPConns: 100, + UDPConns: 50, + Uptime: 86400, + } + if err := repo.InsertNodeMetric(metric); err != nil { + t.Fatalf("insert node metric: %v", err) + } + + metric2 := &model.NodeMetric{ + NodeID: 1, + Timestamp: now, + CPUUsage: 50.0, + MemUsage: 65.0, + DiskUsage: 32.0, + NetInBytes: 2048000, + NetOutBytes: 4096000, + NetInSpeed: 102400, + NetOutSpeed: 204800, + Load1: 2.0, + Load5: 1.5, + Load15: 1.0, + TCPConns: 150, + UDPConns: 75, + Uptime: 172800, + } + if err := repo.InsertNodeMetric(metric2); err != nil { + t.Fatalf("insert node metric 2: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/nodes/1/metrics", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + metrics, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(metrics) != 2 { + t.Fatalf("expected 2 metrics, got %d", len(metrics)) + } + }) + + t.Run("get latest node metric", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/nodes/1/metrics/latest", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + metric, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected object, got %T", out.Data) + } + if cpu, _ := metric["cpuUsage"].(float64); cpu != 50.0 { + t.Fatalf("expected cpuUsage 50.0, got %v", cpu) + } + }) + + t.Run("get metrics for non-existent node returns empty array", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/nodes/999/metrics", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + metrics, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(metrics) != 0 { + t.Fatalf("expected 0 metrics for non-existent node, got %d", len(metrics)) + } + }) + + t.Run("get latest metric for non-existent node returns nil", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/nodes/999/metrics/latest", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + if out.Data != nil { + t.Fatalf("expected nil data for non-existent node, got %v", out.Data) + } + }) + + t.Run("invalid node id returns error", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/nodes/invalid/metrics", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected error for invalid node id") + } + }) + + t.Run("query with time range", func(t *testing.T) { + start := now - 2000 + end := now - 500 + path := "/api/v1/monitor/nodes/1/metrics?start=" + jsonNumber(start) + "&end=" + jsonNumber(end) + req := httptest.NewRequest(http.MethodGet, path, nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + metrics, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 metric in time range, got %d", len(metrics)) + } + }) +} + +func TestTunnelMetricsEndpoints(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + + t.Run("insert and query tunnel metrics", func(t *testing.T) { + metric := &model.TunnelMetric{ + TunnelID: 1, + NodeID: 1, + Timestamp: now - 1000, + BytesIn: 1024000, + BytesOut: 2048000, + Connections: 10, + Errors: 0, + AvgLatencyMs: 15.5, + } + if err := repo.InsertTunnelMetric(metric); err != nil { + t.Fatalf("insert tunnel metric: %v", err) + } + + metric2 := &model.TunnelMetric{ + TunnelID: 1, + NodeID: 1, + Timestamp: now, + BytesIn: 2048000, + BytesOut: 4096000, + Connections: 20, + Errors: 1, + AvgLatencyMs: 20.0, + } + if err := repo.InsertTunnelMetric(metric2); err != nil { + t.Fatalf("insert tunnel metric 2: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/tunnels/1/metrics", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + metrics, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(metrics) != 2 { + t.Fatalf("expected 2 metrics, got %d", len(metrics)) + } + }) + + t.Run("get metrics for non-existent tunnel returns empty array", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/tunnels/999/metrics", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + metrics, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(metrics) != 0 { + t.Fatalf("expected 0 metrics for non-existent tunnel, got %d", len(metrics)) + } + }) + + t.Run("invalid tunnel id returns error", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/tunnels/invalid/metrics", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected error for invalid tunnel id") + } + }) +} + +func TestServiceMonitorCRUD(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + // Seed a node for node-executed monitors. + now := time.Now().UnixMilli() + n := &model.Node{ + Name: "node-1", + Secret: "node-secret", + ServerIP: "127.0.0.1", + Port: "10000-10010", + TCPListenAddr: "[::]", + UDPListenAddr: "[::]", + CreatedTime: now, + Status: 0, + } + if err := repo.DB().Create(n).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + + t.Run("create service monitor - TCP", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "DNS Monitor", + "type": "tcp", + "target": "8.8.8.8:53", + "intervalSec": 60, + "timeoutSec": 5, + "nodeId": 0, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/create", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + monitor, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected object, got %T", out.Data) + } + if name, _ := monitor["name"].(string); name != "DNS Monitor" { + t.Fatalf("expected name 'DNS Monitor', got %v", name) + } + if monitorType, _ := monitor["type"].(string); monitorType != "tcp" { + t.Fatalf("expected type 'tcp', got %v", monitorType) + } + }) + + t.Run("create service monitor - ICMP", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "Ping Monitor", + "type": "icmp", + "target": "8.8.8.8", + "intervalSec": 30, + "timeoutSec": 10, + "nodeId": n.ID, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/create", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + }) + + t.Run("list service monitors", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/services", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + monitors, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(monitors) != 2 { + t.Fatalf("expected 2 monitors, got %d", len(monitors)) + } + }) + + t.Run("create monitor with invalid type returns error", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "Invalid Monitor", + "type": "http", + "target": "https://example.com", + "intervalSec": 60, + "timeoutSec": 5, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/create", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected error for invalid monitor type") + } + }) + + t.Run("create monitor with empty name returns error", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "", + "type": "tcp", + "target": "8.8.8.8:53", + "intervalSec": 60, + "timeoutSec": 5, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/create", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected error for empty name") + } + }) + + t.Run("create monitor with empty target returns error", func(t *testing.T) { + payload := map[string]interface{}{ + "name": "No Target", + "type": "tcp", + "target": "", + "intervalSec": 60, + "timeoutSec": 5, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/create", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected error for empty target") + } + }) + + var monitorID int64 + t.Run("update service monitor", func(t *testing.T) { + monitors, _ := repo.ListServiceMonitors() + if len(monitors) == 0 { + t.Fatalf("no monitors to update") + } + monitorID = monitors[0].ID + + payload := map[string]interface{}{ + "id": monitorID, + "name": "Updated DNS Monitor", + "type": "tcp", + "target": "1.1.1.1:53", + "intervalSec": 120, + "timeoutSec": 10, + "nodeId": 0, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/update", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + updated, _ := repo.GetServiceMonitor(monitorID) + if updated.Name != "Updated DNS Monitor" { + t.Fatalf("expected name 'Updated DNS Monitor', got %s", updated.Name) + } + if updated.IntervalSec != 120 { + t.Fatalf("expected interval 120, got %d", updated.IntervalSec) + } + }) + + t.Run("update non-existent monitor returns error", func(t *testing.T) { + payload := map[string]interface{}{ + "id": 99999, + "name": "Non-existent", + "type": "tcp", + "target": "1.1.1.1:53", + "intervalSec": 60, + "timeoutSec": 5, + "enabled": 1, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/update", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code == 0 { + t.Fatalf("expected error for non-existent monitor") + } + }) + + t.Run("delete service monitor", func(t *testing.T) { + monitors, _ := repo.ListServiceMonitors() + if len(monitors) < 2 { + t.Fatalf("need at least 2 monitors to test delete") + } + deleteID := monitors[1].ID + + // Seed history so we can verify deletion cleanup. + if err := repo.InsertServiceMonitorResult(&model.ServiceMonitorResult{ + MonitorID: deleteID, + NodeID: 0, + Timestamp: now, + Success: 1, + LatencyMs: 1, + StatusCode: 0, + ErrorMessage: "", + }); err != nil { + t.Fatalf("seed monitor result: %v", err) + } + + payload := map[string]interface{}{ + "id": deleteID, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/delete", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + remaining, _ := repo.ListServiceMonitors() + if len(remaining) != 1 { + t.Fatalf("expected 1 remaining monitor, got %d", len(remaining)) + } + results, err := repo.GetServiceMonitorResults(deleteID, 10) + if err != nil { + t.Fatalf("get deleted monitor results: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected 0 results for deleted monitor, got %d", len(results)) + } + }) + + t.Run("delete non-existent monitor returns success", func(t *testing.T) { + payload := map[string]interface{}{ + "id": 99999, + } + body, _ := json.Marshal(payload) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/services/delete", bytes.NewReader(body)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 for delete non-existent, got %d: %s", out.Code, out.Msg) + } + }) +} + +func TestServiceMonitorResults(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + + monitor := &model.ServiceMonitor{ + Name: "Test Monitor", + Type: "tcp", + Target: "8.8.8.8:53", + IntervalSec: 60, + TimeoutSec: 5, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := repo.CreateServiceMonitor(monitor); err != nil { + t.Fatalf("create monitor: %v", err) + } + + t.Run("insert and query monitor results", func(t *testing.T) { + for i := 0; i < 5; i++ { + result := &model.ServiceMonitorResult{ + MonitorID: monitor.ID, + NodeID: 0, + Timestamp: now - int64(i*60000), + Success: 1, + LatencyMs: float64(10 + i), + StatusCode: 0, + ErrorMessage: "", + } + if err := repo.InsertServiceMonitorResult(result); err != nil { + t.Fatalf("insert result %d: %v", i, err) + } + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/services/"+jsonNumber(monitor.ID)+"/results", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + results, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(results) != 5 { + t.Fatalf("expected 5 results, got %d", len(results)) + } + }) + + t.Run("query results with limit", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/services/"+jsonNumber(monitor.ID)+"/results?limit=3", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + results, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(results) != 3 { + t.Fatalf("expected 3 results with limit, got %d", len(results)) + } + }) + + t.Run("query results for non-existent monitor returns empty", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/services/99999/results", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + results, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(results) != 0 { + t.Fatalf("expected 0 results for non-existent monitor, got %d", len(results)) + } + }) + + t.Run("result with error message", func(t *testing.T) { + result := &model.ServiceMonitorResult{ + MonitorID: monitor.ID, + NodeID: 0, + Timestamp: now, + Success: 0, + LatencyMs: 0, + StatusCode: 0, + ErrorMessage: "connection refused", + } + if err := repo.InsertServiceMonitorResult(result); err != nil { + t.Fatalf("insert failed result: %v", err) + } + + results, _ := repo.GetServiceMonitorResults(monitor.ID, 100) + found := false + for _, r := range results { + if r.ErrorMessage == "connection refused" { + found = true + break + } + } + if !found { + t.Fatalf("expected to find result with error message") + } + }) +} + +func TestServiceMonitorLatestResultsEndpoint(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + + m1 := &model.ServiceMonitor{ + Name: "m1", + Type: "tcp", + Target: "127.0.0.1:1", + IntervalSec: 60, + TimeoutSec: 1, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := repo.CreateServiceMonitor(m1); err != nil { + t.Fatalf("create monitor 1: %v", err) + } + m2 := &model.ServiceMonitor{ + Name: "m2", + Type: "tcp", + Target: "127.0.0.1:2", + IntervalSec: 60, + TimeoutSec: 1, + NodeID: 0, + Enabled: 1, + CreatedTime: now, + UpdatedTime: now, + } + if err := repo.CreateServiceMonitor(m2); err != nil { + t.Fatalf("create monitor 2: %v", err) + } + + if err := repo.InsertServiceMonitorResult(&model.ServiceMonitorResult{ + MonitorID: m1.ID, + NodeID: 0, + Timestamp: now - 60_000, + Success: 1, + LatencyMs: 10, + }); err != nil { + t.Fatalf("insert m1 old result: %v", err) + } + if err := repo.InsertServiceMonitorResult(&model.ServiceMonitorResult{ + MonitorID: m1.ID, + NodeID: 0, + Timestamp: now, + Success: 0, + LatencyMs: 20, + }); err != nil { + t.Fatalf("insert m1 latest result: %v", err) + } + if err := repo.InsertServiceMonitorResult(&model.ServiceMonitorResult{ + MonitorID: m2.ID, + NodeID: 0, + Timestamp: now - 30_000, + Success: 1, + LatencyMs: 5, + }); err != nil { + t.Fatalf("insert m2 result: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/services/latest-results", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + rows, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(rows) != 2 { + t.Fatalf("expected 2 latest-result rows, got %d", len(rows)) + } + + seen := make(map[int64]int64, len(rows)) + for _, raw := range rows { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + mid, _ := m["monitorId"].(float64) + ts, _ := m["timestamp"].(float64) + if mid <= 0 { + continue + } + seen[int64(mid)] = int64(ts) + } + if len(seen) != 2 { + t.Fatalf("expected 2 unique monitorIds, got %d", len(seen)) + } + if seen[m1.ID] != now { + t.Fatalf("expected m1 latest timestamp %d, got %d", now, seen[m1.ID]) + } + if seen[m2.ID] != now-30_000 { + t.Fatalf("expected m2 latest timestamp %d, got %d", now-30_000, seen[m2.ID]) + } +} + +func TestServiceMonitorLimitsEndpoint(t *testing.T) { + secret := "monitoring-jwt-secret" + router, _ := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/services/limits", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + + limits, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected object, got %T", out.Data) + } + for _, key := range []string{ + "checkerScanIntervalSec", + "minIntervalSec", + "defaultIntervalSec", + "minTimeoutSec", + "defaultTimeoutSec", + "maxTimeoutSec", + } { + if _, ok := limits[key]; !ok { + t.Fatalf("expected %q in limits", key) + } + } +} + +func TestMonitorNodeAndTunnelListEndpoints(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + + n1 := &model.Node{ + Name: "node-a", + Secret: "node-a-secret", + ServerIP: "127.0.0.1", + Port: "10000-10010", + TCPListenAddr: "[::]", + UDPListenAddr: "[::]", + Inx: 2, + CreatedTime: now, + Status: 1, + } + if err := repo.DB().Create(n1).Error; err != nil { + t.Fatalf("seed node 1: %v", err) + } + n2 := &model.Node{ + Name: "node-b", + Secret: "node-b-secret", + ServerIP: "127.0.0.1", + Port: "11000-11010", + TCPListenAddr: "[::]", + UDPListenAddr: "[::]", + Inx: 1, + CreatedTime: now, + Status: 0, + } + if err := repo.DB().Create(n2).Error; err != nil { + t.Fatalf("seed node 2: %v", err) + } + + t1 := &model.Tunnel{ + Name: "tunnel-a", + TrafficRatio: 1.0, + Type: 1, + Protocol: "tls", + Flow: 1, + CreatedTime: now, + UpdatedTime: now, + Status: 1, + Inx: 2, + } + if err := repo.DB().Create(t1).Error; err != nil { + t.Fatalf("seed tunnel 1: %v", err) + } + t2 := &model.Tunnel{ + Name: "tunnel-b", + TrafficRatio: 1.0, + Type: 1, + Protocol: "tls", + Flow: 1, + CreatedTime: now, + UpdatedTime: now, + Status: 0, + Inx: 1, + } + if err := repo.DB().Create(t2).Error; err != nil { + t.Fatalf("seed tunnel 2: %v", err) + } + + // Nodes + { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/nodes", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + rows, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(rows) != 2 { + t.Fatalf("expected 2 nodes, got %d", len(rows)) + } + first, _ := rows[0].(map[string]interface{}) + if name, _ := first["name"].(string); name != "node-b" { + t.Fatalf("expected node order by inx (node-b first), got %q", name) + } + } + + // Tunnels + { + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/tunnels", nil) + req.Header.Set("Authorization", adminToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + rows, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array, got %T", out.Data) + } + if len(rows) != 2 { + t.Fatalf("expected 2 tunnels, got %d", len(rows)) + } + first, _ := rows[0].(map[string]interface{}) + if name, _ := first["name"].(string); name != "tunnel-b" { + t.Fatalf("expected tunnel order by inx (tunnel-b first), got %q", name) + } + } +} + +func TestMonitorPermissionAdminEndpoints(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + + now := time.Now().UnixMilli() + u := &model.User{ + User: "test-user", + Pwd: "pwd", + RoleID: 1, + ExpTime: now + 3600_000, + Flow: 0, + FlowResetTime: now, + Num: 0, + CreatedTime: now, + Status: 1, + } + if err := repo.DB().Create(u).Error; err != nil { + t.Fatalf("seed user: %v", err) + } + + assignBody, _ := json.Marshal(map[string]interface{}{"userId": u.ID}) + req := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/permission/assign", bytes.NewReader(assignBody)) + req.Header.Set("Authorization", adminToken) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode assign response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 on assign, got %d: %s", out.Code, out.Msg) + } + + listReq := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/permission/list", nil) + listReq.Header.Set("Authorization", adminToken) + listRes := httptest.NewRecorder() + router.ServeHTTP(listRes, listReq) + + if err := json.NewDecoder(listRes.Body).Decode(&out); err != nil { + t.Fatalf("decode list response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 on list, got %d: %s", out.Code, out.Msg) + } + rows, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array on list, got %T", out.Data) + } + found := false + for _, raw := range rows { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + uid, _ := m["userId"].(float64) + if int64(uid) == u.ID { + found = true + break + } + } + if !found { + t.Fatalf("expected assigned permission to appear in list") + } + + removeBody, _ := json.Marshal(map[string]interface{}{"userId": u.ID}) + remReq := httptest.NewRequest(http.MethodPost, "/api/v1/monitor/permission/remove", bytes.NewReader(removeBody)) + remReq.Header.Set("Authorization", adminToken) + remReq.Header.Set("Content-Type", "application/json") + remRes := httptest.NewRecorder() + router.ServeHTTP(remRes, remReq) + + if err := json.NewDecoder(remRes.Body).Decode(&out); err != nil { + t.Fatalf("decode remove response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 on remove, got %d: %s", out.Code, out.Msg) + } + + listReq2 := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/permission/list", nil) + listReq2.Header.Set("Authorization", adminToken) + listRes2 := httptest.NewRecorder() + router.ServeHTTP(listRes2, listReq2) + + if err := json.NewDecoder(listRes2.Body).Decode(&out); err != nil { + t.Fatalf("decode list response after remove: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 on list after remove, got %d: %s", out.Code, out.Msg) + } + rows2, ok := out.Data.([]interface{}) + if !ok { + t.Fatalf("expected array on list after remove, got %T", out.Data) + } + for _, raw := range rows2 { + m, ok := raw.(map[string]interface{}) + if !ok { + continue + } + uid, _ := m["userId"].(float64) + if int64(uid) == u.ID { + t.Fatalf("expected removed permission to be absent") + } + } +} + +func TestMetricBatchInsert(t *testing.T) { + secret := "monitoring-jwt-secret" + _, repo := setupContractRouter(t, secret) + + t.Run("batch insert node metrics", func(t *testing.T) { + now := time.Now().UnixMilli() + var metrics []*model.NodeMetric + for i := 0; i < 10; i++ { + metrics = append(metrics, &model.NodeMetric{ + NodeID: 1, + Timestamp: now - int64(i*1000), + CPUUsage: float64(40 + i), + MemUsage: float64(50 + i), + DiskUsage: 30.0, + NetInBytes: int64(1000 * (i + 1)), + NetOutBytes: int64(2000 * (i + 1)), + NetInSpeed: 100, + NetOutSpeed: 200, + Load1: 1.0, + Load5: 0.8, + Load15: 0.6, + TCPConns: 100, + UDPConns: 50, + }) + } + + if err := repo.InsertNodeMetricBatch(metrics); err != nil { + t.Fatalf("batch insert: %v", err) + } + + retrieved, err := repo.GetNodeMetrics(1, 0, now+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(retrieved) != 10 { + t.Fatalf("expected 10 metrics, got %d", len(retrieved)) + } + }) + + t.Run("batch insert tunnel metrics", func(t *testing.T) { + now := time.Now().UnixMilli() + var metrics []*model.TunnelMetric + for i := 0; i < 5; i++ { + metrics = append(metrics, &model.TunnelMetric{ + TunnelID: 1, + NodeID: 1, + Timestamp: now - int64(i*1000), + BytesIn: int64(1000 * (i + 1)), + BytesOut: int64(2000 * (i + 1)), + Connections: int64(i + 1), + Errors: 0, + AvgLatencyMs: float64(10 + i), + }) + } + + if err := repo.InsertTunnelMetricBatch(metrics); err != nil { + t.Fatalf("batch insert: %v", err) + } + + retrieved, err := repo.GetTunnelMetrics(1, 0, now+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(retrieved) != 5 { + t.Fatalf("expected 5 metrics, got %d", len(retrieved)) + } + }) +} + +func TestMetricPruning(t *testing.T) { + secret := "monitoring-jwt-secret" + _, repo := setupContractRouter(t, secret) + + now := time.Now().UnixMilli() + + oldMetric := &model.NodeMetric{ + NodeID: 1, + Timestamp: now - 8*24*60*60*1000, + CPUUsage: 50, + MemUsage: 60, + DiskUsage: 30, + Load1: 1.0, + Load5: 0.8, + Load15: 0.6, + TCPConns: 100, + UDPConns: 50, + } + if err := repo.InsertNodeMetric(oldMetric); err != nil { + t.Fatalf("insert old metric: %v", err) + } + + newMetric := &model.NodeMetric{ + NodeID: 1, + Timestamp: now, + CPUUsage: 55, + MemUsage: 65, + DiskUsage: 32, + Load1: 1.2, + Load5: 0.9, + Load15: 0.7, + TCPConns: 120, + UDPConns: 60, + } + if err := repo.InsertNodeMetric(newMetric); err != nil { + t.Fatalf("insert new metric: %v", err) + } + + cutoff := now - 7*24*60*60*1000 + if err := repo.PruneNodeMetrics(cutoff); err != nil { + t.Fatalf("prune metrics: %v", err) + } + + retrieved, err := repo.GetNodeMetrics(1, 0, now+1000) + if err != nil { + t.Fatalf("get metrics: %v", err) + } + if len(retrieved) != 1 { + t.Fatalf("expected 1 metric after pruning, got %d", len(retrieved)) + } +} + +func TestMonitoringAuthRequired(t *testing.T) { + secret := "monitoring-jwt-secret" + router, _ := setupContractRouter(t, secret) + + tests := []struct { + name string + method string + path string + }{ + {"node metrics", http.MethodGet, "/api/v1/monitor/nodes/1/metrics"}, + {"node metrics latest", http.MethodGet, "/api/v1/monitor/nodes/1/metrics/latest"}, + {"tunnel metrics", http.MethodGet, "/api/v1/monitor/tunnels/1/metrics"}, + {"service list", http.MethodGet, "/api/v1/monitor/services"}, + {"service results", http.MethodGet, "/api/v1/monitor/services/1/results"}, + } + + for _, tc := range tests { + t.Run(tc.name+" requires auth", func(t *testing.T) { + req := httptest.NewRequest(tc.method, tc.path, nil) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 401 { + t.Fatalf("expected 401 for missing auth, got %d", out.Code) + } + }) + } +} + +func TestMonitorAccessEndpoint(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret) + if err != nil { + t.Fatalf("generate admin token: %v", err) + } + userToken, err := auth.GenerateToken(2, "normal_user", 1, secret) + if err != nil { + t.Fatalf("generate user token: %v", err) + } + + assertAllowed := func(t *testing.T, token string, want bool) { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/v1/monitor/access", nil) + req.Header.Set("Authorization", token) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0, got %d: %s", out.Code, out.Msg) + } + data, ok := out.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected object, got %T", out.Data) + } + allowed, _ := data["allowed"].(bool) + if allowed != want { + t.Fatalf("expected allowed=%v, got %v", want, allowed) + } + } + + t.Run("admin is allowed", func(t *testing.T) { + assertAllowed(t, adminToken, true) + }) + + t.Run("non-admin without grant is denied", func(t *testing.T) { + assertAllowed(t, userToken, false) + }) + + now := time.Now().UnixMilli() + if err := repo.InsertMonitorPermission(2, now); err != nil { + t.Fatalf("insert monitor permission: %v", err) + } + + t.Run("non-admin with grant is allowed", func(t *testing.T) { + assertAllowed(t, userToken, true) + }) +} + +func TestMonitoringPermissionRequired(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + userToken, err := auth.GenerateToken(2, "normal_user", 1, secret) + if err != nil { + t.Fatalf("generate user token: %v", err) + } + + createBody, _ := json.Marshal(map[string]interface{}{ + "name": "NonAdmin Monitor", + "type": "tcp", + "target": "127.0.0.1:1", + "intervalSec": 60, + "timeoutSec": 5, + "nodeId": 0, + "enabled": 1, + }) + + forbidden := []struct { + name string + method string + path string + body []byte + }{ + {"service list", http.MethodGet, "/api/v1/monitor/services", nil}, + {"service create", http.MethodPost, "/api/v1/monitor/services/create", createBody}, + {"node metrics", http.MethodGet, "/api/v1/monitor/nodes/1/metrics", nil}, + } + + for _, tc := range forbidden { + t.Run(tc.name+" forbidden without grant", func(t *testing.T) { + var req *http.Request + if tc.body != nil { + req = httptest.NewRequest(tc.method, tc.path, bytes.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(tc.method, tc.path, nil) + } + req.Header.Set("Authorization", userToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 403 { + t.Fatalf("expected 403 without grant, got %d msg=%q", out.Code, out.Msg) + } + }) + } + + // Grant monitor permission and verify access. + now := time.Now().UnixMilli() + if err := repo.InsertMonitorPermission(2, now); err != nil { + t.Fatalf("insert monitor permission: %v", err) + } + + allowed := []struct { + name string + method string + path string + body []byte + }{ + {"service list", http.MethodGet, "/api/v1/monitor/services", nil}, + {"service create", http.MethodPost, "/api/v1/monitor/services/create", createBody}, + } + + for _, tc := range allowed { + t.Run(tc.name+" allowed with grant", func(t *testing.T) { + var req *http.Request + if tc.body != nil { + req = httptest.NewRequest(tc.method, tc.path, bytes.NewReader(tc.body)) + req.Header.Set("Content-Type", "application/json") + } else { + req = httptest.NewRequest(tc.method, tc.path, nil) + } + req.Header.Set("Authorization", userToken) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + var out response.R + if err := json.NewDecoder(res.Body).Decode(&out); err != nil { + t.Fatalf("decode response: %v", err) + } + if out.Code != 0 { + t.Fatalf("expected code 0 with grant, got %d msg=%q", out.Code, out.Msg) + } + }) + } +} diff --git a/go-backend/tests/contract/tunnel_metrics_ingestion_contract_test.go b/go-backend/tests/contract/tunnel_metrics_ingestion_contract_test.go new file mode 100644 index 0000000..b53136f --- /dev/null +++ b/go-backend/tests/contract/tunnel_metrics_ingestion_contract_test.go @@ -0,0 +1,97 @@ +package contract_test + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "go-backend/internal/store/model" +) + +func TestFlowUploadInsertsTunnelMetrics(t *testing.T) { + secret := "monitoring-jwt-secret" + router, repo := setupContractRouter(t, secret) + + now := time.Now().UnixMilli() + + node := &model.Node{ + Name: "node-1", + Secret: "node-secret", + ServerIP: "127.0.0.1", + Port: "10000-10010", + TCPListenAddr: "[::]", + UDPListenAddr: "[::]", + CreatedTime: now, + Status: 1, + } + if err := repo.DB().Create(node).Error; err != nil { + t.Fatalf("seed node: %v", err) + } + + tunnel := &model.Tunnel{ + Name: "tunnel-1", + TrafficRatio: 1.0, + Type: 1, + Protocol: "tls", + Flow: 1, + CreatedTime: now, + UpdatedTime: now, + Status: 1, + } + if err := repo.DB().Create(tunnel).Error; err != nil { + t.Fatalf("seed tunnel: %v", err) + } + + forward := &model.Forward{ + UserID: 123, + UserName: "user-123", + Name: "forward-1", + TunnelID: tunnel.ID, + RemoteAddr: "1.1.1.1:80", + CreatedTime: now, + UpdatedTime: now, + Status: 1, + } + if err := repo.DB().Create(forward).Error; err != nil { + t.Fatalf("seed forward: %v", err) + } + + serviceName := jsonNumber(forward.ID) + "_123_0" + body, _ := json.Marshal([]map[string]interface{}{{ + "n": serviceName, + "u": 200, + "d": 100, + }}) + + req := httptest.NewRequest(http.MethodPost, "/flow/upload?secret="+node.Secret, bytes.NewReader(body)) + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + + if res.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", res.Code) + } + + metrics, err := repo.GetTunnelMetrics(tunnel.ID, 0, now+60_000) + if err != nil { + t.Fatalf("get tunnel metrics: %v", err) + } + if len(metrics) != 1 { + t.Fatalf("expected 1 tunnel metric row, got %d", len(metrics)) + } + + if metrics[0].TunnelID != tunnel.ID { + t.Fatalf("expected tunnelId %d, got %d", tunnel.ID, metrics[0].TunnelID) + } + if metrics[0].NodeID != node.ID { + t.Fatalf("expected nodeId %d, got %d", node.ID, metrics[0].NodeID) + } + if metrics[0].BytesIn != 100 { + t.Fatalf("expected bytesIn 100, got %d", metrics[0].BytesIn) + } + if metrics[0].BytesOut != 200 { + t.Fatalf("expected bytesOut 200, got %d", metrics[0].BytesOut) + } +} diff --git a/go-gost/x/service/service.go b/go-gost/x/service/service.go index 1819c64..94d02e5 100644 --- a/go-gost/x/service/service.go +++ b/go-gost/x/service/service.go @@ -7,7 +7,6 @@ import ( "errors" "fmt" "io" - "log" "net" "os" "os/exec" @@ -63,11 +62,9 @@ func SetProtocolBlock(httpOn int, tlsOn int, socksOn int) { type Option func(opts *options) func init() { - _, err := LoadConfig("config.json") - fmt.Println("config.json loaded") - if err != nil { - log.Fatal(err) - } + // NOTE: This package can be imported by tests/tools that don't have a local + // config.json. Missing config should not crash the process. + _, _ = LoadConfig("config.json") needWrap = isTls+isSocks+isHttp > 0 } diff --git a/go-gost/x/socket/websocket_reporter.go b/go-gost/x/socket/websocket_reporter.go index f53ccee..b2758f0 100644 --- a/go-gost/x/socket/websocket_reporter.go +++ b/go-gost/x/socket/websocket_reporter.go @@ -17,7 +17,7 @@ import ( "runtime" "strconv" "strings" - "sync" // 新增:用于管理连接状态的互斥锁 + "sync" "time" "github.com/go-gost/x/config" @@ -25,34 +25,67 @@ import ( "github.com/go-gost/x/service" "github.com/gorilla/websocket" "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/load" "github.com/shirou/gopsutil/v3/mem" psnet "github.com/shirou/gopsutil/v3/net" + "golang.org/x/net/icmp" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" ) // SystemInfo 系统信息结构体 type SystemInfo struct { - Uptime uint64 `json:"uptime"` // 开机时间 (秒) - BytesReceived uint64 `json:"bytes_received"` // 接收字节数 - BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数 - CPUUsage float64 `json:"cpu_usage"` // CPU使用率(百分比) - MemoryUsage float64 `json:"memory_usage"` // 内存使用率(百分比) + Uptime uint64 `json:"uptime"` + BytesReceived uint64 `json:"bytes_received"` + BytesTransmitted uint64 `json:"bytes_transmitted"` + CPUUsage float64 `json:"cpu_usage"` + MemoryUsage float64 `json:"memory_usage"` + DiskUsage float64 `json:"disk_usage"` + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` + TCPConns int64 `json:"tcp_conns"` + UDPConns int64 `json:"udp_conns"` + NetInSpeed int64 `json:"net_in_speed"` + NetOutSpeed int64 `json:"net_out_speed"` } // NetworkStats 网络统计信息 type NetworkStats struct { - BytesReceived uint64 `json:"bytes_received"` // 接收字节数 - BytesTransmitted uint64 `json:"bytes_transmitted"` // 发送字节数 + BytesReceived uint64 `json:"bytes_received"` + BytesTransmitted uint64 `json:"bytes_transmitted"` + BytesRecvDelta uint64 `json:"bytes_recv_delta"` + BytesSentDelta uint64 `json:"bytes_sent_delta"` } // CPUInfo CPU信息 type CPUInfo struct { - Usage float64 `json:"usage"` // CPU使用率(百分比) + Usage float64 `json:"usage"` } // MemoryInfo 内存信息 type MemoryInfo struct { - Usage float64 `json:"usage"` // 内存使用率(百分比) + Usage float64 `json:"usage"` +} + +// DiskInfo 磁盘信息 +type DiskInfo struct { + Usage float64 `json:"usage"` +} + +// LoadInfo 负载信息 +type LoadInfo struct { + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` +} + +// ConnectionInfo 连接信息 +type ConnectionInfo struct { + TCPConns int64 `json:"tcp_conns"` + UDPConns int64 `json:"udp_conns"` } // CommandMessage 命令消息结构体 @@ -91,6 +124,25 @@ type TcpPingResponse struct { RequestId string `json:"requestId,omitempty"` } +// ServiceMonitorCheckRequest service monitor check request. +type ServiceMonitorCheckRequest struct { + MonitorID int64 `json:"monitorId"` + Type string `json:"type"` // tcp|icmp + Target string `json:"target"` + TimeoutSec int `json:"timeoutSec"` +} + +// ServiceMonitorCheckResult node-executed check output. +// CommandResponse.Success indicates command execution status. +// Actual check success is represented by this struct. +type ServiceMonitorCheckResult struct { + MonitorID int64 `json:"monitorId"` + Success bool `json:"success"` + LatencyMs float64 `json:"latencyMs"` + StatusCode int `json:"statusCode,omitempty"` + ErrorMessage string `json:"errorMessage,omitempty"` +} + const ( reporterReadWait = 60 * time.Second reporterWriteWait = 5 * time.Second @@ -134,7 +186,7 @@ func NewWebSocketReporter(serverURL string, secret string) *WebSocketReporter { return &WebSocketReporter{ url: serverURL, reconnectTime: 5 * time.Second, // 重连间隔 - pingInterval: 2 * time.Second, // 发送间隔改为2秒 + pingInterval: 5 * time.Second, // 指标上报间隔 configInterval: 10 * time.Minute, // 配置上报间隔 ctx: ctx, cancel: cancel, @@ -449,11 +501,35 @@ func (w *WebSocketReporter) handleConnection() { } } +var lastNetBytesReceived uint64 +var lastNetBytesTransmitted uint64 +var lastNetTime int64 + +var connInfoCached ConnectionInfo +var connInfoCachedAt int64 +var connInfoCachedMu sync.Mutex + // collectSystemInfo 收集系统信息 func (w *WebSocketReporter) collectSystemInfo() SystemInfo { networkStats := getNetworkStats() cpuInfo := getCPUInfo() memoryInfo := getMemoryInfo() + diskInfo := getDiskInfo() + loadInfo := getLoadInfo() + connInfo := getConnectionInfo() + + now := time.Now().UnixMilli() + var netInSpeed, netOutSpeed int64 + if lastNetTime > 0 { + deltaMs := now - lastNetTime + if deltaMs > 0 { + netInSpeed = int64(float64(networkStats.BytesRecvDelta) * 1000 / float64(deltaMs)) + netOutSpeed = int64(float64(networkStats.BytesSentDelta) * 1000 / float64(deltaMs)) + } + } + lastNetBytesReceived = networkStats.BytesReceived + lastNetBytesTransmitted = networkStats.BytesTransmitted + lastNetTime = now return SystemInfo{ Uptime: getUptime(), @@ -461,6 +537,14 @@ func (w *WebSocketReporter) collectSystemInfo() SystemInfo { BytesTransmitted: networkStats.BytesTransmitted, CPUUsage: cpuInfo.Usage, MemoryUsage: memoryInfo.Usage, + DiskUsage: diskInfo.Usage, + Load1: loadInfo.Load1, + Load5: loadInfo.Load5, + Load15: loadInfo.Load15, + TCPConns: connInfo.TCPConns, + UDPConns: connInfo.UDPConns, + NetInSpeed: netInSpeed, + NetOutSpeed: netOutSpeed, } } @@ -622,7 +706,7 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt if cmdMsg.Type != "call" { // 其他状态变更命令保持同步,确保顺序执行 - if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { + if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { go w.routeCommand(cmdMsg) } else { w.routeCommand(cmdMsg) @@ -638,7 +722,7 @@ func (w *WebSocketReporter) handleReceivedMessage(messageType int, message []byt } if cmdMsg.Type != "call" { // 其他状态变更命令保持同步,确保顺序执行 - if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { + if cmdMsg.Type == "TcpPing" || cmdMsg.Type == "ServiceMonitorCheck" || cmdMsg.Type == "UpgradeAgent" || cmdMsg.Type == "RollbackAgent" { go w.routeCommand(cmdMsg) } else { w.routeCommand(cmdMsg) @@ -726,6 +810,13 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) { response.Data = tcpPingResult // needSaveConfig = false (默认值) + // Service monitor check (read-only) + case "ServiceMonitorCheck": + var checkResult ServiceMonitorCheckResult + checkResult, err = w.handleServiceMonitorCheck(cmd.Data) + response.Type = "ServiceMonitorCheckResponse" + response.Data = checkResult + // Protocol blocking switches case "SetProtocol": err = w.handleSetProtocol(cmd.Data) @@ -1381,17 +1472,21 @@ func getNetworkStats() NetworkStats { return stats } - // 汇总所有非回环接口的流量 for _, io := range ioCounters { - // 跳过回环接口 if io.Name == "lo" || strings.HasPrefix(io.Name, "lo") { continue } - stats.BytesReceived += io.BytesRecv stats.BytesTransmitted += io.BytesSent } + if lastNetBytesReceived > 0 && stats.BytesReceived >= lastNetBytesReceived { + stats.BytesRecvDelta = stats.BytesReceived - lastNetBytesReceived + } + if lastNetBytesTransmitted > 0 && stats.BytesTransmitted >= lastNetBytesTransmitted { + stats.BytesSentDelta = stats.BytesTransmitted - lastNetBytesTransmitted + } + return stats } @@ -1399,8 +1494,8 @@ func getNetworkStats() NetworkStats { func getCPUInfo() CPUInfo { var cpuInfo CPUInfo - // 获取CPU使用率 - percentages, err := cpu.Percent(time.Second, false) + // 获取CPU使用率 (non-blocking) + percentages, err := cpu.Percent(0, false) if err == nil && len(percentages) > 0 { cpuInfo.Usage = percentages[0] } @@ -1422,6 +1517,69 @@ func getMemoryInfo() MemoryInfo { return memInfo } +// getDiskInfo 获取磁盘信息 +func getDiskInfo() DiskInfo { + var diskInfo DiskInfo + + usage, err := disk.Usage("/") + if err != nil { + return diskInfo + } + + diskInfo.Usage = usage.UsedPercent + + return diskInfo +} + +// getLoadInfo 获取负载信息 +func getLoadInfo() LoadInfo { + var loadInfo LoadInfo + + avg, err := load.Avg() + if err != nil { + return loadInfo + } + + loadInfo.Load1 = avg.Load1 + loadInfo.Load5 = avg.Load5 + loadInfo.Load15 = avg.Load15 + + return loadInfo +} + +// getConnectionInfo 获取连接信息 +func getConnectionInfo() ConnectionInfo { + now := time.Now().UnixMilli() + const refreshEveryMs = int64((15 * time.Second) / time.Millisecond) + + connInfoCachedMu.Lock() + if connInfoCachedAt > 0 && now-connInfoCachedAt < refreshEveryMs { + v := connInfoCached + connInfoCachedMu.Unlock() + return v + } + connInfoCachedMu.Unlock() + + var connInfo ConnectionInfo + + connStats, err := psnet.Connections("tcp") + if err == nil { + connInfo.TCPConns = int64(len(connStats)) + } + + udpStats, err := psnet.Connections("udp") + if err == nil { + connInfo.UDPConns = int64(len(udpStats)) + } + + connInfoCachedMu.Lock() + connInfoCached = connInfo + connInfoCachedAt = now + connInfoCachedMu.Unlock() + + return connInfo +} + // StartWebSocketReporterWithConfig 使用配置字段启动WebSocket报告器 func StartWebSocketReporterWithConfig(addr string, secret string, http int, tls int, socks int, version string) *WebSocketReporter { @@ -1503,6 +1661,210 @@ func (w *WebSocketReporter) handleTcpPing(data interface{}) (TcpPingResponse, er return response, nil } +// handleServiceMonitorCheck executes a service monitor check on this node. +// It always returns a result (command execution is considered successful even if the check fails). +func (w *WebSocketReporter) handleServiceMonitorCheck(data interface{}) (ServiceMonitorCheckResult, error) { + jsonData, err := json.Marshal(data) + if err != nil { + return ServiceMonitorCheckResult{}, fmt.Errorf("序列化检查数据失败: %v", err) + } + + var req ServiceMonitorCheckRequest + if err := json.Unmarshal(jsonData, &req); err != nil { + return ServiceMonitorCheckResult{}, fmt.Errorf("解析检查请求失败: %v", err) + } + + checkType := strings.ToLower(strings.TrimSpace(req.Type)) + target := strings.TrimSpace(req.Target) + res := ServiceMonitorCheckResult{MonitorID: req.MonitorID} + + if checkType != "tcp" && checkType != "icmp" { + res.Success = false + res.ErrorMessage = "不支持的检查类型" + return res, nil + } + if target == "" { + res.Success = false + res.ErrorMessage = "检查目标为空" + return res, nil + } + + timeoutSec := req.TimeoutSec + if timeoutSec <= 0 { + timeoutSec = 5 + } + timeout := time.Duration(timeoutSec) * time.Second + + start := time.Now() + + switch checkType { + case "tcp": + // Validate and normalize host:port. + _, _, splitErr := net.SplitHostPort(target) + if splitErr != nil { + res.Success = false + res.ErrorMessage = "无效的TCP目标" + res.LatencyMs = float64(time.Since(start).Milliseconds()) + return res, nil + } + conn, dialErr := net.DialTimeout("tcp", target, timeout) + res.LatencyMs = float64(time.Since(start).Milliseconds()) + if dialErr != nil { + res.Success = false + res.ErrorMessage = dialErr.Error() + return res, nil + } + _ = conn.Close() + res.Success = true + return res, nil + + case "icmp": + rtt, pingErr := icmpPing(target, timeout) + res.LatencyMs = float64(rtt.Milliseconds()) + if pingErr != nil { + res.Success = false + res.ErrorMessage = pingErr.Error() + return res, nil + } + res.Success = true + return res, nil + } + + res.Success = false + res.ErrorMessage = "未知错误" + res.LatencyMs = float64(time.Since(start).Milliseconds()) + return res, nil +} + +func icmpPing(target string, timeout time.Duration) (time.Duration, error) { + start := time.Now() + + target = strings.TrimSpace(target) + if target == "" { + return time.Since(start), fmt.Errorf("无效的ICMP目标") + } + // Avoid accepting URL-like targets. + if strings.Contains(target, "://") { + return time.Since(start), fmt.Errorf("无效的ICMP目标") + } + if strings.HasPrefix(target, "[") && strings.HasSuffix(target, "]") { + target = strings.TrimSuffix(strings.TrimPrefix(target, "["), "]") + } + + ipAddr, err := net.ResolveIPAddr("ip", target) + if err != nil || ipAddr == nil || ipAddr.IP == nil { + if err == nil { + err = fmt.Errorf("unknown address") + } + return time.Since(start), fmt.Errorf("解析目标失败: %v", err) + } + + isV4 := ipAddr.IP.To4() != nil + listenAddr := "0.0.0.0" + proto := 1 + var echoType icmp.Type = ipv4.ICMPTypeEcho + var echoReplyType icmp.Type = ipv4.ICMPTypeEchoReply + networks := []string{"udp4", "ip4:icmp"} + if !isV4 { + listenAddr = "::" + proto = 58 + echoType = ipv6.ICMPTypeEchoRequest + echoReplyType = ipv6.ICMPTypeEchoReply + networks = []string{"udp6", "ip6:ipv6-icmp"} + } + + var conn *icmp.PacketConn + selectedNetwork := "" + var lastErr error + for _, nw := range networks { + c, err := icmp.ListenPacket(nw, listenAddr) + if err == nil { + conn = c + selectedNetwork = nw + break + } + lastErr = err + } + if conn == nil { + if lastErr != nil { + return time.Since(start), fmt.Errorf("创建ICMP连接失败: %v", lastErr) + } + return time.Since(start), fmt.Errorf("创建ICMP连接失败") + } + defer conn.Close() + + id := os.Getpid() & 0xffff + seq := 1 + + wm := icmp.Message{ + Type: echoType, + Code: 0, + Body: &icmp.Echo{ + ID: id, + Seq: seq, + Data: []byte("FLVX-PING"), + }, + } + wb, err := wm.Marshal(nil) + if err != nil { + return time.Since(start), err + } + + _ = conn.SetDeadline(time.Now().Add(timeout)) + + var dst net.Addr + if strings.HasPrefix(selectedNetwork, "udp") { + dst = &net.UDPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone} + } else { + dst = &net.IPAddr{IP: ipAddr.IP, Zone: ipAddr.Zone} + } + + if _, err := conn.WriteTo(wb, dst); err != nil { + return time.Since(start), err + } + + addrIP := func(a net.Addr) net.IP { + switch v := a.(type) { + case *net.IPAddr: + return v.IP + case *net.UDPAddr: + return v.IP + default: + return nil + } + } + + rb := make([]byte, 1500) + for { + n, peer, err := conn.ReadFrom(rb) + if err != nil { + return time.Since(start), err + } + if p := addrIP(peer); p != nil && !p.Equal(ipAddr.IP) { + continue + } + rm, err := icmp.ParseMessage(proto, rb[:n]) + if err != nil { + continue + } + if rm.Type != echoReplyType { + continue + } + echo, ok := rm.Body.(*icmp.Echo) + if !ok { + continue + } + if echo.Seq != seq { + continue + } + // For non-privileged endpoints, the kernel may choose the ID. + if !strings.HasPrefix(selectedNetwork, "udp") && echo.ID != id { + continue + } + return time.Since(start), nil + } +} + // tcpPingHost 执行TCP连接测试,返回平均连接时间和失败率 func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float64, error) { var totalTime float64 diff --git a/plans/037-monitoring-metrics-health.md b/plans/037-monitoring-metrics-health.md new file mode 100644 index 0000000..68e1f69 --- /dev/null +++ b/plans/037-monitoring-metrics-health.md @@ -0,0 +1,104 @@ +# 037 - Monitoring: Node Metrics + Service Health Checks + +## Context +This worktree introduces a monitoring feature set: +- Node runtime metrics streamed via WebSocket (agent -> panel -> admin clients) +- Metrics ingestion + retention in panel DB +- Service monitoring (TCP/ICMP checks only) + result storage +- Frontend monitor view (charts + monitor CRUD + run + results) +- Dedicated monitor page (`/monitor`) that works for authorized non-admin users + +The initial implementation landed without a plan doc and had several correctness issues (API JSON shape mismatch, wrong time units, contract test hangs under SQLite single-connection mode, etc.). This plan documents what exists, what was fixed, and what is still incomplete/needs decisions. + +## Goals +- Metrics endpoints return stable JSON fields matching frontend types. +- Contract tests cover metrics + monitor CRUD and are deterministic. +- WebSocket metric messages update node cards correctly. +- Monitoring view queries the correct time range and renders timestamps correctly. +- go-gost/x unit tests do not depend on a local config.json. + +## Non-goals (for this plan) +- A full monitor scheduling system (jitter/backoff/concurrency budgets/per-monitor next-run) beyond the current simple loop. +- Building a full alerting pipeline (notifications, thresholds, paging). + +## Current Status (as of this worktree) +- Backend models updated with JSON tags for monitoring structs. +- Handler endpoints for metrics + service monitors added. +- Metrics ingestion service implemented with buffering + retention pruning. +- Health checker implemented (panel-side when `nodeId == 0`; node-executed via WS when `nodeId > 0`) and background jobs wired. +- Frontend monitor view added; build passes. +- Contract tests for monitoring added. +- Monitoring endpoints are accessible by admin users and non-admin users explicitly authorized by admin (via `monitor_permission`). +- Frontend exposes monitoring via a dedicated `/monitor` page; admin can grant/revoke monitoring permission from the User permissions modal. +- Frontend includes tunnel metrics charts (backed by `/api/v1/monitor/tunnels` list + `/api/v1/monitor/tunnels/:id/metrics`). + +## Known Semantics Gaps (need decisions) +- `service_monitor.intervalSec` is best-effort (checker ticks every 30s; intervals shorter than that won't run faster). +- `service_monitor_result.success` is stored as int (0/1). Frontend currently treats it as number; decide if API should expose boolean. + +## Admin Authorization API +Monitoring permission management (admin-only): +- `GET /api/v1/monitor/permission/list` +- `POST /api/v1/monitor/permission/assign` body: `{ "userId": 123 }` +- `POST /api/v1/monitor/permission/remove` body: `{ "userId": 123 }` + +## Checklist + +### Phase 1: Correctness + Contracts +- [x] Align monitoring JSON response fields with frontend/contract expectations (add json tags or DTO mapping). +- [x] Fix frontend monitor time range query (use ms start/end; avoid `start=60`). +- [x] Fix frontend timestamp rendering (treat timestamp as UnixMilli). +- [x] Fix node realtime metric speed field compatibility (support snake_case speed fields). +- [x] Fix SQLite contract hang by ensuring tunnel-entry precheck uses tx-safe DB reads (no nested connection acquisition). +- [x] Ensure monitoring contract tests pass. + +### Phase 2: Semantics Alignment (Decide + Implement) +- [x] Decide "service monitors run where": + - Option B: node-executed when `nodeId > 0` (chosen) +- [ ] Define interval semantics: + - Per-monitor next-run scheduling vs global scan loop + - Backoff on failures + - Maximum monitors + runtime cost guardrails +- [ ] Standardize API type for `success`: + - Keep int for backward compatibility, or + - Return boolean in API responses (DTO) while storing int in DB + +### Phase 2.1: Partial Implementation (No Semantics Decision Yet) +- [x] Honor `intervalSec` best-effort in panel-side checker (min cadence still bound by global loop). + +### Phase 2.2: Node-Executed Checks +- [x] Add a WebSocket command for node-executed monitor checks (`ServiceMonitorCheck`). +- [x] Panel health checker dispatches checks to the specified node when `nodeId > 0`. +- [x] Allow unrestricted targets by policy; restrict monitoring endpoints to admin + explicitly authorized users. +- [x] Remove HTTP checks; service monitoring supports only `tcp` and `icmp`. + +### Phase 3: Hardening + Performance +- [x] Add query limits/guards for metrics endpoints (max range, max rows) to avoid accidental full-history pulls. +- [ ] Consider indexing review and retention configurability (env or config table). +- [ ] Review concurrency: ingestion buffer flush goroutine spawning and DB write pressure. +- [x] Add minimal UI affordances: time range selector, empty/error states, and service monitor run/results UI. +- [x] Ensure monitoring UI works for authorized non-admin users (dedicated `/monitor` page; no reliance on admin-only `/node/*`). + +### Phase 4: Hygiene +- [x] Add `.entire/metadata/` to `.gitignore` (should never be committed). +- [ ] Add a short developer note in docs/README if needed (API endpoints + semantics). + +## Test Plan +Backend: +```bash +cd go-backend && go test ./... -count=1 +cd go-backend && go test ./tests/contract -count=1 -timeout 120s +``` + +Agent fork: +```bash +cd go-gost/x && go test ./... -count=1 +``` + +Frontend: +```bash +cd vite-frontend && npm run build +``` + +## Notes +- Node-executed checks can be used for internal probing by design; access is restricted to administrators. diff --git a/plans/038-monitoring-bugfixes-and-optimizations.md b/plans/038-monitoring-bugfixes-and-optimizations.md new file mode 100644 index 0000000..f5acf3e --- /dev/null +++ b/plans/038-monitoring-bugfixes-and-optimizations.md @@ -0,0 +1,59 @@ +# 038 - Monitoring Bug Fixes + Optimizations + +## Context +Monitoring in FLVX currently spans: +- Agent -> panel WebSocket realtime system metrics (CPU/mem/disk/net/load/conns) +- Panel-side ingestion + retention pruning (`node_metric`) +- Service monitors (TCP/ICMP) with scheduled checks + stored results +- Frontend monitor page (`/monitor`) with charts + monitor CRUD/run/results + +While the feature set works end-to-end, there are a few correctness footguns and a couple of obvious performance hot spots (agent-side sampling cost and frontend N+1 polling patterns). + +## Goals +- Service monitor updates do not accidentally clear `nodeId` / `enabled` when fields are omitted. +- Checker cadence is explicit (intervals below the scan cadence are clamped / best-effort). +- Reduce frontend requests for service monitor status (avoid per-monitor polling). +- Reduce agent sampling overhead and DB write volume without breaking UI expectations. +- Avoid misclassifying arbitrary JSON as a metric message on the WS channel. + +## Non-goals +- A full scheduler (per-monitor next-run queue, jitter/backoff, concurrency budgets). +- Alerting/notifications. +- Implementing full tunnel-metrics ingestion (connections/errors/latency) beyond current endpoints. + +## Checklist + +### Phase 1: Backend Correctness + Hardening +- [x] Make `/api/v1/monitor/services/update` treat `nodeId` and `enabled` as optional fields (no accidental zeroing). +- [x] Clamp `intervalSec` to a minimum that matches the checker scan cadence (and apply the same clamp in the checker). +- [x] Add `GET /api/v1/monitor/services/latest-results` returning the latest result per monitor (for frontend list rendering). +- [x] WS metric parsing: only treat messages as metrics when they look like a system-metric payload. + +### Phase 2: Frontend UX + Request Reduction +- [x] Fix “立即检查” toast severity (failure should be an error toast). +- [x] Use `latest-results` endpoint to render service monitor status without N+1 polling. +- [x] Add a small hint when chart data is truncated by backend row limits. + +### Phase 3: Agent Sampling Optimizations +- [x] Reduce default WS metric send interval (2s -> 5s). +- [x] Make CPU sampling non-blocking and cache heavy metrics (e.g. connection counts) to reduce per-sample cost. + +## Test Plan +Backend: +```bash +cd go-backend && go test ./... -count=1 +``` + +Agent fork: +```bash +cd go-gost/x && go test ./... -count=1 +``` + +Frontend (best-effort in this environment): +```bash +cd vite-frontend && npm run build +``` + +## Rollout Notes +- Agent sampling interval change reduces metric resolution and DB growth; charts remain usable and realtime UI remains responsive. +- Existing monitors with very small `intervalSec` are best-effort; effective cadence remains bounded by the checker scan loop. diff --git a/plans/039-monitoring-tunnel-metrics-ingestion.md b/plans/039-monitoring-tunnel-metrics-ingestion.md new file mode 100644 index 0000000..5e9cd8b --- /dev/null +++ b/plans/039-monitoring-tunnel-metrics-ingestion.md @@ -0,0 +1,41 @@ +# 039 - Monitoring: Tunnel Metrics Ingestion + +## Context +The `/monitor` UI includes tunnel metric charts backed by: +- `GET /api/v1/monitor/tunnels` (list) +- `GET /api/v1/monitor/tunnels/:id/metrics` (timeseries) + +The backend has the `tunnel_metric` table + query endpoints, but there is no production code path that writes tunnel metrics. As a result, tunnel charts are typically empty. + +## Goal +Persist tunnel traffic timeseries based on agent flow uploads (`POST /flow/upload`). + +## Scope +- Write `tunnel_metric` rows from flow uploads. +- Keep write volume bounded (aggregate per minute). +- Provide contract coverage that a flow upload creates tunnel metrics. + +## Non-goals +- Populate connections/errors/latency for tunnel metrics (remain 0 for now). +- A full aggregation pipeline across multiple nodes per tunnel at query time. + +UI note: +- The tunnel chart only exposes the Traffic view for now; other tabs are hidden. + +## Design +- Agent reports per-service traffic deltas via `/flow/upload` with items `{n,u,d}`. +- Backend derives `forward_id` from service name (`__[...suffix]`). +- Map `forward_id -> tunnel_id` in batch. +- Aggregate per `(node_id, tunnel_id, minute_bucket)` and upsert into `tunnel_metric` using an UPDATE-then-INSERT fallback. + +## Checklist +- [x] Add repository helper: map forward IDs to tunnel IDs. +- [x] Add repository helper: upsert per-minute tunnel metric buckets. +- [x] Extend `/flow/upload` handler to record tunnel metrics from incoming items. +- [x] Add contract test verifying flow upload produces tunnel metrics. +- [x] Run backend tests. + +## Test Plan +```bash +cd go-backend && go test ./... -count=1 +``` diff --git a/plans/040-service-monitor-configurable-limits-and-ui-hints.md b/plans/040-service-monitor-configurable-limits-and-ui-hints.md new file mode 100644 index 0000000..53d2a83 --- /dev/null +++ b/plans/040-service-monitor-configurable-limits-and-ui-hints.md @@ -0,0 +1,40 @@ +# 040 - Service Monitor Limits Config + UI Hints + +## Goal +Make service monitor interval/timeout constraints configurable (instead of hard-coded clamps) and make the UI clearly communicate the effective limits. + +## Current Pain +- Backend clamps `intervalSec` and `timeoutSec` with hard-coded constants. +- Checker scan cadence is also hard-coded, so users can set values that will never be honored. +- Frontend form does not explain allowed ranges or why values may change. + +## Approach +- Add frontend-configurable limits stored in `vite_config` (with safe defaults matching current behavior). +- Backend always normalizes using the configured limits. +- Expose the current limits via a monitoring endpoint so the UI can render accurate hints. +- Frontend shows min/max and validates before submit. +- Admin can edit the limits on `/config`. + +## Config Keys (vite_config) +- `service_monitor_checker_scan_interval_sec` (default: 30) +- `service_monitor_min_interval_sec` (default: 30; auto-raised to at least scan interval) +- `service_monitor_default_interval_sec` (default: 60) +- `service_monitor_min_timeout_sec` (default: 1) +- `service_monitor_default_timeout_sec` (default: 5) +- `service_monitor_max_timeout_sec` (default: 60) + +## Checklist +Backend: +- [x] Introduce shared `ServiceMonitorLimits` config loader. +- [x] Use limits for create/update normalization. +- [x] Use limits in checker (scan interval + timeout clamp). +- [x] Add `GET /api/v1/monitor/services/limits` to return current limits. + +Frontend: +- [x] Fetch limits once and render input descriptions. +- [x] Validate interval/timeout client-side and show inline errors. +- [x] Add `/config` items to edit the `vite_config` keys. + +Verification: +- [x] `cd go-backend && go test ./... -count=1` +- [x] `cd vite-frontend && npm run lint && npm run build` diff --git a/plans/041-monitoring-reliability-realtime-and-ux-hardening.md b/plans/041-monitoring-reliability-realtime-and-ux-hardening.md new file mode 100644 index 0000000..d769002 --- /dev/null +++ b/plans/041-monitoring-reliability-realtime-and-ux-hardening.md @@ -0,0 +1,224 @@ +# 041 - Monitoring Reliability, Realtime, and UX Hardening + +## Context +Current monitoring support in FLVX already covers three major areas: +- Node runtime metrics from agent WebSocket telemetry, buffered into `node_metric`, exposed by `/api/v1/monitor/nodes*`, and rendered on `/monitor`. +- Tunnel metrics derived from `/flow/upload`, stored in `tunnel_metric`, exposed by `/api/v1/monitor/tunnels*`, and rendered on `/monitor`. +- Service monitoring for `tcp` and `icmp`, including CRUD, scheduled checks, manual run, history, and non-admin authorization via `monitor_permission`. + +The feature set is usable, but the audit found several correctness, reliability, and UX gaps: +- The monitor page is not truly realtime and can lag DB ingestion by tens of seconds. +- Tunnel metrics are only partially implemented and are not aggregated correctly for multi-node tunnels. +- Some monitoring writes fail silently, which can hide data-loss and retention issues. +- Service monitor scheduling is functional but too naive for larger monitor sets and restart scenarios. +- The monitoring UI exposes incomplete semantics, weak freshness cues, and inconsistent permission/error affordances. +- Several monitoring endpoints and edge cases still lack direct automated coverage. + +This plan collects all currently known monitoring follow-up work into one implementation document. + +## Goals +- Make node monitoring data freshness explicit and reduce stale or misleading chart behavior. +- Make tunnel metrics correct for multi-node tunnels and align schema/query/UI semantics. +- Harden service monitor scheduling, persistence, and cleanup behavior. +- Improve observability so monitoring ingestion and result writes never fail silently. +- Upgrade the monitoring UI so operators can understand status, freshness, scope, and failures at a glance. +- Expand automated coverage for all monitoring APIs and the highest-risk aggregation/scheduler cases. + +## Non-goals +- Add a full alerting or notification pipeline. +- Add brand-new monitor protocols beyond the current `tcp` and `icmp` scope. +- Build a large analytics dashboard outside the existing monitoring page structure. +- Introduce frontend test infrastructure for broad component/unit testing unless required by an implementation step. + +## Audit Findings To Address +- Node metrics on `/monitor` are DB-polled rather than realtime-streamed. +- Node metrics are buffered for 30s, so charts can lag behind observed node state. +- Tunnel metrics are stored per `(tunnel_id, node_id, timestamp)` but queried and rendered as if they were already tunnel-level aggregates. +- Tunnel metric minute-bucket upsert uses update-then-insert without uniqueness guarantees. +- Tunnel metrics only populate `bytesIn` and `bytesOut`; `connections`, `errors`, and `avgLatencyMs` are placeholder values. +- Node/tunnel/service-monitor writes can fail silently due to ignored errors. +- Service monitor scheduler is serial and uses in-memory `lastRun`, causing restart skew and slow-monitor head-of-line blocking. +- Deleting a service monitor does not clean up related historical results. +- `expectedCode` exists on the model but is not implemented in behavior or UX. +- The monitoring page/menu is exposed before permission is known, leading to avoidable denied-entry UX. +- Service monitor UI does not clearly show latest result freshness, last check time, or whether a displayed row is stale. +- Chart labels and units are not operator-friendly for long time windows and network-heavy views. +- Monitoring API coverage is incomplete for list, permission, limits, latest-results, multi-node tunnel aggregation, and concurrency paths. + +## Design + +### 1. Node Monitoring Freshness and Realtime Model +- Keep the existing WebSocket node telemetry stream as the source of live state. +- Preserve DB-backed metrics queries for historical charts, but explicitly separate them from live cards/status. +- On `/monitor`, add a lightweight realtime subscription path reusing the existing admin WebSocket feed already used by the node page. +- Use realtime events for: + - node online/offline state, + - a small “latest value” strip or summary above charts, + - freshness timestamp display. +- Keep charts historical and DB-backed by default, but add a visible freshness hint such as: + - `历史图表,最近落库延迟约 0-30s`, or + - `最近入库时间: ...`. +- Do not remove buffered ingestion immediately; first make lag transparent in UI and observable in logs/metrics. +- Optional second-step optimization: reduce flush interval or add a bounded flush-on-latest-view mode if DB pressure remains acceptable. + +### 2. Tunnel Metrics Data Model and Query Semantics +- Decide and document one API contract: + - `GET /api/v1/monitor/tunnels/:id/metrics` must return tunnel-level aggregated series for the selected time range, not raw per-node rows. +- Keep storage per `(tunnel_id, node_id, timestamp)` because it is useful for future drill-down. +- Change query behavior so the tunnel metrics endpoint aggregates rows by timestamp across all nodes for the tunnel: + - `SUM(bytes_in)`, + - `SUM(bytes_out)`, + - `SUM(connections)`, + - `SUM(errors)`, + - `AVG` or weighted-average strategy for latency, if latency is later implemented. +- Return a single point per timestamp to the frontend. +- If future node drill-down is needed, add a separate endpoint rather than mixing per-node rows into the current chart API. + +### 3. Tunnel Metric Upsert Safety +- Replace the current update-then-insert fallback with a uniqueness-backed upsert strategy. +- Add a unique index on `(tunnel_id, node_id, timestamp)`. +- Implement DB-safe upsert behavior compatible with SQLite and PostgreSQL via GORM clauses or equivalent dialect-safe SQL. +- Preserve additive semantics for traffic counters inside the bucket. +- Add concurrency coverage proving that parallel uploads for the same bucket do not create duplicate rows. + +### 4. Tunnel Metric Scope Clarification +- Short term: make the UI and API explicitly traffic-only where the backend only has traffic truth. +- Remove or hide unsupported tunnel metric modes from the current UX until real data exists. +- Do not expose zero-filled placeholders as if they were valid telemetry. +- Keep schema fields if future support is planned, but label them as unimplemented in code comments and avoid rendering them as live features. + +### 5. Service Monitor Scheduler Hardening +- Replace the current fully serial best-effort loop with bounded concurrency: + - retain a global scan loop or next-run calculation, + - collect monitors due for execution, + - execute them with a configurable worker limit, + - avoid one slow node/target delaying all others. +- Move scheduling semantics from pure in-memory `lastRun` toward persisted or history-derived next-run safety: + - on restart, do not fire an uncontrolled burst for all monitors if they just ran; + - use latest persisted result timestamp or a persisted scheduler state to calculate due-ness. +- Keep interval clamping behavior aligned with configured limits. +- Continue supporting local panel execution for `tcp` and node execution for `tcp`/`icmp`. + +### 6. Service Monitor Data Lifecycle +- Define monitor deletion semantics explicitly: + - either cascade-delete historical `service_monitor_result` rows when a monitor is deleted, or + - retain them intentionally and exclude orphan rows from latest/list endpoints. +- Preferred approach: delete associated results with the monitor so the UI/API model stays simple. +- Either implement `expectedCode` fully or remove it from the model/API surface for now. +- Because service monitoring currently supports only `tcp` and `icmp`, and no HTTP checks are implemented, `expectedCode` should likely be removed from the data model/API until a real HTTP monitor exists. + +### 7. Observability and Failure Handling +- Stop swallowing monitoring persistence errors. +- For all node/tunnel/service-monitor writes: + - log structured errors with entity identifiers and operation names, + - increment internal counters if an existing metrics/logging primitive exists, + - keep request/loop behavior resilient, but make failure visible. +- Apply this to: + - node metric batch flush, + - tunnel metric bucket writes, + - scheduled service monitor result writes, + - manual service monitor result writes, + - pruning failures. +- Avoid user-facing hard failures for background ingestion, but surface operational diagnostics in logs. + +### 8. Monitoring UI Semantics and Navigation +- Keep `/monitor` accessible only to authenticated users, but improve pre-entry affordances: + - hide or disable the navigation item for users without monitor permission when role/permission data is known, + - or show a locked state with explanation instead of allowing a full denied page transition. +- Preserve the backend permission check as the source of truth. +- On the page itself, upgrade semantics: + - distinguish `enabled/disabled` from `healthy/unhealthy` in the service monitor table, + - show `last checked at`, + - show `latest result` separately from monitor switch state, + - show whether the latest displayed result is stale. +- Add an at-a-glance monitoring summary near the top: + - online/offline node counts, + - monitors healthy/unhealthy/disabled counts, + - latest data freshness text. + +### 9. Monitoring UI Readability Improvements +- Improve chart axis labeling for long ranges: + - use date + time formatting for 24h windows, + - keep shorter labels for short ranges. +- Format bytes and rates into human-readable units (`KB/s`, `MB/s`, `GB`) instead of raw integers. +- Expose clear empty states and fetch-error states instead of silent failures. +- Make tunnel charts explicitly say `流量趋势` if only traffic is supported. +- Show `statusCode` in the results modal only if the corresponding monitor type ever uses it; otherwise omit it. + +### 10. API and Test Coverage Expansion +- Add contract coverage for: + - `GET /api/v1/monitor/nodes`, + - `GET /api/v1/monitor/tunnels`, + - `GET /api/v1/monitor/services/latest-results`, + - `GET /api/v1/monitor/services/limits`, + - monitor permission list/assign/remove endpoints. +- Add backend tests for: + - multi-node tunnel aggregation returning one point per timestamp, + - tunnel upsert concurrency safety, + - service monitor restart/due scheduling semantics, + - service monitor delete cleanup behavior, + - background write failure logging where practical. +- Keep existing build/test targets green for backend, agent, and frontend. + +## Checklist + +### Phase 1: Correctness Fixes +- [x] Aggregate `GET /api/v1/monitor/tunnels/:id/metrics` by timestamp across all node rows for the selected tunnel. +- [x] Add a unique index for tunnel metric minute buckets and replace the race-prone update-then-insert flow with safe upsert logic. +- [x] Stop exposing unsupported tunnel metric dimensions (`connections`, `errors`, `latency`) as active UI features while the backend still stores placeholders. +- [x] Define and implement service monitor deletion cleanup so history does not leave orphaned result rows. +- [x] Remove or fully implement `expectedCode`; do not keep dead monitoring fields in the live API/model contract. + +### Phase 2: Reliability and Scheduling +- [x] Replace serial service monitor execution with bounded-concurrency execution for due monitors. +- [x] Persist or derive service monitor next-run behavior so process restarts do not trigger uncontrolled immediate reruns. +- [x] Ensure monitor scheduler semantics remain aligned with configured min interval and checker scan cadence. +- [x] Add structured logging for all monitoring persistence failures and prune failures. +- [x] Audit all ignored monitoring write errors and convert them into visible operational diagnostics. + +### Phase 3: Realtime and Freshness UX +- [x] Reuse the existing admin WebSocket stream on `/monitor` for live node status and latest-value freshness indicators. +- [x] Add visible chart freshness metadata so users know historical charts are DB-backed and may lag ingestion. +- [x] Decide whether to reduce node metric flush interval after instrumentation confirms acceptable DB impact (decision: keep 30s default for now; revisit after observing DB write rate and UI staleness in production). +- [x] Add a monitoring summary strip showing online nodes, unhealthy monitors, and latest data time. + +### Phase 4: Monitoring Page UX Cleanup +- [x] Separate service monitor switch state (`启用/禁用`) from probe health (`成功/失败`). +- [x] Add `last checked at` to the service monitor list and results modal context. +- [x] Mark stale results clearly when the latest result is older than the configured interval budget. +- [x] Format traffic and speed values in human-readable units instead of raw bytes. +- [x] Improve chart time labels for 24h windows to include date context. +- [x] Replace silent frontend fetch failures with explicit inline error or toast handling. +- [x] Rename or relabel tunnel chart UI to make its current scope unambiguous. + +### Phase 5: Permission and Navigation UX +- [x] Avoid showing a fully interactive monitor nav entry to users who lack monitoring permission once permission state is known. +- [x] Preserve backend authorization as the final gate and keep denied responses intact. +- [x] Improve denied-state copy so users understand whether they need admin grant vs role change. + +### Phase 6: Automated Coverage +- [x] Add contract tests for monitor node list, tunnel list, latest service monitor results, limits, and permission endpoints. +- [x] Add contract or repository tests for multi-node tunnel aggregation correctness. +- [x] Add concurrency tests for tunnel metric upsert safety. +- [x] Add scheduler tests covering restart behavior, due monitor selection, and slow-monitor isolation. +- [x] Keep existing monitoring contract tests passing after all changes. + +## Implementation Notes +- Prefer backward-compatible API changes where possible, but favor correctness over preserving misleading tunnel metric semantics. +- Do not introduce a fake realtime chart if the data source remains DB-backed; label it honestly. +- If permission visibility requires an extra frontend capability call, keep it lightweight and cacheable. +- If schema/index changes are introduced, they must remain compatible with both SQLite and PostgreSQL. + +## Final Verification Targets +- [x] `GET /api/v1/monitor/tunnels/:id/metrics` returns one aggregated point per timestamp even when multiple nodes report the same tunnel bucket. +- [x] Parallel `/flow/upload` calls for the same tunnel/node/minute do not create duplicate bucket rows. +- [x] `/monitor` clearly distinguishes live state from historical persisted charts and surfaces data freshness to the operator. +- [x] Service monitor list shows enabled state, latest health result, latest check time, and stale-state semantics correctly. +- [x] Deleting a service monitor no longer leaves dangling historical data in list-facing APIs. +- [x] Monitoring ingestion/result write failures are visible in logs and no longer fail silently. +- [x] Non-admin users without monitoring permission do not get a confusing monitor-entry experience, while granted users continue to access monitoring successfully. +- [x] Backend monitoring contract tests pass. +- [x] New repository/scheduler tests pass. +- [x] `cd go-backend && go test ./... -count=1` passes. +- [x] `cd go-gost/x && go test ./socket/... -count=1` passes. +- [x] `cd vite-frontend && npm run build` passes. diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index 1e15ab6..d2bd347 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -4,6 +4,7 @@ import { useEffect } from "react"; import IndexPage from "@/pages/index"; import ChangePasswordPage from "@/pages/change-password"; import DashboardPage from "@/pages/dashboard"; +import MonitorPage from "@/pages/monitor"; import ForwardPage from "@/pages/forward"; import TunnelPage from "@/pages/tunnel"; import NodePage from "@/pages/node"; @@ -122,6 +123,14 @@ function App() { } path="/dashboard" /> + + + + } + path="/monitor" + /> diff --git a/vite-frontend/src/api/error-message.ts b/vite-frontend/src/api/error-message.ts index 3f36920..2e6e5f8 100644 --- a/vite-frontend/src/api/error-message.ts +++ b/vite-frontend/src/api/error-message.ts @@ -73,9 +73,12 @@ const normalizeBatchFailure = ( }; }; -const normalizeBatchFailureReason = (failure: BatchOperationFailure): string => { +const normalizeBatchFailureReason = ( + failure: BatchOperationFailure, +): string => { const name = typeof failure.name === "string" ? failure.name.trim() : ""; - const reason = typeof failure.reason === "string" ? failure.reason.trim() : ""; + const reason = + typeof failure.reason === "string" ? failure.reason.trim() : ""; if (name && reason) { return `${name}: ${reason}`; diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index cf968ea..85f5813 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -26,6 +26,16 @@ import type { SpeedLimitMutationPayload, UpdatePasswordPayload, BackupImportPayload, + NodeMetricApiItem, + TunnelMetricApiItem, + ServiceMonitorApiItem, + ServiceMonitorResultApiItem, + ServiceMonitorLimitsApiData, + ServiceMonitorMutationPayload, + MonitorNodeApiItem, + MonitorTunnelApiItem, + MonitorPermissionApiItem, + MonitorAccessApiData, } from "./types"; import axios from "axios"; @@ -372,3 +382,85 @@ export const getAnnouncement = () => Network.get("/announcement/get"); export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data); + +export const getNodeMetrics = ( + nodeId: number, + start?: number, + end?: number, +) => { + const params: Record = {}; + + if (start) params.start = String(start); + if (end) params.end = String(end); + + return Network.get( + `/monitor/nodes/${nodeId}/metrics`, + params, + ); +}; + +export const getNodeMetricsLatest = (nodeId: number) => + Network.get(`/monitor/nodes/${nodeId}/metrics/latest`); + +export const getTunnelMetrics = ( + tunnelId: number, + start?: number, + end?: number, +) => { + const params: Record = {}; + + if (start) params.start = String(start); + if (end) params.end = String(end); + + return Network.get( + `/monitor/tunnels/${tunnelId}/metrics`, + params, + ); +}; + +export const getMonitorTunnels = () => + Network.get("/monitor/tunnels"); + +export const getServiceMonitorList = () => + Network.get("/monitor/services"); + +export const getServiceMonitorLimits = () => + Network.get("/monitor/services/limits"); + +export const createServiceMonitor = (data: ServiceMonitorMutationPayload) => + Network.post("/monitor/services/create", data); + +export const updateServiceMonitor = (data: ServiceMonitorMutationPayload) => + Network.post("/monitor/services/update", data); + +export const deleteServiceMonitor = (id: number) => + Network.post("/monitor/services/delete", { id }); + +export const getServiceMonitorResults = (monitorId: number, limit = 100) => + Network.get( + `/monitor/services/${monitorId}/results`, + { limit: String(limit) }, + ); + +export const getServiceMonitorLatestResults = () => + Network.get( + "/monitor/services/latest-results", + ); + +export const runServiceMonitor = (id: number) => + Network.post("/monitor/services/run", { id }); + +export const getMonitorNodes = () => + Network.get("/monitor/nodes"); + +export const getMonitorAccess = () => + Network.get("/monitor/access"); + +export const getMonitorPermissionList = () => + Network.get("/monitor/permission/list"); + +export const assignMonitorPermission = (userId: number) => + Network.post("/monitor/permission/assign", { userId }); + +export const removeMonitorPermission = (userId: number) => + Network.post("/monitor/permission/remove", { userId }); diff --git a/vite-frontend/src/api/types.ts b/vite-frontend/src/api/types.ts index a9e1945..7a4bb65 100644 --- a/vite-frontend/src/api/types.ts +++ b/vite-frontend/src/api/types.ts @@ -340,3 +340,104 @@ export interface BackupImportPayload { types: string[]; [key: string]: unknown; } + +export interface NodeMetricApiItem { + id: number; + nodeId: number; + timestamp: number; + cpuUsage: number; + memoryUsage: number; + diskUsage: number; + netInBytes: number; + netOutBytes: number; + netInSpeed: number; + netOutSpeed: number; + load1: number; + load5: number; + load15: number; + tcpConns: number; + udpConns: number; +} + +export interface TunnelMetricApiItem { + id: number; + tunnelId: number; + nodeId: number; + timestamp: number; + bytesIn: number; + bytesOut: number; + connections: number; + errors: number; + avgLatencyMs: number; +} + +export interface ServiceMonitorApiItem { + id: number; + name: string; + // Keep as string for forward-compatibility. + type: string; + target: string; + intervalSec: number; + timeoutSec: number; + nodeId: number; + enabled: number; + createdTime: number; + updatedTime: number; +} + +export interface ServiceMonitorResultApiItem { + id: number; + monitorId: number; + timestamp: number; + success: number; + latencyMs: number; + statusCode: number; + errorMessage: string; +} + +export interface ServiceMonitorMutationPayload { + id?: number; + name: string; + type: "tcp" | "icmp"; + target: string; + intervalSec?: number; + timeoutSec?: number; + nodeId?: number; + enabled?: number; +} + +export interface ServiceMonitorLimitsApiData { + checkerScanIntervalSec: number; + minIntervalSec: number; + defaultIntervalSec: number; + minTimeoutSec: number; + defaultTimeoutSec: number; + maxTimeoutSec: number; +} + +export interface MonitorNodeApiItem { + id: number; + inx: number; + name: string; + status: number; + updatedTime: number; +} + +export interface MonitorTunnelApiItem { + id: number; + inx: number; + name: string; + status: number; + updatedTime: number; +} + +export interface MonitorPermissionApiItem { + id: number; + userId: number; + createdTime: number; +} + +export interface MonitorAccessApiData { + allowed: boolean; + reason?: string; +} diff --git a/vite-frontend/src/components/batch-action-result-modal.tsx b/vite-frontend/src/components/batch-action-result-modal.tsx index f580844..b137e88 100644 --- a/vite-frontend/src/components/batch-action-result-modal.tsx +++ b/vite-frontend/src/components/batch-action-result-modal.tsx @@ -39,7 +39,8 @@ const getFailureTitle = ( }; const getFailureReason = (failure: BatchOperationFailure): string => { - const reason = typeof failure.reason === "string" ? failure.reason.trim() : ""; + const reason = + typeof failure.reason === "string" ? failure.reason.trim() : ""; return reason || "未知错误"; }; diff --git a/vite-frontend/src/layouts/admin.tsx b/vite-frontend/src/layouts/admin.tsx index 2639786..08a5bd5 100644 --- a/vite-frontend/src/layouts/admin.tsx +++ b/vite-frontend/src/layouts/admin.tsx @@ -21,7 +21,7 @@ import { import { Input } from "@/shadcn-bridge/heroui/input"; import { BrandLogo } from "@/components/brand-logo"; import { VersionFooter } from "@/components/version-footer"; -import { updatePassword } from "@/api"; +import { getMonitorAccess, updatePassword } from "@/api"; import { safeLogout } from "@/utils/logout"; import { siteConfig } from "@/config/site"; import { useMobileBreakpoint } from "@/hooks/useMobileBreakpoint"; @@ -56,6 +56,10 @@ export default function AdminLayout({ ); const [username, setUsername] = useState(""); const [isAdmin, setIsAdmin] = useState(false); + const [monitorAllowed, setMonitorAllowed] = useState(null); + const [monitorAccessReason, setMonitorAccessReason] = useState( + null, + ); const [passwordLoading, setPasswordLoading] = useState(false); const [passwordForm, setPasswordForm] = useState({ newUsername: "", @@ -117,6 +121,19 @@ export default function AdminLayout({ ), adminOnly: true, }, + { + path: "/monitor", + label: "监控", + icon: ( + + + + ), + }, { path: "/limit", label: "限速", @@ -184,6 +201,41 @@ export default function AdminLayout({ setUsername(name); setIsAdmin(adminFlag); + + // Monitor permission is not strictly role-based; non-admin users may be + // granted access explicitly. Fetch a lightweight capability flag so we can + // avoid a confusing 403 navigation. + if (adminFlag) { + setMonitorAllowed(true); + setMonitorAccessReason(null); + return; + } + + let cancelled = false; + (async () => { + try { + const res = await getMonitorAccess(); + if (cancelled) return; + if (res.code === 0 && res.data) { + setMonitorAllowed(Boolean(res.data.allowed)); + setMonitorAccessReason( + res.data.allowed ? null : (res.data.reason || null), + ); + return; + } + // Fail open to preserve legacy navigation behavior. + setMonitorAllowed(true); + setMonitorAccessReason(null); + } catch { + if (cancelled) return; + setMonitorAllowed(true); + setMonitorAccessReason(null); + } + })(); + + return () => { + cancelled = true; + }; }, []); useEffect(() => { @@ -218,6 +270,23 @@ export default function AdminLayout({ // 菜单点击处理 const handleMenuClick = (path: string) => { + if (path === "/monitor" && monitorAllowed !== true) { + if (monitorAllowed == null) { + toast("正在检查监控权限,请稍后重试"); + + return; + } + + const hint = + monitorAccessReason === "need_admin_grant" + ? "暂无监控权限,请联系管理员在用户页面授予监控权限" + : "暂无监控权限,请联系管理员授权"; + + toast.error(hint); + + return; + } + navigate(path); if (isMobile) { hideMobileMenu(); @@ -346,6 +415,8 @@ export default function AdminLayout({
    {filteredMenuItems.map((item) => { const isActive = location.pathname === item.path; + const isMonitor = item.path === "/monitor"; + const isMonitorBlocked = isMonitor && monitorAllowed !== true; return (
  • @@ -353,13 +424,23 @@ export default function AdminLayout({ className={` w-full flex items-center p-2 rounded-lg text-left relative min-h-[44px] overflow-hidden transition-colors + ${isMonitorBlocked ? "opacity-60" : ""} ${ isActive ? "text-primary-600 dark:text-primary-300" - : "text-gray-700 dark:text-gray-200" + : isMonitorBlocked + ? "text-gray-500 dark:text-gray-400" + : "text-gray-700 dark:text-gray-200" } `} - title={isCollapsed ? item.label : undefined} + aria-disabled={isMonitorBlocked} + title={ + isCollapsed + ? isMonitorBlocked + ? `${item.label} (无权限)` + : item.label + : undefined + } transition={{ duration: 0.15 }} onClick={() => handleMenuClick(item.path)} > diff --git a/vite-frontend/src/layouts/h5.tsx b/vite-frontend/src/layouts/h5.tsx index b36007b..4192dff 100644 --- a/vite-frontend/src/layouts/h5.tsx +++ b/vite-frontend/src/layouts/h5.tsx @@ -1,8 +1,10 @@ import React, { useState, useEffect } from "react"; import { useNavigate, useLocation } from "react-router-dom"; +import toast from "react-hot-toast"; import { BrandLogo } from "@/components/brand-logo"; import { siteConfig } from "@/config/site"; +import { getMonitorAccess } from "@/api"; import { getAdminFlag } from "@/utils/session"; import { useScrollTopOnPathChange } from "@/hooks/useScrollTopOnPathChange"; @@ -17,6 +19,10 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); const location = useLocation(); const [isAdmin, setIsAdmin] = useState(false); + const [monitorAllowed, setMonitorAllowed] = useState(null); + const [monitorAccessReason, setMonitorAccessReason] = useState( + null, + ); useScrollTopOnPathChange(); @@ -72,6 +78,19 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { ), adminOnly: true, }, + { + path: "/monitor", + label: "监控", + icon: ( + + + + ), + }, { path: "/profile", label: "我的", @@ -84,11 +103,60 @@ export default function H5Layout({ children }: { children: React.ReactNode }) { ]; useEffect(() => { - setIsAdmin(getAdminFlag()); + const adminFlag = getAdminFlag(); + + setIsAdmin(adminFlag); + if (adminFlag) { + setMonitorAllowed(true); + setMonitorAccessReason(null); + + return; + } + + let cancelled = false; + (async () => { + try { + const res = await getMonitorAccess(); + if (cancelled) return; + if (res.code === 0 && res.data) { + setMonitorAllowed(Boolean(res.data.allowed)); + setMonitorAccessReason( + res.data.allowed ? null : (res.data.reason || null), + ); + return; + } + setMonitorAllowed(true); + setMonitorAccessReason(null); + } catch { + if (cancelled) return; + setMonitorAllowed(true); + setMonitorAccessReason(null); + } + })(); + + return () => { + cancelled = true; + }; }, []); // Tab点击处理 const handleTabClick = (path: string) => { + if (path === "/monitor" && monitorAllowed !== true) { + if (monitorAllowed == null) { + toast("正在检查监控权限,请稍后重试"); + + return; + } + + const hint = + monitorAccessReason === "need_admin_grant" + ? "暂无监控权限,请联系管理员授权" + : "暂无监控权限"; + + toast.error(hint); + + return; + } navigate(path); }; @@ -121,6 +189,8 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {