From ab80450b66469a582b4df63491a8163c931e124c Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 13 Feb 2026 11:11:10 +0300 Subject: [PATCH 1/7] Expose process PID in API and UI Include the process PID in the API info payload and surface it in the frontend. apiHandler now adds app.Info["pid"] = os.Getpid(); openapi.yaml documents the new pid property as an integer with an example; www/index.html is updated to show pid alongside version and config. This aids debugging and identifying the running process. --- internal/api/api.go | 1 + website/api/openapi.yaml | 1 + www/index.html | 7 ++++++- 3 files changed, 8 insertions(+), 1 deletion(-) diff --git a/internal/api/api.go b/internal/api/api.go index dfb65117..878bd6aa 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -238,6 +238,7 @@ var mu sync.Mutex func apiHandler(w http.ResponseWriter, r *http.Request) { mu.Lock() app.Info["host"] = r.Host + app.Info["pid"] = os.Getpid() mu.Unlock() ResponseJSON(w, app.Info) diff --git a/website/api/openapi.yaml b/website/api/openapi.yaml index b6110572..16611230 100644 --- a/website/api/openapi.yaml +++ b/website/api/openapi.yaml @@ -123,6 +123,7 @@ paths: properties: config_path: { type: string, example: "/config/go2rtc.yaml" } host: { type: string, example: "192.168.1.123:1984" } + pid: { type: integer, example: 12345 } rtsp: type: object properties: diff --git a/www/index.html b/www/index.html index 69126e6f..34ad2c9d 100644 --- a/www/index.html +++ b/www/index.html @@ -147,7 +147,12 @@ const url = new URL('api', location.href); fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => { const info = document.querySelector('.info'); - info.innerText = `version: ${data.version} / config: ${data.config_path}`; + const parts = [ + `version: ${data.version}`, + `pid: ${data.pid}`, + `config: ${data.config_path}`, + ]; + info.innerText = parts.join(' / '); }); reload(); From 0b80fa53cf3676e3cf528e57fc304387aaa32c98 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 13 Feb 2026 14:49:20 +0300 Subject: [PATCH 2/7] feat(api): add system resource monitoring functionality - implement getSystemInfo to gather CPU and memory usage - add platform-specific implementations for memory and CPU usage - enhance OpenAPI documentation to include system resource metrics --- internal/api/api.go | 1 + internal/api/system.go | 16 ++++ internal/api/system_darwin.go | 104 ++++++++++++++++++++++++++ internal/api/system_darwin_test.go | 116 +++++++++++++++++++++++++++++ internal/api/system_linux.go | 84 +++++++++++++++++++++ internal/api/system_other.go | 11 +++ internal/api/system_windows.go | 77 +++++++++++++++++++ website/api/openapi.yaml | 6 ++ 8 files changed, 415 insertions(+) create mode 100644 internal/api/system.go create mode 100644 internal/api/system_darwin.go create mode 100644 internal/api/system_darwin_test.go create mode 100644 internal/api/system_linux.go create mode 100644 internal/api/system_other.go create mode 100644 internal/api/system_windows.go 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: From 8a083d6f53377464f98a3f0d16a1d1b4c9da11c5 Mon Sep 17 00:00:00 2001 From: Sergey Krashevich Date: Fri, 13 Feb 2026 15:11:22 +0300 Subject: [PATCH 3/7] feat(index.html): enhance system information display - add ASCII graph rendering for CPU and memory usage - implement history tracking for better visualization - introduce error handling for data fetching chore(main.js): define system info update interval - set a common refresh interval for system info updates --- www/index.html | 109 +++++++++++++++++++++++++++++++++++++++++++++---- www/main.js | 3 ++ 2 files changed, 103 insertions(+), 9 deletions(-) diff --git a/www/index.html b/www/index.html index 34ad2c9d..8e2cd2c3 100644 --- a/www/index.html +++ b/www/index.html @@ -14,6 +14,8 @@ .info { color: #888; + white-space: pre; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } @@ -144,16 +146,105 @@ // Auto-reload setInterval(reload, 1000); - const url = new URL('api', location.href); - fetch(url, {cache: 'no-cache'}).then(r => r.json()).then(data => { - const info = document.querySelector('.info'); - const parts = [ - `version: ${data.version}`, - `pid: ${data.pid}`, - `config: ${data.config_path}`, + const info = document.querySelector('.info'); + const infoURL = new URL('api', location.href); + const cpuHistory = []; + const memHistory = []; + const graphWidth = 36; + const graphHeight = 8; + const infoUpdateInterval = window.SYSTEM_INFO_UPDATE_INTERVAL_MS ?? 2000; + + function toNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number : 0; + } + + function clampPercent(value) { + return Math.max(0, Math.min(100, toNumber(value))); + } + + function pushHistory(history, value) { + history.push(value); + while (history.length > graphWidth) history.shift(); + } + + function formatBytes(bytes) { + const units = ['B', 'KB', 'MB', 'GB', 'TB']; + let value = Math.max(0, toNumber(bytes)); + let index = 0; + while (value >= 1024 && index < units.length - 1) { + value /= 1024; + index++; + } + return `${value.toFixed(value >= 100 || index === 0 ? 0 : 1)} ${units[index]}`; + } + + function renderAsciiGraphLines(history) { + const bars = Array.from({length: graphWidth}, (_, index) => { + const value = history[index] ?? 0; + return Math.round((value / 100) * graphHeight); + }); + const lines = []; + for (let row = graphHeight; row >= 1; row--) { + let line = '|'; + for (const bar of bars) { + line += bar >= row ? '#' : ' '; + } + line += '|'; + lines.push(line); + } + lines.push('+' + '-'.repeat(graphWidth) + '+'); + return lines; + } + + function padRight(text, width) { + return text + ' '.repeat(Math.max(0, width - text.length)); + } + + function renderInfo(data, cpuUsage, memUsage, memUsed, memTotal) { + const cpuLines = renderAsciiGraphLines(cpuHistory); + const memLines = renderAsciiGraphLines(memHistory); + const graphLines = []; + const graphBlockWidth = graphWidth + 2; // borders + const cpuTitle = `CPU ${cpuUsage.toFixed(1)}%`; + const memTitle = `MEM ${memUsage.toFixed(1)}% (${formatBytes(memUsed)} / ${formatBytes(memTotal)})`; + + graphLines.push( + `${padRight(cpuTitle, graphBlockWidth)} ${padRight(memTitle, graphBlockWidth)}` + ); + for (let i = 0; i < cpuLines.length; i++) { + graphLines.push(`${cpuLines[i]} ${memLines[i]}`); + } + + const lines = [ + `version: ${data.version ?? '-'}`, + `pid: ${data.pid ?? '-'}`, + `config: ${data.config_path ?? '-'}`, + '', + ...graphLines, ]; - info.innerText = parts.join(' / '); - }); + info.textContent = lines.join('\n'); + } + + function updateInfo() { + fetch(infoURL, {cache: 'no-cache'}).then(r => r.json()).then(data => { + const cpuUsage = clampPercent(data.system?.cpu_usage); + const memUsed = toNumber(data.system?.mem_used); + const memTotal = toNumber(data.system?.mem_total); + const memUsage = memTotal > 0 ? clampPercent((memUsed * 100) / memTotal) : 0; + + pushHistory(cpuHistory, cpuUsage); + pushHistory(memHistory, memUsage); + renderInfo(data, cpuUsage, memUsage, memUsed, memTotal); + }).catch(error => { + if (!info.textContent) { + info.textContent = `Can't load system info: ${error.message}`; + } + }); + } + + updateInfo(); + setInterval(updateInfo, infoUpdateInterval); reload(); diff --git a/www/main.js b/www/main.js index d5629178..862a3673 100644 --- a/www/main.js +++ b/www/main.js @@ -122,6 +122,9 @@ document.head.innerHTML += ` `; +// Common UI refresh intervals (ms) +window.SYSTEM_INFO_UPDATE_INTERVAL_MS = 2000; + document.body.innerHTML = `