Commit Graph
283 Commits
Author SHA1 Message Date
bvandeusenandClaude Opus 5.5 0945d78290 feat(mcp): understand and judge a metric anomaly: what happened, what was usual, what else went on (#5837)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m30s
CI / integration (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
get_anomaly gives each occurrence against what is usual at that hour, the
metric around the newest one with its normal, what else happened on the
resource's host around each occurrence (alert transitions, Ansible runs,
other anomalies there and a count elsewhere, plus plugins' own events
through a new mcp_events hook: the host agent's maintenance jobs and
downtime, Docker's container starts, deaths and OOM kills), and how earlier
anomalies on the same metric were judged.

get_metric_normal reads the learned slots and the level that would be
flagged. review_anomaly takes one id or several, all or nothing, with a
note. get_anomaly_settings and (admin) set_anomaly_settings read and change
the review window and each normal's floor, as Settings → Thresholds does.

metric_series.points takes an optional `until`; core.alerts gains
host_of_resource; floor_setting names its floor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:17:34 -04:00
bvandeusenandClaude Opus 5.5 8feec4dc77 feat(mcp): Steward's own backups: the runs, the next one, checking and making one (#5836)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m25s
CI / integration (push) Successful in 1m31s
CI / publish (push) Successful in 1m13s
list_backups, check_backup and make_backup are Settings → Backups for an
agent, admin only like the page. make_backup runs the page's job in the
background and is audited as "<user> via MCP". Restoring stays in the
browser. get_overview names a scheduled backup that failed, is two hours
past its slot, or has no bucket.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 13:09:12 -04:00
bvandeusenandClaude Opus 5.5 3ad8eed83d feat: the host agent is always on; the MCP sees and starts host maintenance (#5834)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m32s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m14s
#5834 "The host agent is always on, and the MCP sees a host's
maintenance", step 1 of milestone 520 "The MCP sees what Steward has
grown".

The host agent is a core system (operator, 2026-10-15: "it's very core
to the functionality of Steward as a whole and shouldn't be considered
something that can be toggled off"). ALWAYS_ON_PLUGINS in core settings
makes to_plugins_cfg report it enabled whatever its stored row says, so
it always loads and its MCP tools are always listed. Settings -> Plugins
shows it as "Built in: always on" with no switch, and a save keeps it
enabled. Its code and URLs stay under plugins/host_agent: every
installed agent posts to /plugins/host_agent/ingest.

Maintenance through the MCP:
- list_maintenance_plans: each plan's group, weekly slot, reboot policy,
  next slot, and its latest run (hosts, how far it got, who it skipped
  and why, how it ended).
- get_maintenance_job(host | job): a host's jobs, newest and running
  first, or one job: state, packages updated, reboot, drained by, why it
  failed, when its current state times out.
- run_maintenance(host, reboot="if_required" | "never"): admin, as the
  host page's Update now; refuses a busy host or one with no helper;
  audited "<user> via MCP".
- The overview names a host whose latest job in the last week failed or
  timed out.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:59:42 -04:00
bvandeusenandClaude Opus 5.5 e122860cdc feat(mcp): write, test and delete this instance's own playbooks (#5748)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m6s
#5748 "MCP can write, edit and test this instance's own playbooks
(steward-local)": two admin tools beside run_playbook, so an agent can
build automation for these hosts that isn't part of Steward itself.

- save_playbook(playbook, content, source="steward-local", replace=false)
  goes through the editor's own validate_playbook_yaml and
  write_playbook. A playbook that isn't a list of plays comes back with
  the YAML error and its line, and nothing is written. An existing file
  is overwritten only with replace=true. steward-builtin is refused
  (Steward's playbooks change in its repository), as are git sources
  (their repo owns them).
- After writing, `ansible-playbook --syntax-check` runs against a
  throwaway localhost inventory (no host contacted, 60 s), so a module
  name Ansible can't resolve shows before any run. The result rides the
  reply; the file is kept either way.
- delete_playbook(playbook, source="steward-local").
- Both audit as ansible.playbook.created/updated/deleted, "<user> via
  MCP", with the size and never the content.

The testing loop is the existing one: run_playbook defaults to check
mode, on one target, then get_ansible_run. The integration test does
that loop with the real ansible-playbook.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:01:39 -04:00
bvandeusenandClaude Opus 5.5 8569b3d674 fix(backup): the safety backup's audit row names who restored (#5820)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m28s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m10s
On the live restore the "before restore" backup.made row read user
"unknown": _safety_backup recorded it with no username. It now carries
the person who started the restore.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:32:08 -04:00
bvandeusenandClaude Opus 5.5 e445c83e67 feat(backup): restore at first-run setup, restore --in-place, docs lead with the UI (#5818, #5819)
CI / integration (push) Successful in 1m28s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m17s
CI / lint (push) Successful in 2s
#5818 "First-run setup: Restore from a backup, by upload": beside creating
the first admin, setup offers "Or restore from a backup". Only while no
user exists; the upload is checked (a newer backup or one whose key can't
open its secrets is refused and not kept), then restored with no safety
backup (nothing to keep), and Steward restarts into the backup's users.

#5819 "Docs and CI": backups.md leads with restoring from Settings →
Backups and from setup; `steward restore --in-place` does the same-schema
restore from the shell, and CI's image check now uses it with a real
pg_restore: a changed setting comes back and the audit log stays.

Also exempts errors/restoring.html from the breadcrumb guard, as with
the other whole-app error pages: it replaces every page while a restore
runs, so there is nowhere to trail back to.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:05:39 -04:00
bvandeusenandClaude Opus 5.5 fb99f8b551 feat(backup): restore from Settings → Backups, with restoring mode (#5816, #5817)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m27s
CI / unit (push) Failing after 1m37s
CI / publish (push) Skipped
#5816 "Restoring mode": while a backup goes back in, the scheduler holds,
ingest and HTMX answer 503 with Retry-After (agents keep their samples),
and every page says what is happening. A safety backup of Steward as it
stands is made first, to /data/backups and the bucket. The backup's key
is put in place, the restore audited, and Steward exits with code 3 so
Swarm, compose or systemd starts it fresh. A failed in-place restore
changes nothing and says why on Backups; a full replace that fails after
the wipe restarts into first-run setup.

#5817 "Settings → Backups: Restore": a Restore button on each sent backup
and on the file card. The chosen backup is checked (the same comparison
as Check, now a shared partial) and held server-side for an hour under a
token; it starts only once the admin types "restore", and an older
backup also needs "Replace everything" ticked. A newer backup, or one
whose key can't open its secrets, gets no button.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 11:03:01 -04:00
bvandeusenandClaude Opus 5.5 9e5f6e183a fix(backup): refuse another schema before reading the dump; tests read settings directly (#5815)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m11s
- restore_in_place checks the backup's migration heads before pg_restore
  writes anything, so a backup from another schema is refused at once.
- The integration tests read their own setting rows directly:
  get_all_settings knows only Steward's own keys.
- check.py: drop an import left unused by the move to pgdata.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:56:57 -04:00
bvandeusenandClaude Opus 5.5 aab8581f20 feat(backup): restore a same-version backup in place, history kept, all or nothing (#5815)
CI / lint (push) Failing after 3s
CI / unit (push) Successful in 1m41s
CI / integration (push) Failing after 1m45s
CI / publish (push) Skipped
#5815 "Restore engine: in place for a same-version backup".

restore_in_place (steward/backup/restore.py) runs in one transaction:
1. It keeps aside what Steward records as it runs: ACTIVITY columns of rows
   that come back, and STATE_SETTINGS.
2. It drops every foreign key touching a replaced table.
3. It truncates the replaced tables and COPYs the backup's rows in
   unparsed, through asyncpg.
4. It puts the kept state back and moves sequences past their rows.
5. It puts right the rows whose parent didn't come back, by each key's own
   ON DELETE. A RESTRICT key that may be empty is cleared, so a run whose
   user is gone keeps its record.
6. It re-adds the keys, which checks every row.

A failure anywhere rolls the whole restore back. A backup made at another
schema is refused with NotSameSchema ("older": a full replace is the way;
"newer": update Steward).

The COPY-text reading moves into steward/backup/pgdata.py, shared by the
check and the restore. The integration tests stand in for pg_restore with
this database's own COPY output:
- settings come back while the audit log and the history of what remains
  stay;
- history of what didn't come back goes;
- a failure part-way changes nothing;
- a newer schema is refused.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:53:38 -04:00
bvandeusenandClaude Opus 5.5 e8b75143f1 feat(backup): check a backup against what is in place, changing nothing (#5823)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m8s
#5823 "Check a backup: compare what it would restore with what is in
place, without restoring".

- steward/backup/check.py: pg_restore writes the backup's rows as COPY
  text with no database involved. The rows in place go out through COPY in
  the same form. Tables are compared on their primary keys, over the columns
  both sides have, and grouped by area.
- Sealed values are opened with each side's own key and compared as
  plaintext digests, so a secret sealed twice still matches. They are never
  shown. A backup whose key can't open its own secrets fails the check.
- What Steward records as it runs (last-seen times, a schedule's last run,
  backup history, scraped UniFi fields) is left out of the comparison:
  contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY.
- Settings → Backups:
  - a Check button on each sent backup, which fetches it from the bucket
    (new s3.get_file);
  - "Check a file" to upload one, with the size limit raised before the
    CSRF check reads the form;
  - a results page.
- Each backup Steward sends checks itself once written, and its history
  row says how that went.
- New CLI command `steward check-backup FILE`. CI's image smoke step checks a
  real backup against its source and expects a match.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:48:48 -04:00
bvandeusenandClaude Opus 5.5 e6da0ff299 feat(backup): a backup holds settings and keys only, for recovery (#5822)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m10s
#5822 "Backup contents: settings and keys only". The operator's ruling
(System 180, 2026-10-15) overturns 2026-10-12's "Config + audit + alerts":
a backup is for system recovery and carries what would otherwise have to
be set up again, with no logs or history.

- contents.py: audit_events, alert_events, alert_states, metric_anomalies,
  metric_anomaly_spans and user_invites move to EXCLUDED.
- Alert state is no longer restored, and evaluation skips a rule with no
  state row. ensure_rule_states (alerts/service.py) gives every
  single-resource rule lacking one an inactive row; alert_reconcile runs
  it each minute.
- Docs, the Backups page and the CLI help say what a backup holds now.
  CI's image check also finds the audit log empty after a restore.
- tests/integration/test_backup_restore_fixes.py is removed: its case (an
  alert event naming a left-out Ansible run) no longer arises. The SET NULL
  fix-up is covered by a unit test on synthetic tables.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:39:55 -04:00
bvandeusenandClaude Opus 5.5 b29b160baa feat(widgets): Overview verdict, pending monitors' age, fullest filesystems first (#5810)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m33s
CI / publish (push) Successful in 1m16s
- Overview: a verdict line when something is wrong ('1 alert firing, 1
  monitor down.'); its picker description now says what it lists (alerts,
  monitors down, anomalies). Host figures have their home in Hosts.
  Comments no longer name the Status page that was folded into Monitors.
- Monitors widget: a monitor not checked yet says when it was added.
- Host page: filesystems fullest first, by the worse of space and inodes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:11:37 -04:00
bvandeusenandClaude Opus 5.5 c113cadbdd feat(widgets): Routers and Hosts problem rows say since when (#5810)
Routers: a router in trouble reads 'started 2h ago' or 'all of the last
24 h', from problem_since over the day's history, read for the troubled
routers only (a certificate's problem carries its own date). Hosts: a
down host reads 'a monitor is down for 3h'; host_glance keeps the
down_since host_status already had.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:08:38 -04:00
bvandeusenandClaude Opus 5.5 a68990db70 fix(alerts): an alert's email waits for the real commit, not a savepoint (#5807)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m16s
SQLAlchemy fires after_commit and after_rollback for a savepoint too. The
host-agent ingest releases one after recording its metrics, so an alert
raised by a sample started its email before the outer transaction
committed; twice on 2026-10-12 the outbox row hit the event's foreign key
first and the email was lost. A savepoint rolling back (a docker persist
failing) cleared every pending notification, the outer one's included.

_after_commit now runs its jobs only on the outermost commit, and a
rolled-back savepoint drops only the jobs added inside it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 10:05:50 -04:00
bvandeusenandClaude Opus 5.5 e2055242f1 fix(host_agent): disks behind a RAID controller each keep their own name (#5806)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m6s
smartctl reports every disk behind a PERC/MegaRAID controller as /dev/bus/0
with type megaraid,N; the helper named a drive by its path alone, so all of
VDNT-Docker02's and Docker03's disks read as one drive "0", each overwriting
the last. The slot is now part of the name (bus0-megaraid3), and Steward gives
a repeated name an ordinal so two drives never share a series. Agent 1.20.1.

fix(ansible): Steward stopping mid-run logs no traceback (#5749)

The run's done-callback called exception() on a cancelled task, which raises;
it now logs a run cut short by shutdown at INFO. A finished run's in-memory
state is dropped by a timer rather than a sleeping task, which shutdown found
still pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:41:20 -04:00
bvandeusenandClaude Opus 5.5 6c4ef368f9 feat(host_agent): each drive's health, read with smartctl, alertable (#5789)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 1m10s
The root helper gains one read-only action, `drives`: it finds the drives
itself (smartctl --scan-open), reads each as JSON, and writes a summary the
agent carries on every sample (agent 1.20.0). Steward judges the figures in
drives.py (failing / worn / watch / ok / no SMART / unreadable), records them
per drive and as host roll-ups for covering rules, keeps the newest report on
the registration and lists it on the host page.

install-deps.sh installs smartmontools with apt, dnf, yum, zypper, pacman or
apk, run by install.sh and every agent playbook; one it can't install is
reported, never fatal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 09:17:50 -04:00
bvandeusenandClaude Opus 5.5 26a3ec3339 feat(alerts): the host's temperature and throughput can drive alert rules (#5787)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m23s
CI / unit (push) Successful in 1m30s
CI / publish (push) Successful in 1m23s
temp_c_max was recorded on every ingest, but host_agent didn't offer it
to rules, so "host running hot" couldn't be watched. The catalog guard
didn't see the row(...) helper ingest builds its rows with. Now offered:
temp_c_max, temp_c per sensor, and network and disk throughput per host,
interface and disk. A new guard holds every literal row(...) name to the
catalog, except NOT_ALERTABLE, where each exclusion is given a reason.
temp_c_max reads in °C.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 22:20:11 -04:00
bvandeusenandClaude Opus 5.5 685850e681 feat(traefik): each router problem says what it means, whether it's real, and what to do (#5712)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Failing after 1m33s
CI / publish (push) Skipped
#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
  couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
  499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.

The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:04:39 -04:00
bvandeusenandClaude Opus 5.5 200384e52d feat(host_agent): a group's updates and reboots on a weekly cadence, one host at a time (#5768)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m20s
CI / unit (push) Successful in 1m34s
CI / publish (push) Skipped
#5768 "A maintenance cadence: a group's updates and reboots, one host at a
time, on a schedule". A plan names an inventory group, a weekday and time
in a time zone, a window and a reboot policy (only when required by
default). At its slot a run takes the group's hosts in order and gives each
a #5767 maintenance job. The next host starts only when the last is back in
service: its agent is posting and, for a swarm node, the node is active and
every replicated service is at its replicas. A failure stops the run, and
no host is started once the window has passed. A host's latest failure
shows as host_agent/<host>/maintenance_failed, for an ordinary alert rule.
The page is Hosts → Maintenance.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:59:07 -04:00
bvandeusenandClaude Opus 5.5 78581c843e fix(host_agent): the maintenance-jobs migration waits for maintenance_windows, and the guards know the new table and action (#5767)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m10s
CI 9146 on 22b0be2:
- integration: host_agent_007 ran before core 0010 created maintenance_windows
  (its window_id foreign key), so the schema never built. It now
  depends_on 0010_maintenance_windows.
- unit: host_maintenance_jobs is named in the backup's EXCLUDED (run
  history, like ansible_runs and host_downtime), and the
  host_agent.maintenance audit action has its wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:45:07 -04:00
bvandeusenandClaude Opus 5.5 6a68bd75d9 feat(host_agent): a root helper the agent can start, for updates, reboots and drains only (#5766)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m12s
#5766 "A root helper the agent can start, for a fixed set of maintenance
actions and nothing else", milestone 514.

The agent runs unprivileged under NoNewPrivileges, so it can't update or
reboot its host. It now leaves a request in /var/lib/steward-agent/requests,
the one path its unit lets it write; steward-maint.path starts maint.py as
root, which reads it without following links, accepts only update, reboot,
drain or undrain, deletes it, and writes the outcome to
/var/lib/steward-maint/results. The agent (1.17.0) reports each new result
state once as a `maintenance` event, re-checks pending updates when an update
finishes, and says in its metadata whether the helper is installed.

The units and the helper are served from one copy at
/plugins/host_agent/files/<name> (a fixed list), and install.sh plus the
install, provision and update playbooks all fetch them, replacing three
inline copies of the unit. Update agent installs the helper on existing
hosts; the Agent panel says under Manage agent whether it is there.

Steward handing out jobs is #5767; the cadence is #5768.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:34:28 -04:00
bvandeusenandClaude Opus 5.5 210a35c956 feat(host_agent): a host says what updates it has waiting and whether it needs a reboot (#5765)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m20s
Step 1 of milestone 514 "Updates and reboots on a cadence, carried out by
each host's agent…": #5765 "The agent reports pending updates and whether
a reboot is required".

- Agent 1.16.0: an hourly background check (apt-check, apt list, or dnf -C
  with updateinfo and needs-restarting), each command bounded at 120 s, no
  root and no network. Debian's reboot-required marker and its packages
  are read every sample.
- Ingest records updates_pending, updates_security and reboot_required for
  rules, and keeps the newest on the registration (host_agent_005_updates).
- The Agent card leads with "Reboot required since …", then the updates
  waiting or "Up to date", with when the check ran. list_agents carries
  them too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:23:44 -04:00
bvandeusenandClaude Opus 5.5 a805e1d539 fix(host_agent): every Docker read is bounded, and the agent reports its own footprint (#5764)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m20s
CI / unit (push) Failing after 1m32s
CI / publish (push) Skipped
#5764 "The agent reads a container's whole log backlog into memory, on its
main loop, with no overall deadline". Docker03's 1.13.0 agent went silent
for a day at 2.1 GB and 46 h of CPU, then ignored SIGTERM.

- One shared reader, _docker_get: each answer has an overall deadline and a
  byte cap, not only a per-recv timeout. A trickling or endless body is cut
  off (15 s / 32 MiB for JSON, 5 s / 4 MiB for a log read).
- Container logs: since the cursor but only the newest 2,000 lines, all
  within 10 s. A note says when older lines were skipped, and a container
  whose answer is too big skips ahead to now instead of re-reading its
  backlog every interval.
- The agent reports its own RSS and CPU; Steward records agent_rss_bytes
  and agent_cpu_pct, so a runaway agent shows before it goes silent.

Agent 1.15.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 18:01:45 -04:00
bvandeusenandClaude Opus 5.5 b6c4ce510d fix(host_agent): a stalled NFS mount no longer freezes the agent (#5760)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m21s
CI / unit (push) Successful in 1m33s
CI / publish (push) Successful in 1m3s
#5760 "One stalled NFS mount freezes the whole agent: statvfs has no
deadline, so the host goes silent while it still answers ping". The agent
read every mount inline in its only loop; a hard-mounted share whose server
stops blocks statvfs in the kernel with no timeout. Each mount is now read
in a daemon thread under one 5 s deadline. One that misses it is sent as
stalled, and isn't probed again while its last probe is still blocked. A
mount that errors (a stale handle) is left out instead of emptying the list.

Steward counts them as mounts_stalled (alertable: "an NFS share stopped"),
and skips the fullest-disk figure when every mount stalled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:12:27 -04:00
bvandeusenandClaude Opus 5.5 7feb2b1ce2 fix(backup): host_downtime is history, left out of the backup (#5756)
CI / lint (push) Successful in 3s
CI / unit (push) Successful in 1m27s
CI / integration (push) Successful in 1m22s
CI / publish (push) Successful in 1m20s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:10:06 -04:00
bvandeusenandClaude Opus 5.5 c7ff5bc556 feat(host_agent): a reboot reads as a reboot, and lost contact as its span (#5756)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
#5756 "The agent says when its host rebooted and when it lost contact, so
downtime is recorded as what it was". Agent 1.14.0 posts events with the
next post that gets through: started (with the host's boot time), stopping
(on SIGTERM, with whether systemd says the host is going down) and
lost_contact (from its first unanswered post to the one carrying it).

Steward keeps them as the host's downtime (host_downtime): reboot,
agent_stop, unclean (a boot after the agent was last heard that nobody
announced) or lost_contact. Retried posts apply once.

An announced stop explains the silence for reboot_grace_seconds (Settings
-> Plugins -> host_agent, 10 minutes): seconds_since_seen reads 0 until
then, and the host's other rules go quiet with it (agents_away beside
#5721's registry). Past the grace, agent offline fires.

The host's Agent card lists the last 7 days of downtime. Rows are pruned
at data.retention_days.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 17:08:16 -04:00
bvandeusenandClaude Opus 5.5 5dd9956f35 feat(host_agent): a disk out of inodes reads as full (#5745)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m13s
VDNT-Util01's / ran out of inodes (956 free of 60.8M) with 274G of space
left; every file create failed and Steward's page read healthy at 66%.

- Agent 1.13.0 reports each mount's inodes (statvfs; a filesystem reporting
  none, like btrfs, is left without).
- Ingest records inodes_used_pct per mount, and disk_used_pct_worst is the
  fullest mount by space or inodes, so existing disk-full rules and the
  overview see it.
- The host's health strip, the Hosts widget and the host glance grade / by
  whichever is fuller and name inodes when they are ("Disk / 66% · inodes
  100%", "Inodes on / ... past its critical threshold").

Hosts pick up the inode figures once their agent is updated to 1.13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:51:46 -04:00
bvandeusenandClaude Opus 5.5 d0c563abd0 fix(alerts): one silent agent is one alert and one finding (#5721)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m8s
VDNT-Docker03's silent agent read as three overview findings (agent offline,
agent stale, the disk rule's no-data) and every drop emailed the disk rule's
silence and return on top of agent offline.

- The host agent registers its heartbeat (seconds_since_seen) and the
  sources it carries (host_agent, docker). While a host's agent is past its
  offline window, its other rules' no_data is recorded "while its agent is
  offline" and not emailed, nor is their quiet return; a return past a
  threshold still fires.
- The overview folds those rules under the host's agent-offline finding
  ("also silent with it: ..."), and a stale agent whose agent-offline alert
  is active is that alert's finding, not a second one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:49:29 -04:00
bvandeusenandClaude Opus 5.5 76d75961f8 fix(alerts): a silence ends when Steward hears of it, once (#5720)
An agent's flushed backlog brings samples hours old. The resume was stamped
at the first old sample's time, inside the silence it ended, and silence was
still judged by the backlog's sample times, so the series was marked silent
again between posts and resumed a second time. A resumed series now counts as
heard at ingest time, its events from that batch are stamped no earlier, and
the rest of the backlog reads as late samples.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:47:09 -04:00
bvandeusenandClaude Opus 5.5 7c23360bc7 fix(norms): templates grade by the cutoffs when handed no grade; the parts test reads inside transactions (#5742)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m11s
CI 9128: the host vitals and monitor templates required the new grades, so
callers (and tests) that render them without one failed; they now fall back
to status_class. The new integration test read outside begin(), which
autobegan the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:20:08 -04:00
bvandeusenandClaude Opus 5.5 f3433fe3ce feat(norms): hosts, containers and monitor response times are graded against their own normal (#5742)
CI / lint (push) Successful in 2s
CI / unit (push) Failing after 1m26s
CI / integration (push) Failing after 1m27s
CI / publish (push) Skipped
Host CPU, memory and load per core, container CPU and memory, and every
monitor's response time learn a normal per hour (milestone 513, System 182).
A reading past its fixed cutoff but within its normal reads healthy and says
what is usual at this hour; one well out of it keeps its grade and is a
metric anomaly. Disk, a monitor down and an agent offline keep their cutoffs.

- Each normal's floor, the smallest departure that counts, is a setting:
  Settings -> Thresholds -> Normals (norms.floor.*). Defaults: host CPU 10
  points, host memory 5, load per core 10, container CPU 10 points of one
  core, container memory 5, monitor response 100 ms; Traefik's 2-point 4xx
  and 50 ms p95 floors move there from constants. learn_normal takes a
  floor function (floor_setting) read at each pass.
- learn_normal(parts=False): a host learns, its cores don't.
- Ingest records load_pct (load_1m / cores x 100), the figure pages grade.
- Shared grading: anomalies.against / graded, worst_reading(normals=),
  hosts' host_grades, docker usage(thresholds=, norms=), monitors'
  latency_grade. Applied to host vitals, the Hosts widget, MCP overview,
  Docker usage, and the monitor list, widget and page.
- The monitor and container pages list their anomalies.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 16:17:04 -04:00
bvandeusenandClaude Opus 5.5 9f14620271 feat(traefik): routers are graded against their own normal (#5741)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m3s
#5741 "Traefik routers are graded against their own normal", milestone 513.

- A router's 4xx share and p95 learn their normal (learn_normal in the
  plugin's setup). Once a router has 5 days of history, a reading over the
  fixed cutoff but inside its normal is healthy, and the healthy table says
  it is usual for that router. One out of its normal is a warning that says
  what is usual, and a metric anomaly.
- The 5xx share is a failure: fixed cutoff always, whatever the history or
  traffic (System 182's ruling).
- A 4xx share over fewer requests than Settings -> Thresholds' new "Fewest
  requests to judge 4xx" (30 per 15 minutes) isn't graded on the page, and
  isn't judged for anomalies.
- Core: learn_normal(gate=(metric, least)) judges a key only while its
  resource's gate metric averages at least `least`; `least` may be a
  function, so it follows a setting. learn_normal(since=...) learns only
  from history after a moment: Traefik latency learns from #5755's
  per-scrape figures only.
- The Traefik page lists the routers' metric anomalies of the last 30 days
  (_anomaly_card.html). The dashboard widget keeps its why to problems.
- Thresholds and docs say when the cutoffs give way to a normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 15:48:34 -04:00
bvandeusenandClaude Opus 5.5 5a0e150789 refactor(docker): container CPU and memory live in the one metric store (#5752)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m18s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
#5752 "Container CPU and memory history lives in the one metric store;
docker_metrics and docker_metrics_hourly are retired". Container cpu_pct and
mem_pct already went to plugin_metrics for alerting; now mem_usage_bytes does
too, and every snapshot in an ingest (backlog included) is recorded at its
own time. The container history chart, Busiest Containers, a service's
history and the MCP container tools read plugin_metrics under resource
"<host>/<container>", rolled up and pruned by the shared metric retention.

docker_012_drop_metrics drops both tables. Their history is not copied
(lesson #5221 "A data migration's cost is set by the live table"); the
store already holds 7 days of container CPU and memory %. Docker retention
now prunes only lifecycle events, and Settings -> Thresholds has one
"Metric retention" card for every source.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 14:39:56 -04:00
bvandeusenandClaude Opus 5.5 5569f9934e feat(anomalies): a metric out of its normal is an anomaly to review (#5740)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m24s
CI / unit (push) Successful in 1m37s
CI / publish (push) Skipped
#5740 "A metric that departs from its normal is recorded as an anomaly to
review, and is findable after it ends", milestone 513.

- Every minute each learned key's last 15 minutes is judged against its
  hour's normal: above the usual hold x1.25 and by the metric's floor opens
  an occurrence, which ends when the level comes back.
- metric_anomalies holds one row per item to review; a repeat while it
  still waits joins it as an occurrence (metric_anomaly_spans).
- An ended row nobody reviews within anomalies.review_days (14, Settings ->
  Thresholds) lapses; an ongoing one never does.
- Learning skips every hour an occurrence covers unless its anomaly was
  marked normal: no normal is learned without review.
- Alerts -> Anomalies lists those to review first, then the reviewed and
  lapsed under day dividers; marking is operator/admin and audited.
- The Overview widget and get_overview warn of open ones and count those
  to review; the host page shows its own; the weekly report lists the
  week's and the lapsed; MCP list_anomalies and review_anomaly.
- Reviewed and lapsed rows are pruned at data.retention_days, and kept
  in backups like alert history.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:41:57 -04:00
bvandeusenandClaude Opus 5.5 4c84bc8560 feat(norms): Steward learns each metric's normal per hour of day, then week (#5739)
CI / unit (push) Failing after 0s
CI / lint (push) Failing after 0s
CI / integration (push) Failing after 1s
CI / publish (push) Skipped
A metric registered with learn_normal() gets, for every resource reporting
it, a usual hold (the 90th percentile across past days of each hour's p95)
and a usual peak for each slot:
- an hour of the day once 5 days of history exist;
- an hour of the week once history reaches back 3 weeks, so weekly jobs are
  learned too.
Each slot takes in the hours either side, so a job that runs a little late
stays normal. One SQL statement relearns everything hourly into
metric_norms, from the rollup plus the raw window not rolled up yet;
norms_at() reads the slot a moment falls in. Nothing is registered yet:
Traefik (#5741) and hosts (#5742) opt in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:48:44 -04:00
bvandeusenandClaude Opus 5.5 cbbf186f17 feat(metrics): each rolled-up hour keeps the level it held, its p95 (#5738)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m16s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m3s
plugin_metrics_hourly gains value_p95 beside value_avg and value_max. A
two-minute backup spike raises the hour's peak and leaves its p95 low; an
hour spent at that level raises both. Learned normals (milestone 513) need
that difference to call a recurring spike usual without calling the same
level held at another hour usual too. Hours rolled up before this keep a
NULL p95 and read as their average.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:45:42 -04:00
bvandeusenandClaude Opus 5.5 2119d20f18 fix(dashboard): widgets hold their place as content loads and the window resizes (#5713)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 1m8s
Wider than 12 columns, pack() took the first free spot for each widget's
content-fitted height, and that height changes as each widget's content
arrives and on every poll, so widgets traded columns and order as the page
loaded (CI 9107: test_dash_layout fails at 16, 18 and 24 columns on the old
pack()). Now columns and order are decided at the saved heights, from the
saved layout and the column count alone; each widget then drops at its
fitted height onto what is above it in its columns, so content only closes
gaps.

The dashboard moves widgets into place in two passes, below everything first
and then up, so Gridstack never pushes a not-yet-moved widget out of its
place on the way. The share view passes saved heights the same way.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:32:23 -04:00
bvandeusenandClaude Opus 5.5 81995051dc feat(traefik): keep each router's 4xx and 5xx responses by status code (#5711)
CI / lint (push) Failing after 2s
CI / integration (push) Successful in 1m19s
CI / unit (push) Successful in 1m30s
CI / publish (push) Skipped
The scraper folded traefik_service_requests_total{code} into two shares and
dropped the code, so a problem couldn't say whether it was scanners (404), a
login wall (401/403) or the app down (502/503/504). Each scrape now stores the
responses per router and code that occurred (traefik_status_codes, migration
traefik_006), pruned with the router metrics and left out of backups (System
180 ruling: Traefik history is not backed up). A problem card lists its codes
over the last 15 minutes, most first, or says none occurred, or that codes
aren't recorded yet.

A counter series new since the previous scrape now counts from 0 instead of
being skipped: Traefik creates a router's 502 series at its first 502, so a
first burst of 5xx read as 0% until it happened again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 11:07:19 -04:00
bvandeusenandClaude Opus 5.5 e42fde89f5 fix(prometheus): word the template audit actions; keep the list verdict's apostrophe out of an autoescaped expression (#5692)
CI / lint (push) Successful in 1s
CI / integration (push) Successful in 1m15s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m5s
Run 9093: test_every_logged_action_is_worded wanted the three
prometheus.template_* actions in ACTIONS, and the list's verdict wrote
"doesn't" as a Jinja string literal, which autoescape turns into &#39;.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:24:34 -04:00
bvandeusenandClaude Opus 5.5 bd762307e0 feat(prometheus): the widget template library: list, edit with live preview, paste in, upload, export, duplicate (#5692)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m16s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
Prometheus gains a "Widget templates" tab beside Targets. It lists the
built-in templates and an admin's own, any that no longer pass the checker
first. A template opens as its YAML beside a live preview drawn over a
chosen target exactly as the dashboard widget draws it (routes.draw, now
shared by both). An admin writes or pastes a new one, reads a .yaml upload
into the editor to look over, duplicates any template under a free name,
edits and deletes their own; anyone can export the YAML. Built-in
templates are read-only.

Custom templates are rows in prometheus_widget_templates (migration
prometheus_002_templates), kept in backups, keyed "custom:<id>" so a
rename keeps its widgets. Saving needs a template that passes the checker
and a name no other template has. Create, update and delete are audited
and link to the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:22:31 -04:00
bvandeusenandClaude Opus 5.5 5ca13f9278 feat(prometheus): the Prometheus dashboard widget, target + template (#5691)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m14s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m12s
#5691 "Generic Prometheus dashboard widget: pick a target and a template",
step 3 of milestone 508 "Prometheus widget templates".

- /plugins/prometheus/widget draws one target with one template in widget
  shape: the verdict (led by "isn't answering" when the target is down),
  then each block with problems first, figures beside a share bar or the
  last 24h of bars, and the healthy rest folding to "+N more healthy" past
  six. A missing target or template, or one that no longer checks, says so.
- library.py: bundled templates from plugins/prometheus/widgets/*.yaml,
  the picker's target and template lists, and the default title.
- widgets/fabled_archives.yaml: the first bundled template, against the
  series Archives reports (refined in #5693).
- Dashboard: a widget setting may take `options_from` (read when the picker
  opens) with an `empty` sentence; `default_title`; a Details link may name
  settings ("/plugins/prometheus/targets/{target}"). Prometheus is grouped
  with the integrations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 07:05:15 -04:00
bvandeusenandClaude Opus 5.5 4c546a9c4d test(backup): run 9076's three failures (#5673, #5674)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m13s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m12s
- The audit-wording guard now reads both branches of a conditional action
  ("backup.made" if ok else "backup.failed"), and its self-test covers that.
- The Backups save test no longer asserts the session's tz, which sign-in
  state sets, not the test.
- The restore integration test uses the app's own metadata. all_metadata
  without plugin dirs imports the `plugins` package as the tests do; loading
  by path under the loader's names is only for `steward restore`. Loading by
  path in the shared test process had mapped traefik and unifi tables a
  second time for later tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:27:24 -04:00
bvandeusenandClaude Opus 5.5 49799bd0b3 feat(backup): daily backups to an S3 bucket, Settings → Backups, and the guide (#5673, #5675)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m15s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
#5673 "S3 client and the backup job: settings, schedule, history, metrics":
- steward/backup/s3.py: SigV4 over httpx, path-style; put and list only
  (an append-only key is enough; retention is the bucket's). Signing is held
  to AWS's published examples.
- steward/backup/job.py: a 15-minute tick. The daily backup goes at the hour
  chosen on the reader's clock; a missed day is made up, and a failure is
  retried an hour later. History of the last 30 runs, audit
  (backup.made / backup.failed), and backup_age_secs / backup_bytes under
  "Steward itself", resource backup, for alert rules.
- Settings → Backups: verdict first, then the bucket (secret sealed; a
  blank box keeps it), the schedule, Test the bucket, Back up now, and the
  recent runs.

#5675 "Docs: backing up to Archives, and restoring": docs/core/backups.md.

Also: the audit page now words the Prometheus target actions from #5668,
which showed as codes, and a guard fails on any logged action without
wording.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:23:58 -04:00
bvandeusenandClaude Opus 5.5 357bb6480d fix(backup): restore finds plugin tables by path, as the app loads them (#5674)
Run 9075: the restore got through schema and data, then all_metadata's
`import plugins` failed in the image, where plugins/ is a directory the app
loads by path (PLUGIN_DIRS), not an installed package. It now loads each
plugin under the loader's own module name from the bootstrap's plugin
dirs, which `steward restore` passes. A plugin whose models are already
mapped under either name is skipped rather than mapped twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:23:58 -04:00
bvandeusenandClaude Opus 5.5 fe51451704 fix(backup): restore through psql, dropping pg_restore 17's transaction_timeout line (#5674)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m14s
CI / unit (push) Successful in 1m26s
CI / publish (push) Failing after 48s
Run 9074's smoke restore stopped at "SET transaction_timeout = 0": pg_restore
17 sends it, and the Postgres 16 server doesn't know the setting. Each
section is now written as a script (pg_restore --file=-), that one line is
dropped, and psql -v ON_ERROR_STOP=1 runs the rest, so any other error
still stops the restore. If psql stops, pg_restore is killed rather than
left blocked on its pipe.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:19:54 -04:00
bvandeusenandClaude Opus 5.5 f1f2cb02cc fix(backup): name ansible_target_groups (kept) and email_outbox_events (left out) (#5672)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m12s
CI / unit (push) Successful in 1m28s
CI / publish (push) Failing after 1m3s
The guard in tests/core/test_backup_contents.py caught both on run 9073.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:16:04 -04:00
bvandeusenandClaude Opus 5.5 b875160c40 feat(backup): steward backup and steward restore; CI restores a dump and boots on it (#5672, #5674)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m14s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
The backup file (#5672 "Backup file: pg_dump of config + audit + alert
history, the secret key and a manifest"): steward-<UTC>.tar holding a
pg_dump --format=custom with metrics', check results' and Docker/Traefik/
UniFi rows left out, the secret key, and a manifest. Every table is named
KEPT or EXCLUDED in steward/backup/contents.py, and a guard test fails on a
new one. The image gains postgresql-client-17 (approved, System 180).

The restore (#5674 "Restore: `steward restore` and a CI test that a dump
brings Steward back"): pg_restore in its three sections. Between data and
constraints, a kept row pointing at a left-out row gets what its foreign
key's ON DELETE says (alert_events.ansible_run_id is cleared). It refuses
a non-empty database unless --wipe, and puts the backup's key in
/data/secret.key. Migrations run on Steward's next start.

`steward` stays the server command: it is now a click group that serves
when given no subcommand.

The publish smoke step backs up the booted image's database, restores it
into a second one, checks a marker setting came back and plugin_metrics
is empty, and starts Steward on it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:14:04 -04:00
bvandeusenandClaude Opus 5.5 eea29123f9 fix(settings): a plugin's password is sealed at rest, and a blank box keeps it (#5671)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m13s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m2s
The UniFi password sat in plugin.unifi in plain text: SECRET_KEYS covers
whole settings, and a plugin's settings are one dict. Saving the plugin's
settings page also wiped it, since the password box is never filled back
in and its blank value was stored.

- A plugin field whose name holds password, secret or token is a
  credential (is_plugin_secret). set_setting seals it inside the dict,
  every read opens it, startup seals any plaintext left from before, and
  the undecryptable-secrets banner names it as plugin.<name>.<field>.
- The settings page uses the same test for its password boxes, and a blank
  box keeps the saved value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:06:31 -04:00
bvandeusenandClaude Opus 5.5 3cb2a95054 feat(prometheus): scrape any /metrics endpoint into Steward's metrics, with pages (#5668, #5669)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m12s
CI / unit (push) Failing after 1m27s
CI / publish (push) Skipped
A bundled prometheus plugin, on by default like the other protocol plugins.
Targets are rows edited on its page: address, bearer token (encrypted),
interval, keep/leave-out name patterns and a series cap. Steward does the
filtering; the endpoint serves everything (Archives' ruling).

- A 5 s tick scrapes due targets at once, each within a 30 s deadline, and
  writes the outcome to its row and its series through record_metrics, so
  alert rules can watch any of them.
- Gauges are stored as they are; counters (and a histogram's or summary's
  _sum/_count) as <name>_per_sec rates from the second scrape. Buckets and
  quantiles are skipped. A labelled series is stored under
  "<target>:k=v,…". Each target also records is_up, scrape_ms, series_kept
  and series_capped.
- Pages: targets not answering first, with why; a target's series grouped
  by metric with their newest values; a chart per series; the editor with
  Test, which scrapes once and says what would be stored.
- The counter-to-rate keeper moves to steward/core/counter_rates.py; SNMP's
  RateKeeper now scales its rate on top of it.
- metric_value reads Prometheus's _seconds as a span and _per_sec as a rate.
- The catalog guard treats an empty list as a dynamic source (snmp,
  prometheus), as the rule form does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 07:04:01 -04:00
bvandeusenandClaude Opus 5.5 9f26258c8e feat(core): one Prometheus text parser, with types; Traefik reads through it (#5667)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m11s
CI / unit (push) Failing after 1m27s
CI / publish (push) Skipped
steward/core/prometheus.py parses the 0.0.4 exposition into families keyed
by name, each with its declared TYPE, so a scraper can tell a counter from a
gauge. A histogram or summary owns its _bucket/_sum/_count samples. Label
values are unescaped, and commas or braces inside quotes stay part of the
value. NaN and +/-Inf are read, timestamps are ignored, and a malformed
line is skipped. Traefik's parse_prometheus is now a thin reshaping of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:55:24 -04:00