diff --git a/internal/api/api.go b/internal/api/api.go index 878bd6aa..bfc23d69 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -239,6 +239,7 @@ func apiHandler(w http.ResponseWriter, r *http.Request) { mu.Lock() app.Info["host"] = r.Host app.Info["pid"] = os.Getpid() + app.Info["system"] = getSystemInfo() mu.Unlock() ResponseJSON(w, app.Info) diff --git a/internal/api/system.go b/internal/api/system.go new file mode 100644 index 00000000..143065d7 --- /dev/null +++ b/internal/api/system.go @@ -0,0 +1,16 @@ +package api + +type systemInfo struct { + CPUUsage float64 `json:"cpu_usage"` // percent 0-100 + MemTotal uint64 `json:"mem_total"` // bytes + MemUsed uint64 `json:"mem_used"` // bytes +} + +func getSystemInfo() systemInfo { + memTotal, memUsed := getMemoryInfo() + return systemInfo{ + CPUUsage: getCPUUsage(), + MemTotal: memTotal, + MemUsed: memUsed, + } +} diff --git a/internal/api/system_darwin.go b/internal/api/system_darwin.go new file mode 100644 index 00000000..e2a1e81a --- /dev/null +++ b/internal/api/system_darwin.go @@ -0,0 +1,104 @@ +//go:build darwin + +package api + +import ( + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "unsafe" +) + +func getMemoryInfo() (total, used uint64) { + total = sysctl64("hw.memsize") + if total == 0 { + return 0, 0 + } + + pageSize, err := syscall.SysctlUint32("hw.pagesize") + if err != nil { + return total, 0 + } + + freeCount, _ := syscall.SysctlUint32("vm.page_free_count") + purgeableCount, _ := syscall.SysctlUint32("vm.page_purgeable_count") + speculativeCount, _ := syscall.SysctlUint32("vm.page_speculative_count") + + // inactive pages not available via sysctl, parse vm_stat + inactiveCount := vmStatPages("Pages inactive") + + available := uint64(freeCount+purgeableCount+speculativeCount)*uint64(pageSize) + + inactiveCount*uint64(pageSize) + if available > total { + return total, 0 + } + return total, total - available +} + +// vmStatPages parses vm_stat output for a specific counter +func vmStatPages(key string) uint64 { + out, err := exec.Command("vm_stat").Output() + if err != nil { + return 0 + } + for _, line := range strings.Split(string(out), "\n") { + if strings.HasPrefix(line, key) { + // format: "Pages inactive: 479321." + parts := strings.Split(line, ":") + if len(parts) < 2 { + continue + } + s := strings.TrimSpace(parts[1]) + s = strings.TrimSuffix(s, ".") + val, err := strconv.ParseUint(s, 10, 64) + if err != nil { + return 0 + } + return val + } + } + return 0 +} + +func sysctl64(name string) uint64 { + s, err := syscall.Sysctl(name) + if err != nil { + return 0 + } + b := []byte(s) + for len(b) < 8 { + b = append(b, 0) + } + return *(*uint64)(unsafe.Pointer(&b[0])) +} + +func getCPUUsage() float64 { + s, err := syscall.Sysctl("vm.loadavg") + if err != nil { + return 0 + } + + raw := []byte(s) + for len(raw) < 24 { + raw = append(raw, 0) + } + + // struct loadavg { fixpt_t ldavg[3]; long fscale; } + ldavg0 := *(*uint32)(unsafe.Pointer(&raw[0])) + fscale := *(*int64)(unsafe.Pointer(&raw[16])) + + if fscale == 0 { + return 0 + } + + load1 := float64(ldavg0) / float64(fscale) + numCPU := float64(runtime.NumCPU()) + + usage := load1 / numCPU * 100 + if usage > 100 { + usage = 100 + } + return usage +} diff --git a/internal/api/system_darwin_test.go b/internal/api/system_darwin_test.go new file mode 100644 index 00000000..c2fae2ee --- /dev/null +++ b/internal/api/system_darwin_test.go @@ -0,0 +1,116 @@ +//go:build darwin + +package api + +import ( + "os/exec" + "runtime" + "strconv" + "strings" + "syscall" + "testing" +) + +func TestGetMemoryInfo(t *testing.T) { + total, used := getMemoryInfo() + + if total == 0 { + t.Fatal("mem_total is 0") + } + if total < 512*1024*1024 { + t.Fatalf("mem_total too small: %d", total) + } + + // total should match sysctl64("hw.memsize") + expectedTotal := sysctl64("hw.memsize") + if total != expectedTotal { + t.Errorf("mem_total %d != hw.memsize %d", total, expectedTotal) + } + + if used == 0 { + t.Fatal("mem_used is 0") + } + if used > total { + t.Fatalf("mem_used (%d) > mem_total (%d)", used, total) + } + + // cross-check: used should be >= wired+active pages (minimum real usage) + pageSize, _ := syscall.SysctlUint32("hw.pagesize") + wired := vmStatPages("Pages wired down") + active := vmStatPages("Pages active") + minUsed := (wired + active) * uint64(pageSize) + + if used < minUsed/2 { + t.Errorf("mem_used (%d) is less than half of wired+active (%d)", used, minUsed) + } + + avail := total - used + t.Logf("RAM total: %.1f GB, used: %.1f GB, avail: %.1f GB", + float64(total)/1024/1024/1024, + float64(used)/1024/1024/1024, + float64(avail)/1024/1024/1024) +} + +func TestGetCPUUsage(t *testing.T) { + usage := getCPUUsage() + + // cross-check with sysctl vm.loadavg + out, err := exec.Command("sysctl", "-n", "vm.loadavg").Output() + if err != nil { + t.Fatal("sysctl vm.loadavg:", err) + } + + // format: { 4.24 4.57 5.76 } or { 4,24 4,57 5,76 } + s := strings.Trim(string(out), "{ }\n") + fields := strings.Fields(s) + if len(fields) < 1 { + t.Fatal("cannot parse vm.loadavg:", string(out)) + } + load1Str := strings.ReplaceAll(fields[0], ",", ".") + load1, err := strconv.ParseFloat(load1Str, 64) + if err != nil { + t.Fatal("parse load1:", err) + } + + numCPU := float64(runtime.NumCPU()) + expected := load1 / numCPU * 100 + if expected > 100 { + expected = 100 + } + + if usage < 0 || usage > 100 { + t.Fatalf("cpu_usage out of range: %.1f%%", usage) + } + + // allow 15% absolute deviation (load average fluctuates between reads) + diff := usage - expected + if diff < 0 { + diff = -diff + } + if diff > 15 { + t.Errorf("cpu_usage %.1f%% deviates from expected %.1f%% (load1=%.2f, cpus=%d) by %.1f%%", + usage, expected, load1, int(numCPU), diff) + } + + t.Logf("CPU usage: %.1f%%, expected: %.1f%% (load1=%.2f, cpus=%d)", + usage, expected, load1, int(numCPU)) +} + +func TestVmStatPages(t *testing.T) { + inactive := vmStatPages("Pages inactive") + if inactive == 0 { + t.Error("Pages inactive returned 0") + } + + free := vmStatPages("Pages free") + if free == 0 { + t.Error("Pages free returned 0") + } + + bogus := vmStatPages("Pages nonexistent") + if bogus != 0 { + t.Errorf("nonexistent key returned %d", bogus) + } + + t.Logf("inactive=%d, free=%d pages", inactive, free) +} diff --git a/internal/api/system_linux.go b/internal/api/system_linux.go new file mode 100644 index 00000000..377d570f --- /dev/null +++ b/internal/api/system_linux.go @@ -0,0 +1,84 @@ +//go:build linux + +package api + +import ( + "bytes" + "os" + "strconv" + "strings" +) + +func getMemoryInfo() (total, used uint64) { + data, err := os.ReadFile("/proc/meminfo") + if err != nil { + return 0, 0 + } + + var memTotal, memAvailable uint64 + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) < 2 { + continue + } + val, err := strconv.ParseUint(fields[1], 10, 64) + if err != nil { + continue + } + switch fields[0] { + case "MemTotal:": + memTotal = val * 1024 // kB to bytes + case "MemAvailable:": + memAvailable = val * 1024 + } + } + + if memTotal > 0 && memAvailable <= memTotal { + return memTotal, memTotal - memAvailable + } + return memTotal, 0 +} + +// previous CPU times for delta calculation +var prevIdle, prevTotal uint64 + +func getCPUUsage() float64 { + data, err := os.ReadFile("/proc/stat") + if err != nil { + return 0 + } + + // first line: cpu user nice system idle iowait irq softirq steal + idx := bytes.IndexByte(data, '\n') + if idx < 0 { + return 0 + } + line := string(data[:idx]) + fields := strings.Fields(line) + if len(fields) < 5 || fields[0] != "cpu" { + return 0 + } + + var total, idle uint64 + for i := 1; i < len(fields); i++ { + val, err := strconv.ParseUint(fields[i], 10, 64) + if err != nil { + continue + } + total += val + if i == 4 { // idle is the 4th value (index 4 in fields, 1-based field 4) + idle = val + } + } + + deltaTotal := total - prevTotal + deltaIdle := idle - prevIdle + prevIdle = idle + prevTotal = total + + if deltaTotal == 0 { + return 0 + } + + return float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100 +} diff --git a/internal/api/system_other.go b/internal/api/system_other.go new file mode 100644 index 00000000..0694ac8d --- /dev/null +++ b/internal/api/system_other.go @@ -0,0 +1,11 @@ +//go:build !linux && !darwin && !windows + +package api + +func getMemoryInfo() (total, used uint64) { + return 0, 0 +} + +func getCPUUsage() float64 { + return 0 +} diff --git a/internal/api/system_windows.go b/internal/api/system_windows.go new file mode 100644 index 00000000..ccdac5ca --- /dev/null +++ b/internal/api/system_windows.go @@ -0,0 +1,77 @@ +//go:build windows + +package api + +import ( + "syscall" + "unsafe" +) + +var ( + kernel32 = syscall.NewLazyDLL("kernel32.dll") + globalMemoryStatusEx = kernel32.NewProc("GlobalMemoryStatusEx") + getSystemTimes = kernel32.NewProc("GetSystemTimes") +) + +// MEMORYSTATUSEX structure +type memoryStatusEx struct { + dwLength uint32 + dwMemoryLoad uint32 + ullTotalPhys uint64 + ullAvailPhys uint64 + ullTotalPageFile uint64 + ullAvailPageFile uint64 + ullTotalVirtual uint64 + ullAvailVirtual uint64 + ullAvailExtendedVirtual uint64 +} + +func getMemoryInfo() (total, used uint64) { + var ms memoryStatusEx + ms.dwLength = uint32(unsafe.Sizeof(ms)) + + ret, _, _ := globalMemoryStatusEx.Call(uintptr(unsafe.Pointer(&ms))) + if ret == 0 { + return 0, 0 + } + + return ms.ullTotalPhys, ms.ullTotalPhys - ms.ullAvailPhys +} + +type filetime struct { + dwLowDateTime uint32 + dwHighDateTime uint32 +} + +func (ft filetime) ticks() uint64 { + return uint64(ft.dwHighDateTime)<<32 | uint64(ft.dwLowDateTime) +} + +var prevIdleWin, prevTotalWin uint64 + +func getCPUUsage() float64 { + var idleTime, kernelTime, userTime filetime + + ret, _, _ := getSystemTimes.Call( + uintptr(unsafe.Pointer(&idleTime)), + uintptr(unsafe.Pointer(&kernelTime)), + uintptr(unsafe.Pointer(&userTime)), + ) + if ret == 0 { + return 0 + } + + idle := idleTime.ticks() + total := kernelTime.ticks() + userTime.ticks() // kernel includes idle + + deltaTotal := total - prevTotalWin + deltaIdle := idle - prevIdleWin + prevIdleWin = idle + prevTotalWin = total + + if deltaTotal == 0 { + return 0 + } + + return float64(deltaTotal-deltaIdle) / float64(deltaTotal) * 100 +} diff --git a/website/api/openapi.yaml b/website/api/openapi.yaml index 16611230..cf02f7b3 100644 --- a/website/api/openapi.yaml +++ b/website/api/openapi.yaml @@ -129,6 +129,12 @@ paths: properties: listen: { type: string, example: ":8554" } default_query: { type: string, example: "video&audio" } + system: + type: object + properties: + cpu_usage: { type: number, format: float, example: 23.5, description: "CPU usage percent (0-100)" } + mem_total: { type: integer, format: int64, example: 17179869184, description: "Total physical memory in bytes" } + mem_used: { type: integer, format: int64, example: 8589934592, description: "Used memory in bytes" } version: { type: string, example: "1.9.12" } /api/exit: