Browse Source

Fix system metrics collection in API

Metrics Improvements:
- CPU: Implement background CPU monitoring with caching
  * Measure CPU usage in separate goroutine every 2 seconds
  * Prevents blocking HTTP requests with sleep delays
  * Calculate usage from /proc/stat (all fields, not just first 4)
  * Include idle + iowait in idle calculation
- Memory: Read actual system memory from /proc/meminfo
  * Use MemTotal - MemAvailable instead of runtime.MemStats
  * Show real system memory usage, not just Go process
- Load Average: Return all 3 values (1m, 5m, 15m)
  * Parse all three fields from /proc/loadavg
  * Update MetricsResponse struct with load_1m, load_5m, load_15m

API Changes:
- Add cachedCPUPercent field to APIServer
- Add updateCPUMetrics() background goroutine
- Update MetricsResponse struct for load average fields
- Remove unused runtime import

Tunnel Port Reporting:
- Add ReportTunnelPort() to API client
- Report SSH/Dashboard tunnel port allocation to server
- Send port updates on connect/disconnect

Testing:
- Built and flashed to Luckfox Pico Ultra W (RK1106)
- Verified CPU metrics match htop/top output (within 5-10%)
- Verified memory shows actual system usage (~30MB/215MB)
- Verified load average shows all 3 values correctly

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
root 4 weeks ago
parent
commit
d9577725bf
4 changed files with 206 additions and 18 deletions
  1. 136 15
      cmd/beacon-daemon/api.go
  2. 47 0
      cmd/beacon-daemon/client.go
  3. 2 2
      cmd/beacon-daemon/main.go
  4. 21 1
      cmd/beacon-daemon/ssh_tunnel.go

+ 136 - 15
cmd/beacon-daemon/api.go

@@ -10,7 +10,6 @@ import (
 	"net/http"
 	"net/http"
 	"os"
 	"os"
 	"os/exec"
 	"os/exec"
-	"runtime"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
@@ -40,6 +39,10 @@ type APIServer struct {
 	// Session management
 	// Session management
 	sessions   map[string]time.Time
 	sessions   map[string]time.Time
 	sessionsMu sync.RWMutex
 	sessionsMu sync.RWMutex
+
+	// Cached CPU metrics (updated in background)
+	cachedCPUPercent float64
+	cpuMu            sync.RWMutex
 }
 }
 
 
 // StatusResponse is the response for /api/status
 // StatusResponse is the response for /api/status
@@ -92,7 +95,9 @@ type MetricsResponse struct {
 	MemUsedMB   float64 `json:"mem_used_mb"`
 	MemUsedMB   float64 `json:"mem_used_mb"`
 	MemTotalMB  float64 `json:"mem_total_mb"`
 	MemTotalMB  float64 `json:"mem_total_mb"`
 	Temperature float64 `json:"temperature"`
 	Temperature float64 `json:"temperature"`
-	LoadAvg     float64 `json:"load_avg"`
+	Load1m      float64 `json:"load_1m"`
+	Load5m      float64 `json:"load_5m"`
+	Load15m     float64 `json:"load_15m"`
 }
 }
 
 
 // SettingsRequest is the request for /api/settings
 // SettingsRequest is the request for /api/settings
@@ -155,6 +160,9 @@ func (s *APIServer) Start(addr string) error {
 		return fmt.Errorf("HTTP server already running")
 		return fmt.Errorf("HTTP server already running")
 	}
 	}
 
 
+	// Start CPU monitoring goroutine
+	go s.updateCPUMetrics()
+
 	mux := http.NewServeMux()
 	mux := http.NewServeMux()
 
 
 	// API endpoints
 	// API endpoints
@@ -290,14 +298,34 @@ func (s *APIServer) handleStatus(w http.ResponseWriter, r *http.Request) {
 	s.jsonResponse(w, status)
 	s.jsonResponse(w, status)
 }
 }
 
 
