diff --git a/cmd/pimonitor/main.go b/cmd/pimonitor/main.go index 5fb6d85..35bcfb7 100644 --- a/cmd/pimonitor/main.go +++ b/cmd/pimonitor/main.go @@ -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) diff --git a/docs/API.md b/docs/API.md index 463926f..ffa345a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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" } ], @@ -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 @@ -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 } } ``` diff --git a/internal/alert/alert.go b/internal/alert/alert.go index f9e7643..222de0d 100644 --- a/internal/alert/alert.go +++ b/internal/alert/alert.go @@ -84,6 +84,9 @@ type Sample struct { TemperatureC float64 TemperatureValid bool + MemoryPercent float64 + MemoryValid bool + SwapPercent float64 SwapValid bool @@ -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)) } diff --git a/internal/alert/alert_test.go b/internal/alert/alert_test.go index c328063..80e844c 100644 --- a/internal/alert/alert_test.go +++ b/internal/alert/alert_test.go @@ -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, } } @@ -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) diff --git a/internal/collector/collector.go b/internal/collector/collector.go index 0d21664..e990578 100644 --- a/internal/collector/collector.go +++ b/internal/collector/collector.go @@ -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, diff --git a/internal/collector/collector_test.go b/internal/collector/collector_test.go index e9dc6d4..f7e42b5 100644 --- a/internal/collector/collector_test.go +++ b/internal/collector/collector_test.go @@ -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) } } diff --git a/internal/config/config.go b/internal/config/config.go index fdd1556..e6b7359 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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 @@ -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, @@ -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 { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 939522d..335757a 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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 }}, diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 35a7da1..f4cd03b 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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 diff --git a/internal/web/assets/app.js b/internal/web/assets/app.js index c625db6..ca4165d 100644 --- a/internal/web/assets/app.js +++ b/internal/web/assets/app.js @@ -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; @@ -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)); diff --git a/packaging/pimonitor.example.yaml b/packaging/pimonitor.example.yaml index 91c140d..8906b62 100644 --- a/packaging/pimonitor.example.yaml +++ b/packaging/pimonitor.example.yaml @@ -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