Commit Graph
247 Commits
Author SHA1 Message Date
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 24700ee608 feat(settings): Settings brought into line with the rest of Steward (milestone 493)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 56s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m9s
The last page of #5214's walk. Settings still described Steward as it was
before monitors were unified and SNMP was rebuilt, drew its tabs below the
breadcrumb, and showed the report schedule in UTC.

- Tabs (#5377): settings/_tabs.html is a settings_tabs() macro called in
  each page's section_tabs block, above the trail, as Ansible's are. The
  breadcrumb guard also fails any page that {% include %}s a tab strip.
- Report time (#5378): reports.schedule_tz keeps the zone of whoever saved
  the schedule; check_and_send compares in it (report_due), and the form shows
  the day and hour on the reader's clock (schedule_in) under their zone's
  name. "Last sent" uses |when.
- Wording (#5379): the report contents list what the report holds (monitors,
  not ping-enabled hosts); "Ping latency" is "Response time" and says what it
  grades; plugin detail says enabling loads now and disabling needs a
  restart; the monitor interval says it applies on the next check (the
  scheduler reads it every pass); the dead list-config branch is gone.
- Traefik (#5380): the 5xx and 4xx cutoffs are settings
  (traefik.threshold.error_5xx_pct 0.1, error_4xx_pct 1.0) in a "Traefik
  routers" group on Thresholds while the plugin runs, read by router_state
  through error_cutoffs(). The Traefik page has a Thresholds button for
  admins, to /settings/thresholds/#traefik.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:28:43 -04:00
bvandeusenandClaude Opus 5.5 263560cc94 fix(migrations): 0035's revision id fits alembic_version (32 chars) (#5374)
CI / unit (push) Successful in 1m35s
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m17s
CI / publish (push) Successful in 1m25s
"0035_drop_plugin_catalog_settings" is 33 characters, and
alembic_version.version_num is VARCHAR(32), so the upgrade failed with
"value too long" (CI run 8800, integration). Renamed to
0035_drop_plugin_catalog. tests/test_migration_ids.py now checks every core
and plugin revision id fits, in the unit lane, which never migrates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:20:11 -04:00
bvandeusenandClaude Opus 5.5 f035a3b14a fix(snmp): a UPS on mains reads "On battery: No, on mains", not "0s" (#5214)
UPS-MIB upsSecondsOnBattery is zero whenever the UPS is on mains power, so
"On battery for 0s" read as a timer that had just started. The reading gets
its own unit, battery_seconds: zero says "No, on mains", anything else
"for <span>". The metric key stays on_battery_for, so stored history keeps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:16:24 -04:00
bvandeusenandClaude Opus 5.5 f67678998c refactor(plugins): no plugin catalog; plugins ship in the image or are mounted (#5374)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 1m12s
CI / unit (push) Successful in 1m25s
CI / publish (push) Skipped
First-party plugins have shipped inside the image since a7a281c, and the
catalog's only repository (Steward-plugins) answers 404, so Steward polled a
dead URL every minute for nothing. The operator chose to remove the catalog
entirely (#5214 "Review: readable and fast on the live instance").

Removed:
- core/plugin_index.py and the plugin_catalog_refresh startup task;
- the plugins.index_url default, and migration 0035 deletes the stored
  plugins.index_url / plugins.repositories rows;
- the Settings -> Plugins repository editor, catalog, refresh, install and
  reload routes and their templates;
- the download/checksum/extract install path in plugin_manager, and the
  PLUGIN_INSTALL_DIR config it wrote to;
- plugins/index.yaml, docs/plugins/index.yaml.example and the guide's
  "Publishing to the Catalog" section (now "Shipping a Plugin").

Kept: enabling and configuring plugins, hot-reload on enable, restart, orphan
cleanup, and the external root (/data/plugins) for operator-mounted plugins.
tests/test_no_plugin_catalog.py guards the routes, default and task.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:15:55 -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
bvandeusenandClaude Opus 5.5 de3958a362 fix(settings): the plugin catalog is fetched by a scheduled task, never by a page load
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 1m32s
The operator's ruling (Background Jobs, 2026-10-08): a Steward page reads
stored records; the sources are contacted by scheduled collection. An audit
of every GET handler, HTMX fragment, widget poll and MCP read tool found one
page load that reached out: Settings -> Plugins' catalog fragment fetched
each plugin repository (5 min cache, 10 s timeout each, retried on every
load when unreachable).

plugin_catalog_refresh ticks every minute and fetches a repository whose
copy is missing or older than an hour, waiting 5 min after a failure;
saving repositories clears the cache, so the next tick fetches. The
fragment only reads the cache and says which repositories are not fetched
yet. Refresh is now a POST that fetches at once, as does install.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 10:09:52 -04:00
bvandeusenandClaude Opus 5.5 5f04f6af66 fix(traefik): the newest scrape per router comes from plugin_metric_latest (#5355)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m6s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 1m28s
The phase log on the live instance: 'traefik rows: slow 7.4s (latest=7.04s
history=0.11s window=0.04s ...)'. latest_per_router walked traefik_metrics'
index one router at a time; each step lands on the router's oldest
entries, which retention deletes every minute (likely dead ones). The
scraper already records every router figure through MetricBatch, which
keeps one row per router and figure in plugin_metric_latest (#5208's
table, read as latest_by_resource does); latest_per_router reads that
now, and the router figures carry the scrape's own time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:59:48 -04:00
bvandeusenandClaude Opus 5.5 18867c8a50 fix(traefik): bandwidth and 4xx sparklines; cards in a grid with a gap; slow rows/widget log their phases (#5355)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m8s
CI / unit (push) Successful in 1m32s
CI / publish (push) Successful in 2m1s
The operator: "load was still slow" (the access log: 7.6-8.0 s on every
poll, warm), "why do the bandwidth and 4xx sections not also get
sparklines", and "no gap between the widgets". router_history reads the
4xx share and bandwidth too, and the card draws all five lines. The
problem cards sit in .node-grid with a bottom margin; a CSS column box
dropped the last card's margin. rows() and widget() log each phase when a
request passes 1 s, so the slow query names itself on the next load.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 09:23:48 -04:00
bvandeusenandClaude Opus 5.5 efa1a5f4f6 test(audit): the day-group fixture is a def, not a lambda (ruff E731)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m14s
CI / unit (push) Successful in 1m29s
CI / publish (push) Successful in 1m56s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:32:40 -04:00
bvandeusenandClaude Opus 5.5 7ce61a2099 fix(traefik): grade routers on their last 15 minutes; the Config tile reads Traefik's reload time (#5355)
CI / lint (push) Failing after 2s
CI / integration (push) Successful in 54s
CI / unit (push) Successful in 1m24s
CI / publish (push) Skipped
The operator: problem cards "can disappear so quickly". Each router was
graded on its latest scrape, so a 4xx share near the 1% cutoff crossed it
on one scrape and fell back on the next. recent_window reads every
router's last 15 minutes in one query, the error shares weighted by
traffic; grade_routers uses it on the page and the dashboard widget, and
the why says "over the last 15 min".

traefik_config_last_reload_success is the Unix time of the last good
reload, not a 0/1 flag, so the tile said "Reload failed" on every scrape.
traefik_005 stores it as config_last_reload_at, and the tile reads
"Config reloaded 3m ago". The router index also carries the 4xx share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:26:10 -04:00
bvandeusenandClaude Opus 5.5 7a444da871 feat(audit): every row keeps its date; dividers for Today, Yesterday, Last 7 days, Older (#5214)
The operator found "Today 08:03" in place of a date odd on the audit log,
and asked for the recency groups as dividers instead. |when now always
writes the date ("Thu 8 Oct 08:03"); mark_day_groups gives each entry its
group in the reader's zone, and a divider row opens each group. A scrolled
page carries the group the last one ended in, so it is not opened twice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:22:03 -04:00
bvandeusenandClaude Opus 5.5 31e93e96cf fix(traefik): the rows page reads every router's history in one index-only query (#5355)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m18s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 2m3s
#5355 "Traefik page doesn't load": /plugins/traefik/rows took 12-28 s.
It ran a query per router ever seen, and each raw row in the range was a
heap fetch. traefik_004 rebuilds the router index to cover the charted
columns and indexes scraped_at for the request chart; router_history reads
the current routers' buckets in one query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:20:09 -04:00
bvandeusenandClaude Opus 5.5 96c1ea9407 feat(host_agent): how far each host's clock is from Steward's (milestone 490)
CI / lint (push) Successful in 5s
CI / integration (push) Successful in 1m8s
CI / unit (push) Successful in 1m34s
CI / publish (push) Successful in 2m27s
- Agent 1.12.0 stamps each post with sent_at; ingest compares it with the
  moment the post arrived: clock_offset_s / clock_checked_at on the
  registration (host_agent_003), and a clock_off_secs metric (either way)
  that alert rules can watch. Replaces the old skew log, which measured the
  newest sample and was off by up to an interval.
- Settings -> Thresholds: Clock drift, 30 s warn / 5 min critical.
- Host page: 'clock 0.3s ahead' in the vitals line, toned past the warning.
- Hosts list: names hosts whose clock is off; when every reporting host is
  off by about the same amount, says Steward's own clock is the likely one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:00:33 -04:00
bvandeusenandClaude Opus 5.5 766eef04c5 test: the user model and session refresh carry time_zone (milestone 490)
Run 8736: test_user_model_fields listed the columns without time_zone, and
the session-refresh fixture's users had no time_zone attribute. The refresh
test now also asserts a zone set on the Account page reaches the session.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 08:00:32 -04:00
bvandeusenandClaude Opus 5.5 6a81cf4aac feat: times in the reader's own zone; the audit log shows exact moments (milestone 490)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m0s
CI / unit (push) Failing after 1m23s
CI / publish (push) Skipped
- users.time_zone (0034). An account without one takes the browser's zone
  on its first page (POST /account/time-zone/adopt, never over a choice made
  by hand); the session follows the account on every request.
- /account: who you are and the zone every page writes its times in,
  reached from your name in the sidebar.
- formatting.when: 'Today 06:58', 'Yesterday 23:41', 'Tue 6 Oct 14:12',
  '6 Oct 2025 14:12', 24-hour, in the reader's zone; |ago's hover is the
  exact local moment instead of UTC; tz_label() for column headers.
- Audit log: When (EDT) with exact times, one detail fact a line, on classes.
- tzdata in the image, and the airgapped smoke checks a zone resolves.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 07:56:27 -04:00
bvandeusenandClaude Opus 5.5 955450d33f fix(ui): section tabs above the breadcrumb, so the Ansible tab strip holds still (#5214)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Successful in 1m22s
CI / publish (push) Successful in 1m36s
The tabs were drawn in each page's content block, which base.html renders
after the breadcrumb, so on every nested Ansible page the strip moved down a
line. base.html now has a section_tabs block above the breadcrumb, and every
Ansible page fills it. A guard in test_breadcrumbs.py fails any page that calls
a tabs macro outside that block.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 06:28:02 -04:00
bvandeusenandClaude Opus 5.5 c678379646 fix(ansible): group names Ansible accepts, and interpreter discovery without the warning (#5334)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 57s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m26s
Every run warned twice because of Steward's own inventory:
- 'Invalid characters were found in group names': the group 'Docker
  Swarm' went into the inventory as typed. ansible_group_name() cleans it
  to docker_swarm (lower case, [a-z0-9_], never a leading digit); two
  names that clean alike get _2, _3 so neither folds into the other. The
  group page says what plays call it. The display name is unchanged.
- Python interpreter discovery: ansible_env sets
  ANSIBLE_PYTHON_INTERPRETER=auto_silent unless the environment already
  sets one; a target or group's ansible_python_interpreter still wins.

Runs are scoped by target set (steward:group:<id>), not by group name,
and --limit is free text, so neither path depends on the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 00:01:54 -04:00
bvandeusenandClaude Opus 5.5 7c346354f2 feat(ansible): Inventory on one page, add behind dialogs, delete behind the confirm (#5333)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 57s
CI / unit (push) Successful in 1m23s
CI / publish (push) Successful in 1m25s
/ansible/inventory/ shows every target (address, its linked Steward host,
its groups) and every group (members, the schedules aimed at it) under a
verdict line. The whole row opens the target or group. Add target and
Add group open small dialogs; a failed add comes back with its dialog
open and what was typed. The red x leaves the rows: Delete is on the
target's or group's page, behind the confirm dialog, saying which
schedules would be left with nothing to run on.

The two list pages (/inventory/targets, /inventory/groups) are gone
with no redirect (rule 122); their URLs keep only the Add POST.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:57:54 -04:00
bvandeusenandClaude Opus 5.5 4acaa42a97 fix(ansible): the playbook variables partial on classes; Run again assertion matches the link (#5331, #5332)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m17s
CI / unit (push) Successful in 1m39s
CI / publish (push) Successful in 1m49s
CI run 8705 on 0c72892: the schedule page's no-inline-style guard caught
_playbook_vars.html (shared by the run and schedule forms), and the run
page's Run again assertion left out the link's class.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:53:38 -04:00
bvandeusenandClaude Opus 5.5 0c72892f58 feat(ansible): schedules read as sentences, each with its own page (#5332)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 59s
CI / unit (push) Failing after 1m22s
CI / publish (push) Skipped
- The Schedules tab: a verdict line (failing first, paused, next due),
  each schedule as one line (what runs where, how often, when next, how it
  last went); the row opens its page; Run now and Pause stay in the row.
- /ansible/schedules/new and /ansible/schedules/<id>: the form on classes,
  dry run beside the button, the playbook's variables rendered with the
  page; at the foot Delete behind the confirm dialog. ?source=&playbook=
  start a new one on a playbook (Schedule it on the playbook's page).
- A run started by a schedule links to that schedule's page.
- The run page's id guard allows the Run again link's ?from=.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:51:15 -04:00
bvandeusenandClaude Opus 5.5 72a3261582 feat(ansible): Playbooks as one list, and a page per playbook that runs it (#5331, #5330)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m20s
CI / publish (push) Skipped
- /ansible/playbooks lists every playbook across sources: what it says it
  does, its category and destructive flag, how it last ran; the whole row
  opens the playbook. A source with none is one quiet line.
- /ansible/playbooks/<source>/<path>: the verdict (what it does, flagged
  destructive, last ran), the run form in place (dry run beside the button,
  options folded), the schedules that use it, its recent runs, the playbook
  itself folded. ?from=<run> fills the form from that run: Run again on a
  run's page.
- /ansible/browse, its view page and the /run-form fragment are gone, not
  redirected (rule 122); every link points at the new pages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:49:05 -04:00
bvandeusenandClaude Opus 5.5 a02f224f22 feat(ansible): one strip of tabs, a run list that opens on its verdict, a run page in one line (#5328, #5329, #5330)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m25s
CI / unit (push) Successful in 1m46s
CI / publish (push) Successful in 1m9s
- section_tabs macro; Runs · Playbooks · Schedules · Inventory on every
  Ansible page, replacing each page's own set of header buttons.
- Runs: a verdict line (last 30 days, failures first with the latest named,
  running now, the schedule due next); under each status what the run did
  across its hosts (ansible/recap.py); a filter bar (playbook, status, who
  started it) that swaps the table and carries into the scroll paging.
- Run page: status, duration, start and who started it in one line; each
  host's result in words, worst first; the playbook it ran links to it;
  the log and failures on classes rather than inline colours.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:45:04 -04:00
bvandeusenandClaude Opus 5.5 7499c38037 feat(ansible): the whole run row opens the run (#5214)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 57s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m29s
Same .row-link / .row-target as the Alerts rules list (snippet #5320).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:36:26 -04:00
bvandeusenandClaude Opus 5.5 16b636b2f0 feat(alerts): the whole rule row opens the rule (#5214)
CI / lint (push) Successful in 3s
CI / integration (push) Successful in 1m2s
CI / unit (push) Successful in 1m24s
CI / publish (push) Successful in 1m30s
The name's link stretches over its row (.row-link / .row-target); the
History and Disable buttons sit above it and keep their own clicks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:23:02 -04:00
bvandeusenandClaude Opus 5.5 e37cd0a873 test(alerts): check the history's Action column on one rule's rows, which the test owns (#5316)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m19s
CI / publish (push) Successful in 1m19s
The all-rules page reads a Postgres shared with other tests, whose
Ansible-run events turn the column on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:06:33 -04:00
bvandeusenandClaude Opus 5.5 531a193bd1 feat(alerts): history reads as episodes; tests follow the confirm dialog (#5316, #5314)
CI / lint (push) Successful in 3s
CI / integration (push) Failing after 54s
CI / unit (push) Successful in 1m34s
CI / publish (push) Skipped
History (#5316):
- Transitions fold into episodes (steward/alerts/episodes.py): each stretch
  out of quiet, the worst state it reached, how long it lasted, its worst
  reading against the rule ('3m 21s (fires above 3m)'), who was told across
  its changes, and its Ansible runs. The changes themselves fold under it.
- An episode cut by paging says so ('cleared later (above)', 'earlier').
- For one rule: a verdict ('Fired 4 times in the last 7d, 1h 2m in all') and
  no Rule column. Notified and Action show only when the range has any.

Tests:
- The confirm guard looks for a confirm( call, not the word in the comment
  that says it is gone (lesson #4365).
- The Hosts viewer test looks for the add-host dialog by id, since every page
  now carries the confirm dialog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 23:02:01 -04:00
bvandeusenandClaude Opus 5.5 0c66c1e7d3 feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m25s
CI / publish (push) Skipped
Confirm dialog (#5314):
- Every data-confirm form now asks in one native <dialog> (base.html + static/confirm.js)
  instead of window.confirm.
- The question becomes the heading and what follows it the detail; the act's
  button is named after the form's own button; Cancel has the focus and Esc
  backs out. Optional data-confirm-title/-detail/-action/-dismiss attributes.
- All 27 existing uses work as they are. Cancelling a run says 'Keep running'.

Alerts list (#5315):
- A verdict line (what is firing, how many rules, where notifications go,
  or 'notifies nobody yet' with a link) replaces the empty Active card.
- Each rule's name opens it; rows show 'last fired' and fold fire/clear into
  the condition; History and Disable stay; Delete leaves the list.
- .docker-verdict becomes the shared .page-verdict.

Rule page (#5317):
- Named for the rule (title and crumb), with when it last fired and how many
  changes of state it holds.
- Three sections: what it watches, when it fires (checks side by side,
  seconds echoed as a duration), what happens (where notifications go, no-data,
  the playbook, whose fields stay folded until ticked).
- Delete at the foot, behind the dialog, saying its history goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 22:58:38 -04:00
bvandeusenandClaude Opus 5.5 7cb6cb1fb5 test(docker): the dead-container page test dates its exit from the clock |ago reads (#5310)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 53s
CI / unit (push) Successful in 1m23s
CI / publish (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:37:07 -04:00
bvandeusenandClaude Opus 5.5 29be9e3590 feat(docker): the service and container pages say each fact once (#5310)
CI / lint (push) Successful in 4s
CI / integration (push) Successful in 1m33s
CI / unit (push) Failing after 1m59s
CI / publish (push) Skipped
Service page:
- The header carries the verdict, mode, stack, image and when it was reported.
  'In trouble since' shows only while the service is in trouble; once recovered,
  'Last failed X ago · its logs'.
- The failure card shows only while the service is down, degraded or crash-looping.
- The tile strip and the kv card are gone.
- One Tasks table: running tasks first with CPU, memory and since when, then
  Swarm's record. The node is named as its Steward host throughout, and
  'N failed in 24h' sits in the caption.

Container page:
- Same shape: header verdict, a 'Last exit' card only while it is stopped
  after a failing exit, set-up and I/O in one kv card, and lifecycle as a table.
- A swarm task's breadcrumb goes through its service.

Both pages:
- Resource history is the one fragment (_usage_history.html): now and peak in
  cores and bytes, drawn with a floor of 1 core / 1 GiB (sparkline_svg floor=),
  so idle noise draws flat.
- The container page's old % sparklines, its private _sparkline and the unused
  bucket()/container_history() helpers are removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 21:33:25 -04:00
bvandeusenandClaude Opus 5.5 6c486d2f9c fix(docker): keep the space before a heading's restarting/unhealthy count (#5214)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 56s
CI / unit (push) Successful in 1m18s
CI / publish (push) Successful in 1m12s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:40:56 -04:00
bvandeusenandClaude Opus 5.5 429629be61 fix(docker): the overview's failed count sits in the heading of the list it counts, no tile row beside it; stale moves into the verdict (#5214)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m19s
CI / publish (push) Skipped
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:38:04 -04:00
bvandeusenandClaude Opus 5.5 2ecf4d57cb test(docker): the disk page's images carry the 12-character ids the agent sends (#5305)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 52s
CI / unit (push) Successful in 1m18s
CI / publish (push) Successful in 1m19s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:20:41 -04:00
bvandeusenandClaude Opus 5.5 8566c8f78e feat(docker): Docker disk is recorded over time per host, the Disk page shows each host's growth and folds images under their repository, and the overview has one disk line per host (#5304–#5306)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 54s
CI / unit (push) Successful in 1m21s
CI / publish (push) Skipped
- #5304 "Docker disk totals recorded over time per host, with a growth chart and alertable metrics"
- #5305 "Disk page: images grouped by repository, versions folded under it"
- #5306 "Docker overview: one disk line per host linking to the Disk page"

Each `docker system df` report is now also recorded as metrics, with the host
as the resource: disk_total_bytes, its parts, and disk_reclaimable_bytes. An
alert rule can watch them. The Disk page starts with the hosts side by side,
showing how much each grew over the selected range with a line of it, then
gives each host its own section. An image's versions fold under its
repository, which is charged only for the layers its versions don't share.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:16:13 -04:00
bvandeusenandClaude Opus 5.5 0b5cd23d38 test(docker): a filtered overview leaves the service out of its table, not out of the failures list (#5299)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m12s
CI / unit (push) Successful in 1m36s
CI / publish (push) Successful in 1m35s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:14:33 -04:00
bvandeusenandClaude Opus 5.5 aae61f1d2c feat(docker): the Swarm page is laid out by node: role, what its containers use, and the services placed on each (#5303)
CI / lint (push) Successful in 5s
CI / integration (push) Failing after 1m27s
CI / unit (push) Successful in 1m40s
CI / publish (push) Skipped
#5303 "Swarm page laid out by node: role, resources and the tasks placed on each"

A verdict line first, then the nodes that are down or drained and the
services short of replicas, then one card per node. A node with a Steward
agent shows what its swarm tasks use against the machine's cores and memory,
service by service. A node without one lists Swarm's placement, by count.
The services table moved to the Docker overview, where it can be filtered.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:11:46 -04:00
bvandeusenandClaude Opus 5.5 a775882bd4 test(docker): a plugin template can take its own name; the overview's assertions follow its new tables (#5299, #5302)
render_plugin_template's first three arguments are positional-only, so the
service page's own `name` reaches the template. The overview names a service
rather than its replica containers, and a stack chip keeps the search it was
clicked under.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:11:46 -04:00
bvandeusenandClaude Opus 5.5 2093f399e6 feat(docker): the Docker page leads with a verdict, lists services and containers as filterable tables, and each service has its own page with logs across its tasks (#5298–#5302)
CI / lint (push) Successful in 2s
CI / integration (push) Failing after 1m4s
CI / unit (push) Failing after 1m28s
CI / publish (push) Skipped
- #5298 "Docker overview: verdict line; only the problem tiles that aren't zero"
- #5299 "Docker overview: swarm services as a filterable, sortable table with a Stack column"
- #5300 "Docker overview: failures listed once, each with a link to the failed task's logs"
- #5301 "Docker overview: standalone containers in one table with a Host column"
- #5302 "Service page: replicas, image, task history, resource history and logs across all its tasks"

overview.py holds the rows, filters and sort as plain functions. The filter
bar sits outside the polled fragment and the URL keeps its state. A service's
page reads every task container Steward still holds anything for, the live
ones and removed ones known by their events, so a failed task's logs are
reachable after Docker has removed it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 20:08:38 -04:00
bvandeusenandClaude Opus 5.5 7dccb2f303 fix(hosts): add HX-Request to Vary rather than replacing it; the session adds Cookie (#5294)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 52s
CI / unit (push) Successful in 1m17s
CI / publish (push) Successful in 1m1s
CI 8616: the integration test asserted Vary == 'HX-Request', but the session
layer appends 'Cookie'. resp.vary.add keeps both; the test asserts membership.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:57:22 -04:00
bvandeusenandClaude Opus 5.5 2990113e10 feat(hosts): the Hosts list searches, filters by group and status, sorts by column, and shows each host's groups (#5294)
CI / integration (push) Failing after 52s
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m17s
CI / publish (push) Skipped
#5294 "Hosts list: search, group and status filters; sortable columns; a Groups column".
Ansible inventory groups are Steward's grouping system (operator's ruling): a host's
groups are its linked target's, and membership overlaps, so the list filters by group
rather than sectioning by it.

- Filter bar: search on name or address, a group (or "No group"), a status (Down,
  Agent silent, Up, No monitors). The table swaps in alone and the URL keeps the filters.
- Name, Status, Latency, 30d uptime, CPU and Memory headers sort (sort_th in _macros);
  problems-first stays the default, and a host with no value goes last either way.
- A Groups column of chips, each filtering to its group; shown when any group exists.
- A swap is keyed on HX-Target, so Back to a pushed URL still gets the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 18:53:03 -04:00