+// updateCPUMetrics updates CPU metrics in background
+func (s *APIServer) updateCPUMetrics() {
+	for {
+		cpuPercent := getCPUPercent()
+		s.cpuMu.Lock()
+		s.cachedCPUPercent = cpuPercent
+		s.cpuMu.Unlock()
+
+		time.Sleep(2 * time.Second)
+	}
+}
+
 // handleMetrics returns system metrics
 // handleMetrics returns system metrics
 func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
 func (s *APIServer) handleMetrics(w http.ResponseWriter, r *http.Request) {
+	load1m, load5m, load15m := getLoadAvg()
+
+	s.cpuMu.RLock()
+	cpuPercent := s.cachedCPUPercent
+	s.cpuMu.RUnlock()
+
 	metrics := MetricsResponse{
 	metrics := MetricsResponse{
-		CPUPercent:  getCPUPercent(),
+		CPUPercent:  cpuPercent,
 		MemUsedMB:   getMemUsedMB(),
 		MemUsedMB:   getMemUsedMB(),
 		MemTotalMB:  getMemTotalMB(),
 		MemTotalMB:  getMemTotalMB(),
 		Temperature: getTemperature(),
 		Temperature: getTemperature(),
-		LoadAvg:     getLoadAvg(),
+		Load1m:      load1m,
+		Load5m:      load5m,
+		Load15m:     load15m,
 	}
 	}
 
 
 	s.jsonResponse(w, metrics)
 	s.jsonResponse(w, metrics)
@@ -653,14 +681,99 @@ func parseFloat(s string, f *float64) error {
 }
 }
 
 
 func getCPUPercent() float64 {
 func getCPUPercent() float64 {
-	// Simplified - read from /proc/stat
-	return 0 // TODO: implement proper CPU usage
+	// Read CPU stats from /proc/stat twice with 100ms interval
+	stat1 := readCPUStat()
+	time.Sleep(100 * time.Millisecond)
+	stat2 := readCPUStat()
+
+	if stat1 == nil || stat2 == nil {
+		return 0
+	}
+
+	// Calculate totals (sum all fields)
+	var total1, total2, idle1, idle2 int64
+	for i := 0; i < len(stat1) && i < 10; i++ {
+		total1 += stat1[i]
+		if i < len(stat2) {
+			total2 += stat2[i]
+		}
+	}
+
+	// Idle = idle + iowait (fields 3 and 4)
+	if len(stat1) >= 5 {
+		idle1 = stat1[3] + stat1[4]
+	}
+	if len(stat2) >= 5 {
+		idle2 = stat2[3] + stat2[4]
+	}
+
+	totalDelta := total2 - total1
+	idleDelta := idle2 - idle1
+
+	if totalDelta == 0 {
+		return 0
+	}
+
+	cpuPercent := 100.0 * (1.0 - float64(idleDelta)/float64(totalDelta))
+	return cpuPercent
+}
+
+func readCPUStat() []int64 {
+	data, err := os.ReadFile("/proc/stat")
+	if err != nil {
+		return nil
+	}
+
+	// First line: cpu  user nice system idle iowait irq softirq steal guest guest_nice
+	scanner := bufio.NewScanner(strings.NewReader(string(data)))
+	if !scanner.Scan() {
+		return nil
+	}
+
+	line := scanner.Text()
+	if !strings.HasPrefix(line, "cpu ") {
+		return nil
+	}
+
+	fields := strings.Fields(line)
+	if len(fields) < 5 {
+		return nil
+	}
+
+	var stats []int64
+	// Read all available fields (up to 10)
+	for i := 1; i < len(fields) && i < 11; i++ {
+		var v int64
+		json.Unmarshal([]byte(fields[i]), &v)
+		stats = append(stats, v)
+	}
+
+	return stats
 }
 }
 
 
 func getMemUsedMB() float64 {
 func getMemUsedMB() float64 {
-	var m runtime.MemStats
-	runtime.ReadMemStats(&m)
-	return float64(m.Alloc) / 1024 / 1024
+	data, err := os.ReadFile("/proc/meminfo")
+	if err != nil {
+		return 0
+	}
+
+	var memTotal, memAvailable int64
+	scanner := bufio.NewScanner(strings.NewReader(string(data)))
+	for scanner.Scan() {
+		line := scanner.Text()
+		if strings.HasPrefix(line, "MemTotal:") {
+			parseMemInfo(line, &memTotal)
+		} else if strings.HasPrefix(line, "MemAvailable:") {
+			parseMemInfo(line, &memAvailable)
+		}
+	}
+
+	if memTotal == 0 {
+		return 0
+	}
+
+	memUsed := memTotal - memAvailable
+	return float64(memUsed) / 1024 // Convert KB to MB
 }
 }
 
 
 func getMemTotalMB() float64 {
 func getMemTotalMB() float64 {
@@ -701,16 +814,24 @@ func getTemperature() float64 {
 	return float64(temp) / 1000
 	return float64(temp) / 1000
 }
 }
 
 
-func getLoadAvg() float64 {
+func getLoadAvg() (float64, float64, float64) {
 	data, err := os.ReadFile("/proc/loadavg")
 	data, err := os.ReadFile("/proc/loadavg")
 	if err != nil {
 	if err != nil {
-		return 0
+		return 0, 0, 0
 	}
 	}
-	var load float64
-	if err := parseFloat(string(data), &load); err != nil {
-		return 0
+
+	// /proc/loadavg format: "0.10 0.40 0.49 1/98 2452"
+	parts := strings.Fields(string(data))
+	if len(parts) < 3 {
+		return 0, 0, 0
 	}
 	}
-	return load
+
+	var load1m, load5m, load15m float64
+	json.Unmarshal([]byte(parts[0]), &load1m)
+	json.Unmarshal([]byte(parts[1]), &load5m)
+	json.Unmarshal([]byte(parts[2]), &load15m)
+
+	return load1m, load5m, load15m
 }
 }
 
 
 func generateToken(length int) string {
 func generateToken(length int) string {

+ 47 - 0
cmd/beacon-daemon/client.go

@@ -333,3 +333,50 @@ func GenerateOrLoadSSHKey(keyPath string) (string, error) {
 
 
 	return pubKeyStr, nil
 	return pubKeyStr, nil
 }
 }
+
+// TunnelPortReport is sent to report tunnel port allocation
+type TunnelPortReport struct {
+	TunnelType string `json:"tunnel_type"` // "ssh" or "dashboard"
+	Port       *int   `json:"port"`        // Allocated port (nil if disconnected)
+	Status     string `json:"status"`      // "connected" or "disconnected"
+}
+
+// ReportTunnelPort reports tunnel port allocation to server
+func (c *APIClient) ReportTunnelPort(tunnelType string, port int, status string) error {
+	var portPtr *int
+	if status == "connected" {
+		portPtr = &port
+	}
+
+	body, err := json.Marshal(&TunnelPortReport{
+		TunnelType: tunnelType,
+		Port:       portPtr,
+		Status:     status,
+	})
+	if err != nil {
+		return err
+	}
+
+	httpReq, err := http.NewRequest("POST", c.baseURL+"/tunnel-port", bytes.NewReader(body))
+	if err != nil {
+		return err
+	}
+
+	httpReq.Header.Set("Content-Type", "application/json")
+	if c.token != "" {
+		httpReq.Header.Set("Authorization", "Bearer "+c.token)
+	}
+
+	resp, err := c.httpClient.Do(httpReq)
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	if resp.StatusCode != 200 {
+		respBody, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("tunnel port report failed: %d %s", resp.StatusCode, string(respBody))
+	}
+
+	return nil
+}

+ 2 - 2
cmd/beacon-daemon/main.go

@@ -119,7 +119,7 @@ func main() {
 		KeepaliveInterval: cfg.SSHTunnel.KeepaliveInterval,
 		KeepaliveInterval: cfg.SSHTunnel.KeepaliveInterval,
 		ReconnectDelay:    cfg.SSHTunnel.ReconnectDelay,
 		ReconnectDelay:    cfg.SSHTunnel.ReconnectDelay,
 	}
 	}
-	sshTunnel := NewSSHTunnel("ssh", 22, sshTunnelCfg)
+	sshTunnel := NewSSHTunnel("ssh", 22, sshTunnelCfg, client)
 
 
 	dashboardTunnelCfg := &TunnelConfig{
 	dashboardTunnelCfg := &TunnelConfig{
 		Enabled:           cfg.DashboardTunnel.Enabled,
 		Enabled:           cfg.DashboardTunnel.Enabled,
@@ -130,7 +130,7 @@ func main() {
 		KeepaliveInterval: cfg.DashboardTunnel.KeepaliveInterval,
 		KeepaliveInterval: cfg.DashboardTunnel.KeepaliveInterval,
 		ReconnectDelay:    cfg.DashboardTunnel.ReconnectDelay,
 		ReconnectDelay:    cfg.DashboardTunnel.ReconnectDelay,
 	}
 	}
-	dashboardTunnel := NewSSHTunnel("dashboard", 80, dashboardTunnelCfg)
+	dashboardTunnel := NewSSHTunnel("dashboard", 80, dashboardTunnelCfg, client)
 
 
 	// Create scanner manager
 	// Create scanner manager
 	scanners := NewScannerManager(*binDir, cfg.Debug)
 	scanners := NewScannerManager(*binDir, cfg.Debug)

+ 21 - 1
cmd/beacon-daemon/ssh_tunnel.go

@@ -26,17 +26,19 @@ type SSHTunnel struct {
 	localPort int    // Local port to forward (22 for SSH, 8080 for dashboard)
 	localPort int    // Local port to forward (22 for SSH, 8080 for dashboard)
 
 
 	cfg       *TunnelConfig
 	cfg       *TunnelConfig
+	client    *APIClient
 	cmd       *exec.Cmd
 	cmd       *exec.Cmd
 	stopChan  chan struct{}
 	stopChan  chan struct{}
 	mu        sync.Mutex
 	mu        sync.Mutex
 }
 }
 
 
 // NewSSHTunnel creates a new SSH tunnel manager
 // NewSSHTunnel creates a new SSH tunnel manager
-func NewSSHTunnel(name string, localPort int, cfg *TunnelConfig) *SSHTunnel {
+func NewSSHTunnel(name string, localPort int, cfg *TunnelConfig, client *APIClient) *SSHTunnel {
 	return &SSHTunnel{
 	return &SSHTunnel{
 		name:      name,
 		name:      name,
 		localPort: localPort,
 		localPort: localPort,
 		cfg:       cfg,
 		cfg:       cfg,
+		client:    client,
 		stopChan:  make(chan struct{}),
 		stopChan:  make(chan struct{}),
 	}
 	}
 }
 }
@@ -84,6 +86,17 @@ func (t *SSHTunnel) Start() error {
 	log.Printf("[%s-tunnel] Started: %s:%d -> localhost:%d (remote_port=%d)",
 	log.Printf("[%s-tunnel] Started: %s:%d -> localhost:%d (remote_port=%d)",
 		t.name, t.cfg.Server, t.cfg.Port, t.localPort, t.cfg.RemotePort)
 		t.name, t.cfg.Server, t.cfg.Port, t.localPort, t.cfg.RemotePort)
 
 
+	// Report tunnel connection to server
+	go func() {
+		// Wait a bit for SSH to establish connection
+		time.Sleep(2 * time.Second)
+		if err := t.client.ReportTunnelPort(t.name, t.cfg.RemotePort, "connected"); err != nil {
+			log.Printf("[%s-tunnel] Failed to report connection to server: %v", t.name, err)
+		} else {
+			log.Printf("[%s-tunnel] Reported connection to server (port %d)", t.name, t.cfg.RemotePort)
+		}
+	}()
+
 	// Monitor process
 	// Monitor process
 	go t.monitor()
 	go t.monitor()
 
 
@@ -131,6 +144,13 @@ func (t *SSHTunnel) Stop() {
 
 
 	if t.cmd != nil && t.cmd.Process != nil {
 	if t.cmd != nil && t.cmd.Process != nil {
 		t.cmd.Process.Kill()
 		t.cmd.Process.Kill()
+
+		// Report disconnection to server
+		if t.cfg.RemotePort != 0 {
+			if err := t.client.ReportTunnelPort(t.name, 0, "disconnected"); err != nil {
+				log.Printf("[%s-tunnel] Failed to report disconnection to server: %v", t.name, err)
+			}
+		}
 	}
 	}
 }
 }