Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions cmd/pimonitor/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,16 @@ func run(args []string) error {
PollIntervalSeconds: cfg.PollIntervalSeconds,
NetworkEnabled: cfg.NetworkEnabled,
Thresholds: httpapi.Thresholds{
TemperatureWarnC: cfg.Thresholds.TemperatureWarnC,
TemperatureCritC: cfg.Thresholds.TemperatureCritC,
CPUWarnPercent: cfg.Thresholds.CPUWarnPercent,
CPUCritPercent: cfg.Thresholds.CPUCritPercent,
DiskWarnPercent: cfg.Thresholds.DiskWarnPercent,
DiskCritPercent: cfg.Thresholds.DiskCritPercent,
SwapWarnPercent: cfg.Thresholds.SwapWarnPercent,
SwapCritPercent: cfg.Thresholds.SwapCritPercent,
TemperatureWarnC: cfg.Thresholds.TemperatureWarnC,
TemperatureCritC: cfg.Thresholds.TemperatureCritC,
CPUWarnPercent: cfg.Thresholds.CPUWarnPercent,
CPUCritPercent: cfg.Thresholds.CPUCritPercent,
DiskWarnPercent: cfg.Thresholds.DiskWarnPercent,
DiskCritPercent: cfg.Thresholds.DiskCritPercent,
SwapWarnPercent: cfg.Thresholds.SwapWarnPercent,
SwapCritPercent: cfg.Thresholds.SwapCritPercent,
MemoryWarnPercent: cfg.Thresholds.MemoryWarnPercent,
MemoryCritPercent: cfg.Thresholds.MemoryCritPercent,
},
},
}, staticHandler, log)
Expand Down
9 changes: 6 additions & 3 deletions docs/API.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,7 @@ polling this endpoint).
"states": [
{ "metric": "cpu", "level": "ok", "value": 12.4, "since": "2026-07-12T18:00:00Z" },
{ "metric": "disk", "resource": "/", "level": "warn", "value": 82.1, "since": "2026-07-12T18:25:00Z" },
{ "metric": "memory", "level": "ok", "value": 45.2, "since": "2026-07-12T18:00:00Z" },
{ "metric": "swap", "level": "ok", "value": 0, "since": "2026-07-12T18:00:00Z" },
{ "metric": "temperature", "level": "crit", "value": 78.5, "since": "2026-07-12T18:30:10Z" }
],
Expand Down Expand Up @@ -239,8 +240,8 @@ Notes:
- `enabled` is `false` (with empty `states`/`events`) when alerting is
disabled via `alerts.enabled: false`.
- `states` lists one entry per evaluated metric: `cpu`, `temperature`,
`swap`, and one `disk` entry per mounted filesystem (distinguished by
`resource`, the mountpoint). `resource` is omitted for non-per-device
`memory`, `swap`, and one `disk` entry per mounted filesystem (distinguished
by `resource`, the mountpoint). `resource` is omitted for non-per-device
metrics.
- A metric whose collection fails on a given tick is skipped rather than
evaluated against a bogus zero, so its state is left unchanged (or absent
Expand Down Expand Up @@ -286,7 +287,9 @@ server:
"disk_warn_percent": 80,
"disk_crit_percent": 95,
"swap_warn_percent": 50,
"swap_crit_percent": 90
"swap_crit_percent": 90,
"memory_warn_percent": 80,
"memory_crit_percent": 95
}
}
```
Expand Down
6 changes: 6 additions & 0 deletions internal/alert/alert.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,9 @@ type Sample struct {
TemperatureC float64
TemperatureValid bool

MemoryPercent float64
MemoryValid bool

SwapPercent float64
SwapValid bool

Expand Down Expand Up @@ -197,6 +200,9 @@ func (e *Engine) Evaluate(s Sample) []Event {
if s.TemperatureValid {
record(e.evalMetric("temperature", "", s.TemperatureC, t.TemperatureWarnC, t.TemperatureCritC, s.Timestamp))
}
if s.MemoryValid {
record(e.evalMetric("memory", "", s.MemoryPercent, t.MemoryWarnPercent, t.MemoryCritPercent, s.Timestamp))
}
if s.SwapValid {
record(e.evalMetric("swap", "", s.SwapPercent, t.SwapWarnPercent, t.SwapCritPercent, s.Timestamp))
}
Expand Down
54 changes: 46 additions & 8 deletions internal/alert/alert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import (
// machine tests: temperature warns at 60/crits at 75, cpu at 80/95, etc.
func testThresholds() config.Thresholds {
return config.Thresholds{
TemperatureWarnC: 60,
TemperatureCritC: 75,
CPUWarnPercent: 80,
CPUCritPercent: 95,
DiskWarnPercent: 80,
DiskCritPercent: 95,
SwapWarnPercent: 50,
SwapCritPercent: 90,
TemperatureWarnC: 60,
TemperatureCritC: 75,
CPUWarnPercent: 80,
CPUCritPercent: 95,
DiskWarnPercent: 80,
DiskCritPercent: 95,
SwapWarnPercent: 50,
SwapCritPercent: 90,
MemoryWarnPercent: 80,
MemoryCritPercent: 95,
}
}

Expand Down Expand Up @@ -269,6 +271,42 @@ func TestEvaluate_InvalidMetricKeepsState(t *testing.T) {
}
}

// Memory has its own threshold pair and is evaluated like every other
// metric: a sustained crossing must fire, and a read failure must not
// spuriously report ok.
func TestEvaluate_MemoryEvaluated(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
e := New(testThresholds(), 0)

e.Evaluate(Sample{Timestamp: start, MemoryPercent: 99, MemoryValid: true})

r := e.Report()
var memState *State
for i := range r.States {
if r.States[i].Metric == "memory" {
memState = &r.States[i]
}
}
if memState == nil || memState.Level != LevelCrit {
t.Fatalf("expected memory to be crit at 99%%, got %+v", memState)
}
if len(r.Events) != 1 || r.Events[0].Metric != "memory" || r.Events[0].To != LevelCrit {
t.Fatalf("expected a single memory fired->crit event, got %+v", r.Events)
}

// A failed read must not clear the alert.
e.Evaluate(Sample{Timestamp: start.Add(time.Second), MemoryValid: false})
r = e.Report()
for i := range r.States {
if r.States[i].Metric == "memory" {
memState = &r.States[i]
}
}
if memState.Level != LevelCrit {
t.Fatalf("expected memory to remain crit through a read failure, got %s", memState.Level)
}
}

// Disk alerts are tracked per mountpoint, independently.
func TestEvaluate_PerDiskState(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
Expand Down
2 changes: 2 additions & 0 deletions internal/collector/collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,8 @@ func (c *Collector) fastTick(ctx context.Context) {
CPUValid: cpuErr == nil,
TemperatureC: temp.Celsius,
TemperatureValid: tempErr == nil,
MemoryPercent: mem.UsedPercent,
MemoryValid: memErr == nil,
SwapPercent: swap.UsedPercent,
SwapValid: memErr == nil,
Disks: diskSamples,
Expand Down
15 changes: 9 additions & 6 deletions internal/collector/collector_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,20 +155,23 @@ func TestCollector_Alerts_EvaluatedOnFastTick(t *testing.T) {
if !report.Enabled {
t.Fatal("expected alerts to be enabled")
}
// CPU and swap collection succeed on any Linux CI host, so both states
// must be present. (Temperature may be skipped when no thermal zone is
// available, e.g. in a container, so it is not asserted here.)
var haveCPU, haveSwap bool
// CPU, memory, and swap collection succeed on any Linux CI host, so all
// three states must be present. (Temperature may be skipped when no
// thermal zone is available, e.g. in a container, so it is not asserted
// here.)
var haveCPU, haveMemory, haveSwap bool
for _, st := range report.States {
switch st.Metric {
case "cpu":
haveCPU = true
case "memory":
haveMemory = true
case "swap":
haveSwap = true
}
}
if !haveCPU || !haveSwap {
t.Fatalf("expected cpu and swap alert states, got %+v", report.States)
if !haveCPU || !haveMemory || !haveSwap {
t.Fatalf("expected cpu, memory, and swap alert states, got %+v", report.States)
}
}

Expand Down
37 changes: 21 additions & 16 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,16 @@ import (
// Thresholds are the warn/critical cutoffs used both to color-code the web
// dashboard and to size the load-average gauges.
type Thresholds struct {
TemperatureWarnC float64 `yaml:"temperature_warn_c"`
TemperatureCritC float64 `yaml:"temperature_crit_c"`
CPUWarnPercent float64 `yaml:"cpu_warn_percent"`
CPUCritPercent float64 `yaml:"cpu_crit_percent"`
DiskWarnPercent float64 `yaml:"disk_warn_percent"`
DiskCritPercent float64 `yaml:"disk_crit_percent"`
SwapWarnPercent float64 `yaml:"swap_warn_percent"`
SwapCritPercent float64 `yaml:"swap_crit_percent"`
TemperatureWarnC float64 `yaml:"temperature_warn_c"`
TemperatureCritC float64 `yaml:"temperature_crit_c"`
CPUWarnPercent float64 `yaml:"cpu_warn_percent"`
CPUCritPercent float64 `yaml:"cpu_crit_percent"`
DiskWarnPercent float64 `yaml:"disk_warn_percent"`
DiskCritPercent float64 `yaml:"disk_crit_percent"`
SwapWarnPercent float64 `yaml:"swap_warn_percent"`
SwapCritPercent float64 `yaml:"swap_crit_percent"`
MemoryWarnPercent float64 `yaml:"memory_warn_percent"`
MemoryCritPercent float64 `yaml:"memory_crit_percent"`
}

// Alerts configures the server-side threshold alert engine, which maps each
Expand Down Expand Up @@ -115,14 +117,16 @@ func Default() Config {
PiModelEnabled: true,
APIKey: "",
Thresholds: Thresholds{
TemperatureWarnC: 60,
TemperatureCritC: 75,
CPUWarnPercent: 80,
CPUCritPercent: 95,
DiskWarnPercent: 80,
DiskCritPercent: 95,
SwapWarnPercent: 50,
SwapCritPercent: 90,
TemperatureWarnC: 60,
TemperatureCritC: 75,
CPUWarnPercent: 80,
CPUCritPercent: 95,
DiskWarnPercent: 80,
DiskCritPercent: 95,
SwapWarnPercent: 50,
SwapCritPercent: 90,
MemoryWarnPercent: 80,
MemoryCritPercent: 95,
},
Alerts: Alerts{
Enabled: true,
Expand Down Expand Up @@ -272,6 +276,7 @@ func (t Thresholds) validate() error {
{"cpu", t.CPUWarnPercent, t.CPUCritPercent},
{"disk", t.DiskWarnPercent, t.DiskCritPercent},
{"swap", t.SwapWarnPercent, t.SwapCritPercent},
{"memory", t.MemoryWarnPercent, t.MemoryCritPercent},
}
for _, p := range pairs {
if p.warn < 0 {
Expand Down
1 change: 1 addition & 0 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ func TestValidate_RejectsBadValues(t *testing.T) {
{"cpu warn above crit", func(c *Config) { c.Thresholds.CPUWarnPercent = 99 }},
{"disk warn above crit", func(c *Config) { c.Thresholds.DiskWarnPercent = 99 }},
{"swap warn above crit", func(c *Config) { c.Thresholds.SwapWarnPercent = 99 }},
{"memory warn above crit", func(c *Config) { c.Thresholds.MemoryWarnPercent = 99 }},
{"negative alerts for_seconds", func(c *Config) { c.Alerts.ForSeconds = -1 }},
{"negative notify max retries", func(c *Config) { c.Alerts.NotifyMaxRetries = -1 }},
{"negative notify backoff", func(c *Config) { c.Alerts.NotifyRetryBackoffSeconds = -1 }},
Expand Down
18 changes: 10 additions & 8 deletions internal/httpapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,14 +25,16 @@ type MetricsProvider interface {
// Thresholds are the color-coding thresholds the frontend uses to render
// metric cards as ok/warn/critical.
type Thresholds struct {
TemperatureWarnC float64 `json:"temperature_warn_c"`
TemperatureCritC float64 `json:"temperature_crit_c"`
CPUWarnPercent float64 `json:"cpu_warn_percent"`
CPUCritPercent float64 `json:"cpu_crit_percent"`
DiskWarnPercent float64 `json:"disk_warn_percent"`
DiskCritPercent float64 `json:"disk_crit_percent"`
SwapWarnPercent float64 `json:"swap_warn_percent"`
SwapCritPercent float64 `json:"swap_crit_percent"`
TemperatureWarnC float64 `json:"temperature_warn_c"`
TemperatureCritC float64 `json:"temperature_crit_c"`
CPUWarnPercent float64 `json:"cpu_warn_percent"`
CPUCritPercent float64 `json:"cpu_crit_percent"`
DiskWarnPercent float64 `json:"disk_warn_percent"`
DiskCritPercent float64 `json:"disk_crit_percent"`
SwapWarnPercent float64 `json:"swap_warn_percent"`
SwapCritPercent float64 `json:"swap_crit_percent"`
MemoryWarnPercent float64 `json:"memory_warn_percent"`
MemoryCritPercent float64 `json:"memory_crit_percent"`
}

// ClientConfig is the non-sensitive runtime configuration exposed via
Expand Down
4 changes: 3 additions & 1 deletion internal/web/assets/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
disk_crit_percent: 95,
swap_warn_percent: 50,
swap_crit_percent: 90,
memory_warn_percent: 80,
memory_crit_percent: 95,
},
};
let lastCPUCount = 1;
Expand Down Expand Up @@ -225,7 +227,7 @@
// Memory & swap (show absolute sizes alongside the percentage, like
// the filesystem rows).
const memUsed = Math.max(0, (snap.memory.total_bytes || 0) - (snap.memory.available_bytes || 0));
renderBar('mem-bar', 'mem-pct', snap.memory.used_percent, t.disk_warn_percent, t.disk_crit_percent,
renderBar('mem-bar', 'mem-pct', snap.memory.used_percent, t.memory_warn_percent, t.memory_crit_percent,
fmtBytes(memUsed) + ' / ' + fmtBytes(snap.memory.total_bytes));
renderBar('swap-bar', 'swap-pct', snap.swap.used_percent, t.swap_warn_percent, t.swap_crit_percent,
fmtBytes(snap.swap.used_bytes) + ' / ' + fmtBytes(snap.swap.total_bytes));
Expand Down
2 changes: 2 additions & 0 deletions packaging/pimonitor.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ thresholds:
disk_crit_percent: 95
swap_warn_percent: 50
swap_crit_percent: 90
memory_warn_percent: 80
memory_crit_percent: 95

# Server-side threshold alert engine. On each poll it maps the current
# metrics against the thresholds above into per-metric alert states
Expand Down
Loading