Commit Graph
147 Commits
Author SHA1 Message Date
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 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 3369288e1d test(prometheus): set the template's version line whatever it is, not only version: 1 (#5693)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m15s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 20s
Run 9095: with the Archives template at version 2, the library test's
"bad" copy no longer had a bad version, saved, and took the name the
real save then wanted.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 09:45:53 -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 ec3e5e7e0d feat(prometheus): fold state sets on the target page; forget series an endpoint drops (#5689)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m20s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 20s
#5689 "Prometheus target page: fold state sets, show epoch times, size
bytes", step 1 of milestone 508 "Prometheus widget templates".

- A state set (name ending in _state, a state label, every series 0 or 1;
  or the OpenMetrics form, the label named after the metric) reads as one
  row per thing it describes, naming the current state, the others on
  hover. fabledarchives_jobs{state} is a count and stays as it is.
- label_pairs / state_label / fold_states in service.py, for the widget
  templates to reuse.
- After a good scrape, series it no longer has lose their newest value
  (forget_gone): Archives' old build_info row read as current beside the
  new one. History stays; a failed scrape forgets nothing; alert rules keep
  their own last-sample time.
- Epoch times and _bytes sizes were already read by shown_value /
  metric_value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:11:24 -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 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 002181253e fix(prometheus): the list page is a section top; the scrape test builds its text without format(); docs (#5668, #5670)
- tests/test_breadcrumbs.py: the Prometheus target list is its section's
  own top page, like SNMP's, so it carries no trail.
- The integration test's exposition has label braces, which str.format read
  as fields: the fake scrape raised, the target was recorded down and no
  series landed. It substitutes the counter with replace() now.
- docs/plugins/prometheus.md: adding a target, filtering, how series are
  named and stored, alerting, and the Fabled Archives recipe.

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 57e1960dc1 feat(dashboard): an Alerts widget beside the Overview; the Overview's overflow links to Alerts and Monitors (#5510)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m16s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
The Alerts widget (/alerts/widget) is shaped like every widget: a verdict
("2 firing, 1 silent." / "Nothing firing: all 12 rules are quiet."), then
what is firing or silent with its reading now, what the rule fires on and
since when, then what someone acknowledged and who; a line when alert
email failed in the last 7 days or is retrying (outbox.alert_trouble),
linking admins to Settings -> System; the quiet rules counted, a covering
rule once. It sits beside the Overview in the recommended layout (6x4
each).

The Overview's "+N more" line pointed at the removed /status/; alerts now
link to /alerts/ and monitors to /monitors/.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:27:17 -04:00
bvandeusenandClaude Opus 5.5 defad2e98b feat(email): delivery you can see: each alert's email in history, outgoing email on Settings -> System (#5509)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m1s
#5509 "Delivery you can see: each alert's email state in history, and
outgoing email on Settings -> System" (milestone 501).

- Alerts -> History: an alert's email reads from the outbox (#5506):
  "gathering" with when it sends, "sending", "retrying" with the attempt,
  next try and the server's last answer, "sent", or "failed" naming each
  reader it didn't reach and why (outbox.delivery / email_states). Events
  from before the outbox keep what they recorded. The Notified column
  shows once an alert has a queued email.
- Settings -> System gets "Outgoing email": every email of the last 7
  days not yet sent, failures first, then the longest retrying, with
  recipient, kind, state and last error, and a count of what was sent.
  Failures and anything still retrying after 5 minutes lead the page's
  verdict (outbox.outgoing).
- get_alert_history (MCP) adds `email`: the same delivery state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:12:40 -04:00
bvandeusenandClaude Opus 5.5 249d22e41f test(email): the deleted-alerts outbox test stays in one event loop (#5507)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m15s
Run 8942 failed this test alone (118 passed): it read the row back in a
second asyncio.run, and the app's pooled connection belonged to the
first, closed loop. The behaviour held ("The alerts this email carried
were deleted"); the settled row is now read from attempt's own result.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:07:17 -04:00
bvandeusenandClaude Opus 5.5 9a3e7ddba2 feat(email): the report, invites, resets and test sends go through the outbox; only it talks SMTP (#5508)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m11s
CI / unit (push) Successful in 1m26s
CI / publish (push) Skipped
#5508 "Every other email through the outbox: weekly report, invites,
password resets, and the test buttons" (milestone 501).

- outbox.send_each queues one email per reader (claimed, so the sender
  doesn't take it too) and tries them at once, for a page that answers
  with what happened; outbox.said words the result. attempt takes a cfg
  for a test of the form's unsaved settings and returns the settled rows.
- Weekly report (scheduled and Send now) and invites: queued, tried at
  once; one the server couldn't take yet is retried for an hour. An invite
  that didn't go shows its link with the reason, as before.
- Password reset: queued in the reset link's own transaction; the sender
  delivers it on its next tick.
- Settings -> Notifications test email and a rule's Send test: queued and
  recorded with retry=False, tried once (System 156 ruling).
- Removed mail.send, notifications._send_email and _smtp_send_sync;
  dispatch_notifications is webhook only. A guard asserts only the outbox
  (and smtp_open itself) opens an SMTP connection, with a self-test.

Also fixes run 8941 (#5507's integration test): its Also-send-to
addresses were "@test", which is_real_address rightly drops; they are
example.com now.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 16:05:01 -04:00
bvandeusenandClaude Opus 5.5 5237ffc663 feat(alerts): alert email waits 2 minutes in the outbox and each alert records its outcome (#5507)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m13s
CI / unit (push) Successful in 1m25s
CI / publish (push) Skipped
#5507 "Alert emails through the outbox, gathered for 2 minutes"
(milestone 501). Replaces the in-memory core/alert_burst.py.

- outbox.hold_alert: an alert joins each recipient's held alert email, or
  opens one due 2 minutes later (System 156 ruling). A Postgres advisory
  lock keeps two alerts at once from opening two windows for one reader.
- email_outbox_events.alert_json (migration 0043) keeps the alert as it
  fired, so the email written when the window closes says what it said.
- write_held writes it then, in the reader's zone: the usual alert email
  for one alert, the list for several (outbox.alert_words).
- after_settle records on each AlertEvent, once every email carrying it
  has settled: email_sent, or email_error naming each reader it didn't
  reach and the server's reason.
- A held email survives a restart and goes out when its window closes.
- A refusal of a message's one recipient no longer repeats the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:59:17 -04:00
bvandeusenandClaude Opus 5.5 4e77ad0e7f feat(email): every email is a row in email_outbox until the server accepts it (#5506)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m15s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m30s
#5506 "Email outbox: a table of outgoing email and a sender that retries
until accepted, for up to an hour" (milestone 501).

- email_outbox (migration 0042) holds each message per recipient, its
  words, its state (held / pending / sending / sent / failed), attempts and
  the server's last answer. email_outbox_events links alert events for
  #5507.
- core/outbox.py: enqueue; a sender task every 15 s claims what is due
  (SKIP LOCKED, stale claims released), sends the batch over one
  connection with no session held, and settles each row:
  - accepted -> sent;
  - a 5xx refusal -> failed at once with the server's words;
  - no connection, a timeout, a 4xx -> retried after 30 s, 1, 2, 5, 10,
    15 min, the last try at the hour, then failed;
  - a test (retry=False) settles on its first answer.
  It runs on startup, so a row left by a restart goes out at boot.
- notifications.smtp_open is the one place a connection is opened.
- mail.message takes the html part as optional.
- cleanup ages email_outbox rows on the data window.

Senders move onto it in #5507 and #5508.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 15:53:53 -04:00
bvandeusenandClaude Opus 5.5 0b2fbf13fc feat(settings): Settings → System shows table sizes and what the last cleanup removed (#5469)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m12s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m4s
#5469 "Settings → System: table sizes and what the last cleanup removed".

- Storage: each table's size on disk (pg_total_relation_size, indexes and TOAST
  included) and Postgres's row estimate (reltuples), largest first, the 15
  largest by name and the rest summed, under the database's total size. Read
  from the catalog only, never by counting rows.
- Last cleanup: when it ran, how long it took, and what each pass removed (each
  aged table, run log files, the metric and Docker rollups, the log ring), or
  "Nothing was past its window". run_cleanup now returns that summary and keeps
  it as the cleanup.last_run setting, as reports.last_sent_at does; cleanup_secs
  (#5467) is recorded from the same figure.
- A hint links to where each retention window is set.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:29:55 -04:00
bvandeusenandClaude Opus 5.5 a81b578247 feat(mcp): search_container_logs takes a Swarm service's name and searches every task it has run (#5468)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m9s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 15s
#5468 "Log search by service name: searching "steward_app" refuses because every
deploy makes a new container name".

search_container_logs(container="steward_app") was refused, because no container
carries the service's own name and five task containers partly matched. Now a
service's name reaches every task container Steward holds anything for, live and
removed (docker_svc.service_containers, as the service page's logs already do),
so one search spans deploys. Each match carries the slot and task_id that wrote
it, and the reply names the service and how many tasks were searched. A container
named exactly what was asked still takes precedence. A miss that partly matches
task containers now names their services.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 14:21:56 -04:00
bvandeusenandClaude Opus 5.5 656e127fd8 perf(metrics): a day of one metric reads from the index alone, built after boot (#5490)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m15s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m3s
#5490 "A day of one metric reads fast even cold". A cold 24 h series read
measured 18.3 s against 63 ms warm (#5485): every sample's row sat on its own
heap page. ix_plugin_metrics_series_value carries `value` (INCLUDE), so the
read is an index-only scan; it replaces the plain series index from 0023.

It is built once, with CREATE INDEX CONCURRENTLY from a task started after the
app is serving, never in a migration, so the boot isn't held while it builds
over the live table (#5220) and ingest and pages carry on. A build cut short
by a restart leaves an INVALID index; the next boot drops and rebuilds it.
Later boots find it present and only check. Postgres keeps it current on each
insert, as it did the index it replaces.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:23:46 -04:00
bvandeusenandClaude Opus 5.5 57e040f1d0 feat(mcp): update_group_members puts hosts into an inventory group or takes them out (#5487)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m9s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m19s
#5487 "MCP: edit inventory group membership". Group membership decides which
hosts a group alert rule covers, and the MCP could only read it. The new
operator tool takes a group and lists to add and remove, each a target's
name or id or the Steward host it is linked to; an unknown name refuses the
whole change. It answers with the group's targets after and the alert rules
covering the group (whose per-host rules follow within a minute), and audits
as ansible.group.updated by "<user> via MCP", as the Inventory page does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:48:08 -04:00
bvandeusenandClaude Opus 5.5 73cfbda1da fix(alerts): a rule's page doesn't wait for its metric preview (#5485); Send test up top (#5486)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m26s
CI / publish (push) Successful in 1m5s
#5485 "A rule's page waits up to 25 s for its metric preview: a cold 24 h
series read from plugin_metrics". Opening "Agent offline" took 25.6 s; a
cold day of one series measured 18.3 s over MCP, a warm one 63 ms. The
pickers still come with the page from plugin_metric_latest; the preview is
its own request (hx-trigger=load), showing "Reading the last day…" until it
lands. Picker swaps no longer read it either.

#5486 "Send test where it can be found": the button moves from inside "What
happens" to the page header beside History, its result under the verdict.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:44:19 -04:00
bvandeusenandClaude Opus 5.5 d19df51ffb feat(alerts): pick the host, then its part, and see what the metric reads (#5475)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m8s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
#5475 "Rule form: pick the host, then its part; metrics that part reports;
a link to groups; a preview of the metric", from the operator's review of
the New rule page: hosts and their parts shared one long picker.

- Host agent rules pick a Host, then a Part: "The whole host", then its
  mounts, CPU cores, disks, interfaces and sensors under a heading each,
  cores in number order. A part that reports nothing a rule can watch
  (an interface's traffic) isn't offered.
- The Metric list holds what the picked resource reports, in catalog order.
- Under it, a preview: the reading now, and the last 24 h low to high with
  an hourly sparkline drawn from zero (UI ruling 8), read from one series.
- The group picker links to Ansible > Inventory to make a group, in a new
  tab, and re-reads the groups when the tab comes back into view.
- Picking a host or part swaps the pickers alone (/alerts/api/rule_target);
  picking a metric swaps the preview (/alerts/api/metric_preview).
- A rule's own resource and metric stay on offer after they go quiet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:54:08 -04:00
bvandeusenandClaude Opus 5.5 d5601575d8 test(alerts): seed metrics the way ingest does, through record_metrics (#5473)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m37s
CI / publish (push) Successful in 1m11s
#5473 "Opening an alert rule takes up to 15 s". With the rule pickers now
reading plugin_metric_latest, nine integration tests failed on rule_fields:
"host_agent has no resource 'box-…'". They seeded a resource by adding a raw
PluginMetric row, which no production path does: host-agent ingest and
record_metric both go through record_metrics, which keeps the latest-value
table. The seeds now go through record_metrics too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 09:28:53 -04:00
bvandeusenandClaude Opus 5.5 5ef18c992c feat(alerts): Send test on a rule checks that its alerts reach someone (#5466)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m9s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m6s
#5466 "Send test: check a rule's delivery from the rule". A rule's page gets
a Send test button (operator role), and MCP gets send_test_alert. Each sends
a TEST alert for that rule now, by email to everyone who gets alerts and by
webhook. The alert carries the rule's name and condition, at the latest
reading of what it watches; a rule covering hosts uses its first host. The
result for each channel shows under the button, or "Nothing is set up to
send to". Nothing fires, no event is written, and the test is audited as
alert_rule.tested. An alert with no reading now says "has no reading yet"
instead of "is None".

Docs: alerting.md also gains the burst email from #5465 "One email per burst
of alerts".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:33:32 -04:00
bvandeusenandClaude Opus 5.5 48a0de5600 feat(alerts): one rule can cover every host, or every host in a group (#5464)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m7s
CI / unit (push) Successful in 1m25s
CI / publish (push) Successful in 1m12s
#5464 "Group rules: one rule covers every host, or every host in a group",
step 2 of milestone 499 "Alerts you can trust". Each rule named one host, so
the live instance carried six rules for three hosts, and a new host had no
agent-offline or disk alert until someone copied them.

- alert_rules.covers / cover_group / parent_id (migration 0041). A rule on
  host_agent may cover "all_hosts" or a "group" (an inventory group: the
  Hosts ruling, #5294). A covering rule has no state; Steward keeps one
  ordinary child rule per covered host, named "<rule>: <host>", so each
  host's alert keeps its own state, history and acknowledgement.
- alerts.service.reconcile_children: a covered host that has reported in
  the last 7 days gets a child; a host no longer covered loses its child;
  children take the parent's condition, checks, notify setting, action and
  on/off. Run on save and each minute (alert_reconcile).
- A child is changed through its parent: edit, toggle and delete of a child
  are refused (409 / PartOfCoveringRule); its edit page opens the parent.
- Rule form: a Covers choice for host sources, with the group picker; fixed
  once the rule exists. Rules list: a covering rule reads "every host in
  swarm · 3 hosts" with its hosts' rules nested under it, each opening its
  history. The history picker and the rule count leave covering rules out.
- MCP: create_alert_rule takes covers/group; update_alert_rule takes group
  and passes changes down; list_alert_rules shows covers, hosts and part_of.
- docs/core/alerting.md: "Rules that cover hosts".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:25:51 -04:00
bvandeusenandClaude Opus 5.5 734d313a54 feat(alerts): an acknowledgement is on the record, with who made it (#5463)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m7s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m8s
#5463 "Acknowledgements on record: an event and who did it", step 1 of
milestone 499 "Alerts you can trust". The live walk in #5461 found that
acknowledging changed only the alert's current state: once it resolved,
nothing said who had acknowledged it or when.

- alert_events.actor_id (migration 0040): the person behind a transition.
- Acknowledging writes a firing → acknowledged event by the acknowledger.
- A rule edit, disable or enable that resets an alert carries its actor
  (web and MCP alike).
- The history page notes "acknowledged by X" and "rule disabled by X"; MCP
  get_alert_history returns `by`.
- One usernames() helper in alerts.service, used by the active list, the
  history page and the MCP tools.
- docs/core/alerting.md: the state diagram no longer shows an acknowledged
  alert re-firing, and says what an event records.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 08:19:48 -04:00
bvandeusenandClaude Opus 5.5 b3a5bbfeff perf(docker): the hourly log prune ranks only the containers over their cap (#5449)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m30s
CI / unit (push) Successful in 1m42s
CI / publish (push) Successful in 1m52s
#5449 "The hourly Docker log prune ranks every log line in the table to find
the few over their cap": the size ring put a running-total window over every
row of docker_logs each hour, sorting the whole table on NFS-backed storage
to find the handful of containers past docker.logs.max_bytes_per_container.

- prune_docker_logs (capability docker.prune_logs) does the age cutoff, then
  a per-container byte total picks the containers at or over the cap, and
  only their rows are ranked and trimmed. Same keep rule as before: the
  newest line always survives.
- run_docker_retention keeps the metrics rollup and the event prune.
- Cleanup runs the log ring in its own transaction after them.

Arose from #5431 "Ingest dropped for ~3 minutes after each deploy while
startup retention runs".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:31:00 -04:00
bvandeusenandClaude Opus 5.5 4a33046aef fix(ansible): Run now runs the playbook and leaves the schedule's time alone (#5445)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m51s
CI / integration (push) Successful in 1m45s
CI / publish (push) Successful in 2m21s
Run now set last_run_at to the click, and a schedule's next turn counts
from last_run_at, so a manual run of the nightly prune at 14:00 moved
every later run to 14:00. Operator ruling: "run now shouldn't change the
schedule it should just run the playbook and leave the schedule as it
was."

Run now no longer touches last_run_at. It still records the run as
last_run_id, so the list shows it and a firing during it is skipped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 22:08:18 -04:00
bvandeusenandClaude Opus 5.5 8edc0c30a3 fix(mcp): get_monitor says how often a monitor on the global interval runs (#5433)
check_interval_s was `m.check_interval_seconds or None`, so a monitor
following monitors.poll_interval_seconds read as null and a reader
couldn't tell how often it ran. Monitor results now carry the effective
interval and `interval_from: "monitor" | "global"`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:50:20 -04:00
bvandeusenandClaude Opus 5.5 480b465b8e fix(alerts): a series that comes back gets one history event, not two at one instant (#5442)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m29s
CI / unit (push) Successful in 1m41s
CI / publish (push) Successful in 1m25s
When a silent series' sample also moved the rule (fired, resolved), the
resume and the transition were two events: the resume from the
pre-evaluation state at ingest time, the transition at the sample's
time. Ties sort by a random id, and the resume's "→ inactive" landed
after "→ firing", so the history read "silent, firing, back to normal"
at once and the episode closed while the alert was firing.

_track_samples now only clears the mark and returns its note; the first
sample's evaluation records one event from no_data to wherever that
sample took the rule ("firing" or "resolved" notification), or back to
the state it was in ("data_resumed", still only when notify_no_data).

Also fixes the #5441 integration test: its rule needs a recorded
resource to target (CI 8878).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:48:43 -04:00
bvandeusenandClaude Opus 5.5 a9bf4b7f16 fix(alerts): Steward's own outage no longer reads as every agent going silent (#5441)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m26s
CI / unit (push) Successful in 1m31s
CI / publish (push) Skipped
After Steward or its database was down, every agent's age and every
series' last sample were old, so all agent-offline rules fired and all
rules went no-data at once, clearing a minute later. With per-account
alert email that would have mailed everyone a dozen notices per restart.

A periodic job now ticks a Watch (steward/core/watch.py) once it has
reached the database; the Watch says since when runs have been unbroken
(process start, or the end of a gap over 120 s). seconds_since_seen and
the no-data sweep count silence from no earlier than that, so a source
must stay quiet while Steward is watching before it counts as silent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 21:45:47 -04:00
bvandeusenandClaude Opus 5.5 593b583d23 feat(auth): reset a forgotten password from an emailed link (milestone 497)
CI / lint (push) Failing after 3s
CI / integration (push) Successful in 1m37s
CI / unit (push) Failing after 1m43s
CI / publish (push) Skipped
Step #5429 "Reset by email: request, link and new-password page" of
milestone 497 "Passwords: change your own, and reset it by email".

- Sign-in page: "Forgot your password?" while a mail server is set.
- /forgot takes a username or email and answers the same either way; only
  an active local account with a real address gets mail. Requests count
  per address on their own throttle, so they never lock out sign-in.
- password_resets (migration 0039): a one-time key, stored as its sha256,
  working for an hour; asking again replaces it.
- /reset/<key>: the new password, then signed straight in; every other
  browser is signed out (sessions_since). Bad keys count as invite keys do.
- Email "[Steward] Reset your password", in the account's own zone.
- Audited: user.password_reset_requested, user.password_reset_by_email.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:17:24 -04:00
bvandeusenandClaude Opus 5.5 ea523b8c37 feat(account): change your own password on the Account page (milestone 497)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m10s
CI / unit (push) Successful in 1m31s
CI / publish (push) Successful in 1m26s
Step #5428 "Account page: change your own password" of milestone 497
"Passwords: change your own, and reset it by email". A Password section
in the page's one form, for local accounts: current, new, again. Blank
keeps the password; a new one signs out every other browser
(sessions_since) and keeps this one. A wrong current password counts
toward the login throttle. Audited as account.password_changed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 20:14:45 -04:00
bvandeusenandClaude Opus 5.5 ea31deabf9 fix(unifi): the Overview's history buckets on Postgres; devices name their state (#5207)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m24s
CI / unit (push) Successful in 1m35s
CI / publish (push) Successful in 1m29s
#5207 "UniFi Overview tab: func.strftime is SQLite-only": the WAN and
client sparklines bucketed with strftime('%s'), which Postgres lacks, so
/plugins/unifi/rows failed. Traefik's date_bin bucket moves to
core/time_range.time_bucket and both plugins use it. Devices list offline
and failing first, and a state other than online is named (Upgrading,
Adopting, Heartbeat missed...) instead of drawn as Down.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:28:19 -04:00
bvandeusenandClaude Opus 5.5 2995cfdd68 fix(users): a password reset signs out every session begun with the old password (#5427)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m7s
CI / unit (push) Failing after 1m24s
CI / publish (push) Skipped
#5427 "A password reset doesn't end the sessions already signed in with
the old password": users.sessions_since (migration 0038) is set on reset;
refresh_session_user ends a session whose login_at is earlier. An admin
resetting their own password carries on signed in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:25:35 -04:00
bvandeusenandClaude Opus 5.5 fb4cf098c3 test(invites): match the escaped apostrophe; give test admins a real address
CI / lint (push) Successful in 6s
CI / unit (push) Successful in 1m43s
CI / integration (push) Successful in 1m17s
CI / publish (push) Successful in 1m41s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:00:13 -04:00
bvandeusenandClaude Opus 5.5 2b6e12859a feat(users): invite by email — a one-time link where the invitee makes their own account (milestone 496)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m17s
CI / unit (push) Successful in 1m43s
CI / publish (push) Skipped
The operator's ask: an invite link sent by mail, the key in the URL, and
the new user choosing their own username, password and email.

- user_invites (migration 0037): the key's sha256 only, the address, the
  role and days the admin chose, who sent it, used/revoked.
- core/invites: create (revokes an older open invite to the address),
  renew (new key, new clock), find_pending, the four states, signup checks.
- /invite/<key>, outside sign-in and throttled like login: the invitee's
  username, email (prefilled, theirs to change) and password; the account
  takes the admin's role, the key is used up in the same transaction and
  they are signed in. A bad key gets one plain message.
- Settings › Users: Invite by email (address, role, 1/7/30 days), sent from
  the admin in their zone; without a mail server, or if sending fails, the
  link is shown once to copy. Invites not yet used are listed with Resend
  and Withdraw.
- Audit: user.invited, user.invite_resent, user.invite_revoked, user.joined.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:54:44 -04:00
bvandeusenandClaude Opus 5.5 ac38a04642 test(account): count the page's own forms, not base.html's
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m3s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m34s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:11:18 -04:00
bvandeusenandClaude Opus 5.5 beae8a33f3 fix(account): one form and one Save for your account; the same for an account's page in Users
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m8s
CI / unit (push) Successful in 1m36s
CI / publish (push) Skipped
Operator review of the Account page: two sections with two different
save buttons, one stuck to the next card and one beside a squeezed hint.
- Account: time zone and Email are one form; POST /account/ saves both,
  nothing unless all of it is valid. /account/email is gone.
- Users › account: Name and Email are one form; the rename route also
  saves the email switches. /settings/users/<id>/email is gone.
- The Email section is a plain section now (email_section); its hint sits
  under the switches, names the zone as the picker does, and links
  Notifications only for an admin.
- apply_email_form leaves a switch the form doesn't carry as it is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 16:05:41 -04:00
bvandeusenandClaude Opus 5.5 31e32c173c test(settings): the adopt-zone test gives the account its zone — the session copy is refreshed from it
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m38s
CI / unit (push) Successful in 1m52s
CI / publish (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:39:47 -04:00
bvandeusenandClaude Opus 5.5 7ae6f85032 fix(settings): Notifications opens on first visit — no second begin on an autobegun session
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 59s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
Reading the settings autobegins the session's transaction, so the
nested db.begin() that saves the first admin's zone as smtp.tz raised
InvalidRequestError and every visit 500'd until a zone was saved. The
integration test signed in without a zone, so that path never ran;
it now has a test of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:34:19 -04:00
bvandeusenandClaude Opus 5.5 22090b02d8 feat(email): each account gets its own email, in its own zone (milestone 495)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m0s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m32s
Milestone 495 "Email goes to each account, in its own time zone", on the
operator's ruling (System 156): notifications go to each user, times in
their zone, defaults on, toggled per user or per role.

- users.notify_alerts / notify_report (migration 0036): None follows the
  role's default, notify.<kind>.<role> in settings, all on to start.
- mail.pick_recipients: active accounts that want the email and have an
  address mail can reach (an SSO placeholder like name@oidc is skipped and
  flagged), each in its own zone with no UTC beside it, then the Also send
  to addresses in their chosen zone with UTC beside. One copy per address,
  all over one SMTP connection.
- Alerts, the weekly report and the test email (to you, in your zone, plus
  the typed addresses) send that way. The save-time smtp.tz capture is gone.
- Notifications: a role × email table of defaults, who each email reaches
  and who can't be reached, and Also send to with its own zone, which starts
  as the first admin's to open the page.
- Account page: your address and your two switches. Users: an Emailed
  column, and an admin sets an account's switches on its page.
- The Alerts page counts the accounts it emails. zone_select macro and
  formatting.zones_by_region shared by Account and Notifications.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:16:17 -04:00
bvandeusenandClaude Opus 5.5 c533eec33a feat(settings): Users page — list, add, rename, role, password, disable, delete (#5336)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m31s
CI / integration (push) Successful in 1m3s
CI / publish (push) Successful in 1m21s
#5336 "Users page: list accounts, change role, disable, reset a local
password, create a local user".

- Settings › Users (admin only, a Settings tab): every account with how it
  signs in, its role, last sign-in (from the audit log) and working AI
  tokens; disabled accounts last. A row opens the account.
- An account's page: rename (any account), role and password (local
  accounts; an LDAP or single sign-on role comes from the provider at
  every sign-in, and the page says so), disable or enable, and Delete
  behind the confirm dialog.
- Refusals are said beside the control: nobody disables, demotes or
  deletes themselves, the last active admin stays, and an account that
  made alert rules, maintenance windows or playbook runs is disabled
  rather than deleted (those records keep who made them).
- Add user: a local account with a role and a password of 8+ characters.
- Every change is audited as user.*, and the audit log names and links the
  account.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:12:56 -04:00
bvandeusenandClaude Opus 5.5 ec55faf737 feat(email): alert and weekly-report emails read as designed messages (#5335)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m22s
CI / unit (push) Successful in 1m49s
CI / publish (push) Successful in 1m13s
#5335 "Emails Steward sends read as designed messages: an HTML alert email
and weekly report, with a plain-text twin".

- core/mail.py renders each email as multipart/alternative from
  templates/email/: a table-and-inline-style HTML part and a plain-text
  twin carrying the same facts. Colours are the design system's light
  theme, resolved from theme.css; nothing is fetched when a mail opens.
- Alert email: state badge, a headline sentence ("Disk used on web01 is
  94%."), rule, resource, reading, the condition in words, and the time in
  the zone Notifications was saved in with UTC beside it; "View in
  Steward" when Steward's address is set. Subject "[Steward] Firing: <rule>
  — <resource> 94%". Alerts now carry their operator.
- Weekly report: monitors down, active alerts, the week's alert events,
  uptime and busiest Traefik routers as small tables with links. Sent from
  the configured From address; the "Generated … UTC" line and the stale
  repo footer are gone.
- condition_text() states a condition from a rule's parts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 14:08:05 -04:00
bvandeusenandClaude Opus 5.5 501a345aed test(snmp): read and remove the poll test's rows in one transaction (milestone 491)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 58s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m34s
Run 8790: a select opened the session's transaction before begin().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 11:01:08 -04:00
bvandeusenandClaude Opus 5.5 189b492397 test(snmp): the poll test reaches the plugin as the app loaded it (milestone 491)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m6s
CI / unit (push) Successful in 1m41s
CI / publish (push) Skipped
SNMP is on by default, so the loader has already mapped snmp_devices under
_steward_plugin_snmp; importing plugins.snmp.models defined the table twice
(integration lane, run 8788).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:57:16 -04:00
bvandeusenandClaude Opus 5.5 9ca2e38fa1 feat(snmp): devices you add, test and read on the SNMP page (milestone 491)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 55s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
The operator, reviewing SNMP on #5214: "this system looks incomplete like
there's something missing from it". The one device shown was plugin.yaml's
example (core-switch 192.168.1.1, community public), polled every minute;
devices could only be defined by editing YAML in the image; a failed poll was
logged at DEBUG and stored nowhere, so the page said "waiting for the next
poll" forever; v3 ran as v2c; counters showed as lifetime totals.

- Devices are rows (snmp_devices, migration snmp_001): address, port,
  v1/v2c community or a v3 user with auth/priv protocols and keys (secrets
  encrypted), presets plus custom readings, an optional host binding, and
  the newest poll's outcome. The YAML device list and its example are gone.
- The SNMP page adds, edits and deletes them (admin, audited, with
  get_audit_entities so the audit log names and links a device). Readings
  come from presets (system uptime, interface traffic by number, UPS-MIB,
  Linux net-snmp) and custom "OID name [unit]" lines.
- One GET per device with every OID, devices polled at once. The outcome is
  written to the row in words ("No reply from … within 5 s. Check the address
  and port, that SNMP is on, and the community"), with readings it lacks.
  v1's whole-request error drops that OID and asks again. SNMPv3 signs in
  with USM (MD5/SHA-1/SHA-2, DES/3DES/AES).
- Test asks the unsaved form's device for its name, description, uptime and
  interfaces by number. It is an admin's explicit action; every page reads
  only stored rows (System 178's ruling).
- Each reading has a unit; a counter is stored as a per-second rate (bytes as
  bit/s), a reset or wrap skipped. The page, widget and host panel say values
  in units and why a device is silent; the device page draws a chart per
  reading on a dated axis.

Tests: readings and presets, the form and its secrets, v3 checks, rates,
the poller against a stand-in pysnmp (one GET, v1 retry, timeout wording,
USM), the pages, and an integration test of the poll writing outcomes and
rates on Postgres.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:54:11 -04:00