docs: correct threshold units and what the monitoring views actually ship
The README described CARLOS_ALERT_*_PCT overrides as "fractions (0-1)" while RulesFromEnv reads percent integers via Atoi, so 0.95 — the value the doc invited — was silently rejected and the built-in default quietly stood. Wording fixed, and the agent now logs any override that is set but not accepted, so the next typo says so at boot instead of never. RulesFromEnv stays pure: pctFrac is exported as PctFrac so the agent asks the same question rather than duplicating the rule. Also trimmed the docs to what v1 renders: the per-unit table is server-sorted by CPU with no sort control, owner rows carry CPU and memory, and nothing reads days/ yet — the rollups are written for a later trend view. Noted in README and as a spec §4 implementation note.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+81
−11
README.md+9 −5docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md+9 −0internal/host/metrics_loop.go+24 −0internal/host/metrics_loop_test.go+28 −0internal/metrics/alert.go+11 −6
diff --git a/README.md b/README.md| index 74178b004..3acf3a040 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -368,13 +368,17 @@ for the full design. |
| delivery and the console UI channel are unaffected). |
| - `CARLOS_ALERT_DISK_PCT` / `CARLOS_ALERT_MEM_PCT` / `CARLOS_ALERT_CPU_PCT` — |
| optional threshold overrides for the built-in disk / memory / host-CPU |
| - rules, as fractions (0-1). |
| + rules, as percent integers 0-100 (e.g. `95`). Anything else is ignored |
| + with a log line, and that rule keeps its built-in default. |
| **Console surfaces:** |
| - `GET /fleet/metrics` — operator-only dashboard: per-box needles and |
| - 48-hour sparklines, a per-box per-unit table sortable by CPU/memory, and |
| - active alerts at the top. |
| + 48-hour sparklines, a per-box per-unit table (ordered by CPU, busiest |
| + first), and active alerts at the top. |
| - A "Resources" section on each app's own page — owner-facing, scoped to |
| - that app's own instances only (current CPU/memory/io plus sparklines and |
| - daily trends); never host-level totals or other tenants' units. |
| + that app's own instances only (current CPU and memory plus 48-hour |
| + sparklines); never host-level totals or other tenants' units. |
| + |
| +The `days/` rollups are written from the first flush onward but nothing |
| +renders them yet — they are the history a later trend view will read. |
diff --git a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md| index 0f404ca76..c1ce58c14 100644 |
| --- a/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md |
| +++ b/docs/superpowers/specs/2026-08-02-host-process-monitoring-design.md |
| @@ -188,6 +188,15 @@ sample's own timestamp and applies no such cutoff. |
| House rendering rules for both views: vendored assets only (Lucide-style |
| in-code SVG, no CDN); sparklines drawn from inline data. |
| +Implementation note: v1 renders the 48-hour raw sweep only, on both views. |
| +The `days/` rollups are written from the first flush onward, but nothing |
| +reads them yet — the "longer trends from the daily rollups" above and the |
| +owner "daily trends" are a later task's view, not what ships. Two smaller |
| +narrowings in the same spirit: the per-unit table is server-sorted by CPU |
| +descending with no sort control in the page, and owner instance rows carry |
| +CPU and memory (io is collected and stored per unit, but not rendered on |
| +the owner view). |
| + |
| Implementation note: a series longer than the sparkline's 240px viewBox is |
| downsampled inside `sparkPoints` to at most 240 points via bucket means of |
| non-nil values; a bucket with no non-nil samples produces a gap, never a |
diff --git a/internal/host/metrics_loop.go b/internal/host/metrics_loop.go| index 33ba9714e..f9df3a1a7 100644 |
| --- a/internal/host/metrics_loop.go |
| +++ b/internal/host/metrics_loop.go |
| @@ -646,6 +646,29 @@ func realUnitReadings() map[string]unitRaw { |
| return out |
| } |
| +// alertPctEnvVars are the percent-integer threshold overrides |
| +// metrics.RulesFromEnv reads. |
| +var alertPctEnvVars = []string{"CARLOS_ALERT_DISK_PCT", "CARLOS_ALERT_MEM_PCT", "CARLOS_ALERT_CPU_PCT"} |
| + |
| +// logRejectedAlertOverrides says so when one of those is SET but not |
| +// accepted. RulesFromEnv is pure and silently keeps the default, which is |
| +// the right behaviour — an operator's typo must never stop the agent — but |
| +// silence is the wrong report: someone who writes 0.95 (a fraction, the |
| +// units this took before the README was corrected) would otherwise believe |
| +// they had lowered the CPU threshold while the box quietly ran on 0.95's |
| +// rejection and the built-in default. |
| +func logRejectedAlertOverrides(getenv func(string) string) { |
| + for _, name := range alertPctEnvVars { |
| + v := getenv(name) |
| + if v == "" { |
| + continue |
| + } |
| + if _, ok := metrics.PctFrac(v); !ok { |
| + log.Printf("agent: %s=%q ignored: want a percent integer 0-100 (e.g. 95) — keeping the built-in default", name, v) |
| + } |
| + } |
| +} |
| + |
| // loadAlertState reads a persisted AlertState from path, falling back to a |
| // fresh state for box on anything that isn't a clean read — a missing file |
| // (first boot) or a corrupt one (a previous crash mid-write, or a hand |
| @@ -693,6 +716,7 @@ func metricsLoop() { |
| statePath := filepath.Join(dataDir(), "monitor", "alerts.json") |
| st := loadAlertState(statePath, box) |
| rules := metrics.RulesFromEnv(os.Getenv) |
| + logRejectedAlertOverrides(os.Getenv) |
| webhookURL := os.Getenv("CARLOS_ALERT_WEBHOOK") |
| log.Printf("agent: metrics loop armed (60s ticks, 5min flush), publishing to bucket %s", bucket) |
diff --git a/internal/host/metrics_loop_test.go b/internal/host/metrics_loop_test.go| index a84456516..4e51b8c0b 100644 |
| --- a/internal/host/metrics_loop_test.go |
| +++ b/internal/host/metrics_loop_test.go |
| @@ -1,9 +1,11 @@ |
| package host |
| import ( |
| + "bytes" |
| "context" |
| "encoding/json" |
| "errors" |
| + "log" |
| "math" |
| "os" |
| "path/filepath" |
| @@ -770,3 +772,29 @@ func TestMetricsFlushBackfillLeavesExistingRollupAlone(t *testing.T) { |
| t.Fatalf("second flush changed hour 09 to %+v", got) |
| } |
| } |
| + |
| +// TestLogRejectedAlertOverrides: a threshold override that RulesFromEnv |
| +// rejects (it wants a percent integer, and "0.95" — the fraction the README |
| +// used to describe — is not one) must not vanish silently. An accepted value |
| +// and an unset one say nothing. |
| +func TestLogRejectedAlertOverrides(t *testing.T) { |
| + env := map[string]string{ |
| + "CARLOS_ALERT_DISK_PCT": "0.95", // a fraction: rejected |
| + "CARLOS_ALERT_MEM_PCT": "85", // accepted |
| + // CARLOS_ALERT_CPU_PCT unset |
| + } |
| + var buf bytes.Buffer |
| + prev := log.Writer() |
| + log.SetOutput(&buf) |
| + defer log.SetOutput(prev) |
| + |
| + logRejectedAlertOverrides(func(k string) string { return env[k] }) |
| + |
| + out := buf.String() |
| + if !strings.Contains(out, "CARLOS_ALERT_DISK_PCT") { |
| + t.Errorf("a rejected override logged nothing: %q", out) |
| + } |
| + if strings.Contains(out, "CARLOS_ALERT_MEM_PCT") || strings.Contains(out, "CARLOS_ALERT_CPU_PCT") { |
| + t.Errorf("an accepted or unset override was logged: %q", out) |
| + } |
| +} |
diff --git a/internal/metrics/alert.go b/internal/metrics/alert.go| index 86979d46a..9cb788aee 100644 |
| --- a/internal/metrics/alert.go |
| +++ b/internal/metrics/alert.go |
| @@ -35,23 +35,28 @@ func DefaultRules() Rules { |
| // CARLOS_ALERT_DISK_PCT, CARLOS_ALERT_MEM_PCT, CARLOS_ALERT_CPU_PCT. An |
| // unset, unparsable, or out-of-range (0..100) value keeps the default — |
| // deliberately pure (no logging); a caller that wants to log a rejected |
| -// override does so itself, since this package touches neither stderr nor |
| -// the OS. |
| +// override asks PctFrac the same question this does, since this package |
| +// touches neither stderr nor the OS. |
| func RulesFromEnv(getenv func(string) string) Rules { |
| r := DefaultRules() |
| - if v, ok := pctFrac(getenv("CARLOS_ALERT_DISK_PCT")); ok { |
| + if v, ok := PctFrac(getenv("CARLOS_ALERT_DISK_PCT")); ok { |
| r.DiskFrac = v |
| } |
| - if v, ok := pctFrac(getenv("CARLOS_ALERT_MEM_PCT")); ok { |
| + if v, ok := PctFrac(getenv("CARLOS_ALERT_MEM_PCT")); ok { |
| r.MemFrac = v |
| } |
| - if v, ok := pctFrac(getenv("CARLOS_ALERT_CPU_PCT")); ok { |
| + if v, ok := PctFrac(getenv("CARLOS_ALERT_CPU_PCT")); ok { |
| r.CPUFrac = v |
| } |
| return r |
| } |
| -func pctFrac(s string) (float64, bool) { |
| +// PctFrac converts one percent-integer override string ("95") to the |
| +// fraction Rules holds (0.95), reporting ok=false for empty, unparsable, or |
| +// out-of-range (0..100) input. Exported so the agent can LOG an override it |
| +// set but this rejected without either duplicating the rule or dragging |
| +// stderr into this package. |
| +func PctFrac(s string) (float64, bool) { |
| if s == "" { |
| return 0, false |
| } |