package telemetry import ( "context" "encoding/json" "fmt" "net/http" "os" "os/exec" "path/filepath" "sort" "strings" "time" "github.com/shirou/gopsutil/v4/cpu" "github.com/shirou/gopsutil/v4/disk" "github.com/shirou/gopsutil/v4/host" gopsload "github.com/shirou/gopsutil/v4/load" "github.com/shirou/gopsutil/v4/mem" "git.hangman-lab.top/zhi/HarborForge.Monitor/internal/config" ) const Version = "0.1.0" type Payload struct { Identifier string `json:"identifier"` PluginVersion string `json:"plugin_version,omitempty"` Agents []any `json:"agents"` NginxInstalled bool `json:"nginx_installed"` NginxSites []string `json:"nginx_sites"` CPUPct float64 `json:"cpu_pct,omitempty"` MemPct float64 `json:"mem_pct,omitempty"` DiskPct float64 `json:"disk_pct,omitempty"` SwapPct float64 `json:"swap_pct,omitempty"` LoadAvg []float64 `json:"load_avg,omitempty"` UptimeSeconds uint64 `json:"uptime_seconds,omitempty"` // Optional OpenClaw metadata, enriched from plugin bridge. // These fields are omitted if no plugin data is available. OpenClawVersion string `json:"openclaw_version,omitempty"` } func BuildPayload(ctx context.Context, cfg config.Config) (Payload, error) { payload := Payload{ Identifier: cfg.Identifier, PluginVersion: Version, Agents: []any{}, NginxSites: []string{}, } cpuPct, err := cpu.PercentWithContext(ctx, time.Second, false) if err == nil && len(cpuPct) > 0 { payload.CPUPct = round1(cpuPct[0]) } vm, err := mem.VirtualMemoryWithContext(ctx) if err == nil { payload.MemPct = round1(vm.UsedPercent) } diskPath := cfg.RootFS if diskPath == "" { diskPath = "/" } diskUsage, err := disk.UsageWithContext(ctx, diskPath) if err == nil { payload.DiskPct = round1(diskUsage.UsedPercent) } swapUsage, err := mem.SwapMemoryWithContext(ctx) if err == nil { payload.SwapPct = round1(swapUsage.UsedPercent) } avg, err := gopsload.AvgWithContext(ctx) if err == nil { payload.LoadAvg = []float64{round2(avg.Load1), round2(avg.Load5), round2(avg.Load15)} } hostInfo, err := host.InfoWithContext(ctx) if err == nil { payload.UptimeSeconds = hostInfo.Uptime } nginxInstalled, nginxSites, err := detectNginx(cfg.RootFS) if err == nil { payload.NginxInstalled = nginxInstalled payload.NginxSites = nginxSites } return payload, nil } func Send(ctx context.Context, client *http.Client, cfg config.Config, payload Payload) error { body, err := json.Marshal(payload) if err != nil { return fmt.Errorf("marshal payload: %w", err) } req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(cfg.BackendURL, "/")+"/monitor/server/heartbeat", strings.NewReader(string(body))) if err != nil { return fmt.Errorf("build request: %w", err) } req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-Key", cfg.APIKey) resp, err := client.Do(req) if err != nil { return fmt.Errorf("send request: %w", err) } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { return fmt.Errorf("heartbeat failed with status %s", resp.Status) } return nil } func detectNginx(rootFS string) (bool, []string, error) { installed := false if rootFS == "" { if _, err := exec.LookPath("nginx"); err == nil { installed = true } } for _, path := range []string{ rootPath(rootFS, "/etc/nginx/nginx.conf"), rootPath(rootFS, "/usr/local/etc/nginx/nginx.conf"), rootPath(rootFS, "/opt/homebrew/etc/nginx/nginx.conf"), rootPath(rootFS, "/usr/sbin/nginx"), rootPath(rootFS, "/usr/bin/nginx"), } { if _, err := os.Stat(path); err == nil { installed = true break } } if !installed { return false, []string{}, nil } dir := rootPath(rootFS, "/etc/nginx/sites-enabled") entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return true, []string{}, nil } return true, nil, fmt.Errorf("read nginx sites-enabled: %w", err) } sites := make([]string, 0, len(entries)) for _, entry := range entries { name := entry.Name() fullPath := filepath.Join(dir, name) if entry.Type()&os.ModeSymlink != 0 { if target, err := os.Readlink(fullPath); err == nil { name = fmt.Sprintf("%s -> %s", name, target) } } sites = append(sites, name) } sort.Strings(sites) return true, sites, nil } func round1(v float64) float64 { return float64(int(v*10+0.5)) / 10 } func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 } func rootPath(rootFS, path string) string { if rootFS == "" || rootFS == "/" { return path } return filepath.Join(rootFS, strings.TrimPrefix(path, "/")) }