Monitors, schedules and maintenance plans already pause with a
Pause/Resume button and say "Paused" where they're listed. Prometheus
targets and syslog patterns only had a tick box inside the edit form,
so a target refused by its endpoint couldn't be stopped from its page.
- Both get a Pause/Resume button (target page and list; patterns list)
posting to /toggle, recorded in the audit log, and a Paused badge.
- The forms lose the tick box. A new one starts running; an edit keeps
whether it is paused.
- A target's "off" state is now "paused", in its pages and MCP tools.
A resumed target reads "not scraped yet" until its next scrape, and its
counter rates start over.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
"swarm services short on swarm is 6." becomes "6 swarm services are
short of their replicas.", with a Which line naming each service and how
many of its tasks run. The swarm figures get labels, and the counting
figures (swarm, mounts, drives, updates) read as sentences in the email,
its subject and the Alerts page. A plugin names what its count counts
through core.alerts.names_behind; the Docker plugin does for the three
swarm figures, from the stored swarm. Webhooks get alert.which too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first applied run lost VDNT-Docker03 29 images (10.4 GB) before its
script ran. Its `docker system prune` removes dangling images, and Docker
counts an image with no tag as dangling: every image a swarm task pulled by
digest. Docker03 was left holding only its own tasks' images, as under the
old prune. Nothing went down, since a running container's image can't be
removed.
- Step 1 now prunes containers, networks, build cache and (prune_volumes)
volumes one by one. Every image decision is left to the script, which
keeps the images services are pinned to.
- A test guards against any image-pruning command in the playbook.
- The playbook moves to maintenance/swarm_prune/, with its script in that
folder's files/, so a helper's name can't collide with another
playbook's (the operator's direction for playbooks with helpers).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`docker image ls` on Docker 29 leaves out images with no tag, which is every
image a swarm task pulled by digest: the check run on VDNT-Docker03 saw 4 of
its 58. Missing them only kept them, but superseded digests there would
never have gone. The list now comes from GET /images/json on the socket.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The nightly prune ran `docker system prune -a`, which deletes every image no
local container uses, so each swarm node kept only its own tasks' images.
When the other node failed or was drained, its services had to pull first:
on 2026-10-25 Traefik took 11.5 min to start on Docker03.
maintenance/swarm_prune.yml (built in) prunes stopped containers, unused
networks, dangling images and build cache, then removes only the images that
no container uses and no swarm service is pinned to, wherever that service
runs. Old digests left by redeploys still go.
- A container's image is kept by its id and by the reference it was made
from, since on the containerd store the id alone hasn't held a tag.
- A node that can't list the swarm's services removes no images and fails
rather than pruning blind.
- Check mode lists what would go.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each weekly run drains Docker02 then Docker03, and the swarm never moves a
task back by itself, so every run ended with nearly every service on one
node. On 2026-10-25 the operator had that spread out by hand.
- Agent 1.26.0 reports each swarm service's pinned digest, and every
repo@sha256 its host holds (from /system/df, every 10 min).
- Once a run's last host is back in service, Steward picks services on the
fullest node to move to the emptiest until they are within one task. It
takes services with several replicas on one node first. A service moves only
when the emptier node already holds its exact image. One the plan keeps in
place, one below its replicas, and one with no pinned digest stay put, and
the run says why.
- A swarm manager's helper carries it out through a new action, rebalance,
with the service names (validated, at most 48). It force-updates each in
turn with the spec unchanged and no registry lookup, skips any a
constraint places, and gives each 5 min to settle.
- The run ends done with what moved, or failed if a move didn't settle or
the manager didn't answer in 60 min. A docker-scoped window covers the
restarts, and the step goes on the timeline.
- Plans gain "Spread swarm services back out" (on by default) and "Keep in
place", on the create form and in an "After the run" form on each plan.
list_maintenance_plans shows both and the latest run's spread.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-25 the 09:00 job on VDNT-Docker02 drained the node, which ran
Traefik and Steward. The agent's result reaches Steward through Traefik,
and Traefik took 11.5 minutes to start on Docker03 (its image had to be
pulled). The job gave up at the 10-minute drain deadline and finished
timed_out where it stood, so the node stayed drained for hours.
- A job that fails or times out while its node may be drained (the drain
was handed out, or went through and wasn't undone) goes to undraining
first. The outcome is kept in the new column ends_as, and the job
finishes as that once the node is active again. If the undrain goes
wrong too, detail says both.
- The drain deadline is 30 minutes instead of 10, so a result held up while
the proxy moves still lands.
- Migration host_agent_012_ends_as. get_maintenance_job returns ends_as.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A ticket records a known problem: title, area, hosts, subject, description,
links to alert rules and anomalies, and a log of comments, edits, resolving
and reopening. Opening, resolving (with the resolution as the note) and
reopening go on the timeline; every step is audited, and the audit rows stay
off the timeline.
An alert or anomaly an open ticket covers (by link, by subject, or a
host-wide ticket on the host itself) shows a Known issue badge, and offers
"Open a ticket" prefilled from its rule or anomaly when none does. Hosts,
monitors and containers get a Known issues card.
MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket,
comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and
list_anomalies carry known_issue; get_overview, get_host, get_monitor and
get_container carry open_tickets. Also update_change and delete_change for
changes recorded by hand (author or admin).
Tickets are left out of backups, like the timeline (2026-10-15 ruling).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The change-form test set the zone on the session, which the auth middleware
resets from the account on every request. The password reset test asserted
BST, which stopped being London's zone name on 25 Oct.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A change Steward can't see happen (a config edited, a disk swapped) is
recorded by whoever made it: what was done, its area, when (in the reader's
own zone), the hosts, what it was done to and a detail. Its author or an
admin edits or deletes it; recording, editing and deleting are audited and
those audit rows stay off the timeline. log_change records one as
"<user> via MCP", editable by the token's user.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host agent writes each maintenance job once it ends, and each stretch a
host was away (a reboot, a crash, lost contact, the agent restarting), kept up
to date until it is back. Docker judges each snapshot: a unit on a new image,
or a container recreated, is a deploy; deaths past the Thresholds crash-loop
count are one crash-loop entry that later deaths extend; single restarts are
recorded but hidden. An anomaly has one entry whose type follows its review:
only those judged real are shown by default.
A source whose record changes keeps one entry for it (record_or_update).
Plugins register the records their entries point to, so an aged-out job or
downtime reads "Details no longer kept". The first fill runs once per source
(timeline.filled), the host agent's jobs and downtime included.
get_anomaly's "what else was going on" reads the timeline for the host or
resource instead of querying alerts, runs, maintenance, downtime and container
deaths itself; Docker's own source keeps only starts, stops and health
changes. A new kind, Observed, is for what Steward saw happen with no rule or
person behind it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A stored log, timeline_entries, with one row per thing that happened. Each
source writes its row in the same transaction as its own record, so the
history outlives the 30/90/365-day retention of the tables it points to:
- alert events (core/alerts._add_event): fired, resolved, acknowledged,
went silent, readings back;
- finished Ansible runs (executor, and the startup sweep of interrupted
runs): changed something, changed nothing, failed, dry run;
- audit rows (core/audit.log_audit): Steward's settings, users and access,
sign-ins, backups. Actions another writer records are left to it.
Entries carry a kind (fixed) and an area (Service, Host OS, Hardware,
Storage, Network, Security, Monitoring). Settings → Timeline edits the
areas and each type's area and visibility; both are applied when the
timeline is read, so a change re-sorts past entries and a hidden type
comes back when shown. Kept forever unless a retention is set.
The first start fills the timeline from the last 90 days of records once,
skipping any already covered. A host rename carries its entries.
UI: Timeline in the sidebar (filter by host, area, kind; scrolls on), a
History card on each host page. MCP: get_timeline.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
18032a4 re-checked a "not back in service" job every minute with no end,
so any later swarm wobble on the host (radarr's failed task during the
downloads move, say) flipped maintenance_failed back to 1: five false
firings and ten emails for Docker02 overnight on 10-19. And the overview's
finding never learned the rule at all, so it still said Docker02's
maintenance failed three days on.
The job now keeps came_back_at, set the first time its host is found back.
maintenance.still_failed(job) is the one answer: the figure, the overview,
the cadence page's run rows and the host panel all ask it. A failed update
or a timed-out job still flags its host until a job succeeds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Three 404s on a quiet router read 7.5% and were raised as an anomaly; most of
the 4xx anomalies on review stood for 3 to 7 errors in 15 minutes. A new
Settings → Thresholds minimum, traefik.threshold.min_4xx (default 20, more
than one a minute held for the window), applies to both the router's grade
on its page and to anomaly detection, as min_5xx does for 5xx.
learn_normal takes `fewest`: for a share gated on a per-second rate, the
fewest events (rate × window × share) it must stand for to be judged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
response_ms was the whole `ping -c 1` process, or an open_connection awaited
on the event loop: about 15 ms on a LAN whose round trip is under 1, and every
host read slow together whenever Steward was busy. A ping now reports the
round trip ping itself printed (the elapsed time only if it printed none);
a TCP connect is timed in a thread, the name resolved first and not counted.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_host_senders on VDNT-Docker02 ran past 120 s: a prefix LIKE on
plugin_metrics.resource_name can't use its index (the column's collation
isn't C), so each read went through the whole table, and the host page's
Who's sending panel repeats it on every refresh. The containers and NFS
mounts under a host are now named from plugin_metric_latest, and their
series read by resource IN (...) on the series index.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The host page gains a "Who's sending" panel, and the MCP a tool, that
put the host's outbound (or inbound) traffic down to its containers and
NFS mounts: read in shared time buckets, so the host's busiest bucket
says who was sending in it. A swarm service's tasks add up under its
name; what none of them accounts for is the host itself.
Also fixes CI 9488: per-container net_rx_bps / net_tx_bps join the
docker alert catalog, so a rule can catch a container flooding the
uplink.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docker02's outbound traffic tops out the internet connection and Steward
couldn't say who sends it: a container's counters were kept only as a
running total, and NFS traffic belongs to no container.
- Docker ingest turns each running container's cumulative network counters
into net_rx_bps / net_tx_bps on "<host>/<container>" (net_rates), counting
from the stored row so a restart of Steward loses nothing; a container
new, recreated or restarted has no rate until its next snapshot.
- Agent 1.25.0 reads the bytes sent and received per NFS mount from
mountstats (already parsed for the delays) as tx_bps / rx_bps; Steward
stores them as nfs_tx_bps / nfs_rx_bps on "<host>:<mount>".
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Docker02's Sunday job updated and rebooted, then failed only because
portal_app was short of its replica past the 30-minute settle. Its
maintenance_failed figure stayed 1, and the alert kept firing, until a
later job succeeds. A job failed for not coming back into service is
checked again each minute and reads 0 once the host is back; a failed
or timed-out update still reads 1 until a job succeeds.
Also: compose.deploy.yml's syslog comment still said devices are told
apart by address under Swarm (stale since #6096).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SNMP poll asks each device's sysName in the same GET and keeps it
(snmp_003_sys_name). A syslog source told apart by name finds its own
address from the SNMP device (reported or given name) or host of that
name, so its lines reach the SNMP device's and host's pages again; the
edit page gains Device address, which always wins (syslog_004).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Through ingress every device arrives from Docker's own address, so a
source there is keyed by the hostname its lines carry (new column
sender, unique with address). The set-up help no longer guesses the
address from the web hostname: it shows the plugin's send_to setting,
covers ingress and host mode, ASUSTOR ADM, and both syslog formats.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-18 an update on VDNT-Docker02 restarted the Docker daemon six
seconds in, before the drain that came only with the reboot, and every
container on the node stopped. Docker's own packages restart the daemon from
their install scripts; needrestart's list-only mode doesn't stop that.
- The helper gains a `check` action (agent 1.24.0): a dry run of the update
for each package manager, whether Docker is among it, and the running
containers outside the swarm with their restart policies.
- A swarm node is drained before an update that upgrades Docker, else only
before a reboot, and at most once a job.
- After a drain the job settles 5 minutes before the update or reboot.
- Once the work is over, the containers that ran outside the swarm must be
seen running again within 10 minutes, or the job fails naming them.
- An older agent gets no check and its job runs as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
During Docker02's maintenance reboot its Agent offline alert fired muted by
the job's window, then resolved just after the window closed: the resolution
was mailed for a firing nobody had been told of. A resolution whose firing
was held and never released is now recorded with a note, not sent.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The Syslog page says where to send (this server, its port and protocols) and
where the setting is on Synology, QNAP, TrueNAS, OpenWrt, UniFi and rsyslog,
open until a device sends. A sender whose address is on Steward's own
container network (Swarm ingress publishing hides the real sender) is
flagged, with the host-mode ports to publish instead. compose files and the
Dockerfile publish 5514/udp and /tcp; docs/plugins/syslog.md describes the
plugin and the deploy.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_anomaly's circumstances gain the host's or SNMP device's syslog
warnings and worse (mcp_events / mcp_resource_events). An anomaly row opens
"Syslog around it", read only when opened. A shared Chart.js plugin
(chart-marks.js) draws a dashed line at each syslog error on the host
metrics charts and an SNMP device's charts, with a line saying how many.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A pattern (phrases, a severity, a source, a window) is counted every minute
into plugin_metrics as lines_matching_<key> per added source, zero included,
so an ordinary alert rule fires on a match and clears once the window passes.
Each count reads only the lines stored since the last, by line id; a new or
changed pattern reads its window once.
Patterns page with ready-made storage-trouble presets and "Alert on it",
which opens the rule form aimed at the count. Source names are kept unique,
and a rename moves its counts and the rules on them.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- /plugins/syslog/sources/<id>: the lines newest first, loading as you
scroll, filtered by severity and worse, app, text and time, with day
dividers and times to the second in the reader's zone.
- The Syslog page links each source and shows its lines in the last hour.
- A Syslog panel on a host's page and an SNMP device's: the newest lines of
the sources bound to it or at its address.
- MCP: list_syslog_sources and search_syslog (source, host, text, severity
and worse, app, range, paging by next_before).
- Shared: escape_like moves to steward/core/like.py and cut_middle to the
MCP shaping module, replacing four inline copies; |when takes seconds=.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Audit: syslog.source_* worded, and a Syslog filter area.
- Backups keep syslog_sources (added, named, ignored; what it last sent
isn't compared) and leave syslog_lines out, as logs.
- The integration test reaches the plugin as loaded (#5775), so its
tables aren't defined twice; ruff E741.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- plugins/syslog: RFC 5424 and RFC 3164 parsing, RFC 6587 TCP framing.
- Lines are queued as they arrive and stored every 5 s through a Holding,
so a storage stall delays them rather than losing them.
- A new sender is an offered source, counted and not kept, until an admin
adds it on the Syslog page (name, host); it can be ignored instead.
- The listener follows the plugin's settings (port, UDP, TCP), rebinding
within seconds of a save; a port it can't open is said on the page.
- Hourly ring per source: age window and byte cap from Settings.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Saving an enabled plugin threw away hot_reload_plugin's (ok, message), so a
plugin that failed to load live read "Enabled, not loaded" with nothing
more. Now:
- a live-load failure is recorded with its reason, as a boot failure is, and
logged; a later success clears it;
- any save of an enabled plugin that isn't running tries to start it, not
only the off-to-on toggle;
- enabling one already running is a success, not a failure;
- the plugin's page offers "Restart to load" while it is enabled and not
running.
Quart never marks the app as having served its first request, so
register_blueprint after startup is allowed: hot-loading is not refused
wholesale, as #6087 suspected.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
main's z-index makes it a stacking context, so the fixed drawer (z-index 60)
could never rise over the sticky sidebar (z-index 20): its title, group
labels and first column of widgets sat underneath. Above 900px the drawer
now starts at left:220px; below, the sidebar is off-canvas and it keeps the
full width.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The agent sent Docker's working set (usage less inactive_file), which still
counts recently used file cache. A file-heavy container with no limit read
191 GB while its processes held 2.2 GB, and the host's own figure, which
already leaves cache out, disagreed with it.
Agent 1.23.0 sends mem_used_bytes / mem_used_pct (anon + shmem, so
Postgres's shared buffers count; v1: rss + shmem) and mem_cache_bytes
apart. The new names keep the two meanings apart in history and in learned
normals: docker_013 renames the columns, clears the old values and adds
mem_cache_bytes. The Docker page, swarm by-node sums, Busiest Containers,
container detail, normals and the MCP tools all read the new figure. File
cache is shown only on the container's page and in get_container.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Synology: what each disk and volume reads and writes, bytes and requests
a second and the load DSM gives it (SYNOLOGY-STORAGEIO- and -SPACEIO-MIB,
64-bit byte counters), and each disk's reconnects and sectors not found.
- Linux server: CPU waiting on disk, the wait tick counter's share of the
CPU's, and memory available (free + buffers + cache). A reading may now be
worked out from others: the poll asks for its inputs, rates each counter,
and stores only the result.
- Interface traffic: errors and discards in and out, per second.
- byte_count unit: a byte counter shown as bytes a second.
Asustor's MIB has nothing more for this; its Linux server readings gain the
same CPU wait and available memory.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each sample carries each NFS mount's requests since the last one: how many
a second, their round trip and execute time (READ and WRITE on their own),
and how many were sent again or hit a major timeout. mountstats is the
kernel's own count and never touches the share, so it reads while one is
stuck. Steward stores them per mount and the slowest on the host as
nfs_exe_max_ms, for an "NFS slow" rule and the host page's NFS chart beside
mounts_stalled; each mount's bar says what a request took.
Agent 1.22.0 (which also carries the backlog order from 41fc807).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The same Holding as the SNMP poll, for the rest of Steward's own
collectors:
- Monitors: the list is read within 10 s or the last one read stands in;
a start the database doesn't take is kept in memory, so a monitor isn't
checked twice; each result is held and stored at the time it was
checked. A stall shows on the monitors as the checks it saw, not a gap.
- UniFi and Traefik: each poll's writes are held (hold_write) and run
oldest first in one transaction; every reading is stamped with its scrape.
- A held round the database refuses (as opposed to can't be reached) is
stored alone and let go, so it can't hold up every round after it.
The Prometheus scrape is left as it is: its one target, Fabled Archives,
keeps its own database on the same NFS.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-17 the NFS under Postgres stalled for ~15 minutes. Two things
went wrong for the readings that would have explained it:
- The SNMP poll stored before it polled again, so while the database hung
it stopped polling. It now collects on schedule and hands each round to a
Holding (steward/core/holding.py): one writer stores what is held, oldest
first, each reading at the time it was collected. Bounded at two hours of
polls, with a 30-minute deadline on a store; the device list read falls
back to the last one read after 10 s.
- "Mount not answering" (10 checks in a row) went pending on Docker02 and
Docker03 and never fired: the agent sent each post's backlog with its
newest, healthy sample, and that sample ended every post's run. Agent
1.22.0 queues the new sample behind the backlog, so Steward receives a
host's samples in the order they were taken.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The table walk stopped after 64 raw entries, counting the RAM disks, loops,
partitions and md arrays that rows_like then drops, so a Synology's eighth
disk was never reached. The walk is now bounded at 1,024 entries per column;
64 still caps the rows kept.
ADM labels its disks "Disk0".."Disk4" while their indexes run 1..5; the
Asustor disk table now names each row by its index ("Disk 1"). Keys are
unchanged, so history carries on.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A preset can now read a vendor's tables: one row per disk, volume, fan or
sensor. The scheduled poll discovers the rows by walking the device: on its
first poll, after a save, and then hourly, and only while it answers. Each row
becomes readings named by the device's own labels ("Disk 1 status"). Only the
columns a row answered are asked for.
A vendor status, whether a code (Synology) or a word (Asustor), is a state
reading. It is stored as a level (0 OK, 1 busy, 2 warning, 3 failed), so one
alert rule covers any vendor, and shown in the device's own word.
- Synology: system, power and fan status and temperature; per disk: status,
health, temperature and bad sectors; per volume: RAID state (scrubbing,
parity check, rebuild, degraded), free space and size.
- Asustor: per disk: status, SMART and temperature; per volume: status, free
space and size; CPU and system temperature, free memory and fans.
From ADM's ASUSTOR-DISK-MIB and ASUSTOR-SYSTEM-MIB.
- Linux server: plus each physical disk's 1-minute busy % (UCD-DISKIO-MIB).
Migration snmp_002_table_rows adds tables, tables_at and texts to
snmp_devices. Milestone 530.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A redeploy's new process ticks at once, so its once-a-minute samples never
broke for 90 s and get_anomaly showed none of the day's restarts. The first
self-monitor tick in a process now records restart_gap_secs (time since the
last process's last sample), and steward_gaps shows each one as a restart.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new plan's last_slot_at holds the moment it was made, so its first
slot is the next one; the tool returned that moment, a Wednesday
evening for a Sunday plan, beside latest_run: null. cadence.fired_slot
returns it only when it falls on one of the plan's slots.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A Traefik that hasn't restarted keeps reporting the certificate it
replaced, so codex.fabledsword.com's old one (21 days left) was listed
beside its renewal (81 days) and would have raised an expiry warning on
2026-10-22. A reported certificate whose every name a later-expiring
one covers is now old, on the page, the widget, the overview and MCP.
list_certificates takes `limit` (default 100) with total/truncated, and
says how many served certificates no HTTP router uses (`unrouted`).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each source in an anomaly's `around` was cut to its oldest 40 with
nothing in the reply to say so; on a busy host the events nearest the
occurrence were the ones dropped. Each source now keeps the 40 nearest
the occurrence's start, and `around_left_out` counts the rest. Sources
return everything their bounded query read (Docker and the host agent
no longer cut at 40 and 20 themselves), so the count is honest.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
All 16 Traefik 4xx anomalies on 2026-10-15 came back with nothing
around them, though 15 were Steward's own restarts. A router has no
host, and every events source was read by host.
- Core: each break of more than 90 s in Steward's once-a-minute health
sample is a "steward" event beside every anomaly, placed or not.
- Plugins: an optional mcp_resource_events(db, source, resource, since,
until) for a resource with no host. Traefik's gives the container
events of the swarm service behind a router, on any host.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One 502 among about 70 requests read steward@swarm critical at 1.4%:
past the 30-request minimum and far past the 0.1% cutoff, yet one
error. A new Settings → Thresholds field, "Fewest server errors to
fail" (traefik.threshold.min_5xx, default 3), must also be reached in
the 15-minute window; the 0.1% cutoff still catches a busy router.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
router_findings never passed `since`, so every failing router read as
brand new and sorted last among the criticals. The overview now reads
the failing routers' last day of history and dates each one with
problem_since, as the dashboard widget does.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Two 500s of about sixteen requests graded roundtable@swarm critical at
9.1%. The Settings → Thresholds minimum of requests in 15 minutes (30 by
default) now guards both shares; the 5xx share still keeps its fixed
cutoff whatever the router's history.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Through one address Steward read whichever replica answered, and with
host-mode ports behind a keepalived VIP that can be the replica serving
nothing: since the 18:43 restart every scrape landed on Docker03's idle
Traefik, which has no traefik_service_* series, so no router was stored.
A metrics_url host name that resolves to several addresses (a swarm
service's tasks.<service>, a Kubernetes headless Service, round-robin DNS)
is now scraped address by address, with the original Host header and TLS
name. Answers from one process are kept once; a replica that doesn't answer
is skipped with a warning. Requests, bytes and latency buckets are summed
over the replicas that have a baseline, so the sum's rise is the sum of each
one's own rise; certificates are listed once; Traefik's own figures add
connections and memory and keep the newest reload. A name with one address
is one scrape, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
still_failing was handed the query's one-pass result. The first failed
run's any() consumed it, so a second failure in the window found no
successes left and was reported though a later run had put it right
(ci-runner-volumes came back once keepalived-survey also had a failure).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
One metrics URL can reach several Traefik processes: a swarm service's
replicas behind the routing mesh or its VIP, a Kubernetes Service's pods,
a keepalived pair after failover, or the same Traefik after a restart.
Each keeps its own counters, so comparing one with another read the second
one's lifetime totals as new traffic: retired routers flashed 100% 502 and
an idle router showed 834 req/s whenever the scrape moved to the other
replica.
Each scrape is now keyed by Traefik's process_start_time_seconds and
compared only with the same process's previous scrape. A process seen for
the first time (or not in the last hour) sets its baseline and counts
nothing. Without a start time every scrape is one process, as before.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_overview listed every failed or interrupted run of the last 24 hours,
so a dry run that failed, was fixed and re-run green kept warning for a
day. A failure now drops out once a later success of the same playbook,
source and hosts is at least as real: an applied success clears either
kind of failure, a dry-run success only a dry-run failure.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The snmp plugin registers list_snmp_devices and get_snmp_device; the
prometheus plugin registers list_prometheus_targets and
get_prometheus_target. Both are viewer-level, read what the poller and
scraper stored, and exist only while their plugin is enabled.
get_overview names a device or target whose last poll or scrape failed;
stale, pending and off are left out.
A device's community and v3 keys and a target's token are never
returned, and a password in a target URL is masked.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_network gives the WAN, clients, Wi-Fi networks, speed tests and devices
by status; list_devices, list_clients (everyone seen, or the newest poll's
top talkers), list_network_events, list_alarms and get_network_config
(networks, port forwards, firewall rules) read what the poller stored.
get_overview names the WAN down while the poll is current, a device offline
or degraded that the controller still reports, and active alarms.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
list_routers grades every router as the Traefik page does (the last 15
minutes, 5xx on its cutoff, 4xx and p95 against the router's normal) and
gives a troubled one its status codes, what they mean and the swarm
service behind it. get_router adds its history, codes, normal and
anomalies; list_certificates the served certificates and the routers they
cover; get_traffic the access log's clients, countries and routers.
get_overview names a router answering 5xx and a certificate near expiry.
The access-log aggregation moves into routes.summarize_access, shared by
the traffic page and get_traffic.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get_anomaly gives each occurrence against what is usual at that hour, the
metric around the newest one with its normal, what else happened on the
resource's host around each occurrence (alert transitions, Ansible runs,
other anomalies there and a count elsewhere, plus plugins' own events
through a new mcp_events hook: the host agent's maintenance jobs and
downtime, Docker's container starts, deaths and OOM kills), and how earlier
anomalies on the same metric were judged.
get_metric_normal reads the learned slots and the level that would be
flagged. review_anomaly takes one id or several, all or nothing, with a
note. get_anomaly_settings and (admin) set_anomaly_settings read and change
the review window and each normal's floor, as Settings → Thresholds does.
metric_series.points takes an optional `until`; core.alerts gains
host_of_resource; floor_setting names its floor.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
list_backups, check_backup and make_backup are Settings → Backups for an
agent, admin only like the page. make_backup runs the page's job in the
background and is audited as "<user> via MCP". Restoring stays in the
browser. get_overview names a scheduled backup that failed, is two hours
past its slot, or has no bucket.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5835 "MCP: swarm nodes", step 2 of milestone 520. list_swarm_services
already listed every node (role, leader, availability, status); the
audit missed it. What was missing:
- Each node row carries `host_id` (the Steward host of that name) and
`maintenance` (the state of a maintenance job running on it), so an
agent can go node -> get_host / get_maintenance_job.
- get_overview names a node whose status isn't ready, unless its host
has a maintenance job running (rebooting on purpose).
- Drained alone is not a finding: VDNT-Util01 is a manager kept drained
on purpose (#5777), and a drain a failed job left behind is already
that job's maintenance_failed finding.
unique_nodes() keeps the newest report of each node, as the list did.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5834 "The host agent is always on, and the MCP sees a host's
maintenance", step 1 of milestone 520 "The MCP sees what Steward has
grown".
The host agent is a core system (operator, 2026-10-15: "it's very core
to the functionality of Steward as a whole and shouldn't be considered
something that can be toggled off"). ALWAYS_ON_PLUGINS in core settings
makes to_plugins_cfg report it enabled whatever its stored row says, so
it always loads and its MCP tools are always listed. Settings -> Plugins
shows it as "Built in: always on" with no switch, and a save keeps it
enabled. Its code and URLs stay under plugins/host_agent: every
installed agent posts to /plugins/host_agent/ingest.
Maintenance through the MCP:
- list_maintenance_plans: each plan's group, weekly slot, reboot policy,
next slot, and its latest run (hosts, how far it got, who it skipped
and why, how it ended).
- get_maintenance_job(host | job): a host's jobs, newest and running
first, or one job: state, packages updated, reboot, drained by, why it
failed, when its current state times out.
- run_maintenance(host, reboot="if_required" | "never"): admin, as the
host page's Update now; refuses a busy host or one with no helper;
audited "<user> via MCP".
- The overview names a host whose latest job in the last week failed or
timed out.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5748 "MCP can write, edit and test this instance's own playbooks
(steward-local)": two admin tools beside run_playbook, so an agent can
build automation for these hosts that isn't part of Steward itself.
- save_playbook(playbook, content, source="steward-local", replace=false)
goes through the editor's own validate_playbook_yaml and
write_playbook. A playbook that isn't a list of plays comes back with
the YAML error and its line, and nothing is written. An existing file
is overwritten only with replace=true. steward-builtin is refused
(Steward's playbooks change in its repository), as are git sources
(their repo owns them).
- After writing, `ansible-playbook --syntax-check` runs against a
throwaway localhost inventory (no host contacted, 60 s), so a module
name Ansible can't resolve shows before any run. The result rides the
reply; the file is kept either way.
- delete_playbook(playbook, source="steward-local").
- Both audit as ansible.playbook.created/updated/deleted, "<user> via
MCP", with the size and never the content.
The testing loop is the existing one: run_playbook defaults to check
mode, on one target, then get_ansible_run. The integration test does
that loop with the real ansible-playbook.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Agent 1.21.0. The hourly update check now covers the package managers
beyond apt and dnf (#5803 "The agent's update check reads pacman,
zypper and yum"):
- yum: `yum -C check-update`, `updateinfo list security`,
`needs-restarting -r`, as dnf.
- zypper: `--no-refresh list-updates` (`--dup` on Tumbleweed),
needed security patches from `list-patches --category security`,
and `zypper needs-rebooting`.
- pacman: `checkupdates` from pacman-contrib, which install-deps.sh now
installs. pacman has no security notion; a reboot is required once
the running kernel's /usr/lib/modules/<release> is gone.
The checks sit in one UPDATE_CHECKS table, and a failure names its
manager.
The root helper's update runs on the same hosts (#5804 "The scheduled
update runs on pacman, zypper and yum hosts"): yum upgrade, zypper
refresh + update (dup on Tumbleweed, with 102/103 counted as success),
pacman -Syu. Each step carries a label so a failure says which one,
and the reboot check reads zypper and the pacman kernel too.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
systemd/fablednetmon.service still carried the project's first name in
its user, paths, command and config location, so it couldn't start
Steward as written. It is now systemd/steward.service: user steward,
/opt/steward, `steward --config /etc/steward/config.yaml`, with a note
that /data must be writable (secret.key, safety backups) and why
Restart=on-failure brings Steward back after a web UI restore (exit 3).
The legacy-name guard now reads service units too, and forbids
"netmon" beside "scryer" and "roundtable". The 2026-04 rebrand plan and
spec under docs/superpowers keep the old names: they record the rename.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the live restore the "before restore" backup.made row read user
"unknown": _safety_backup recorded it with no username. It now carries
the person who started the restore.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5818 "First-run setup: Restore from a backup, by upload": beside creating
the first admin, setup offers "Or restore from a backup". Only while no
user exists; the upload is checked (a newer backup or one whose key can't
open its secrets is refused and not kept), then restored with no safety
backup (nothing to keep), and Steward restarts into the backup's users.
#5819 "Docs and CI": backups.md leads with restoring from Settings →
Backups and from setup; `steward restore --in-place` does the same-schema
restore from the shell, and CI's image check now uses it with a real
pg_restore: a changed setting comes back and the audit log stays.
Also exempts errors/restoring.html from the breadcrumb guard, as with
the other whole-app error pages: it replaces every page while a restore
runs, so there is nowhere to trail back to.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5816 "Restoring mode": while a backup goes back in, the scheduler holds,
ingest and HTMX answer 503 with Retry-After (agents keep their samples),
and every page says what is happening. A safety backup of Steward as it
stands is made first, to /data/backups and the bucket. The backup's key
is put in place, the restore audited, and Steward exits with code 3 so
Swarm, compose or systemd starts it fresh. A failed in-place restore
changes nothing and says why on Backups; a full replace that fails after
the wipe restarts into first-run setup.
#5817 "Settings → Backups: Restore": a Restore button on each sent backup
and on the file card. The chosen backup is checked (the same comparison
as Check, now a shared partial) and held server-side for an hour under a
token; it starts only once the admin types "restore", and an older
backup also needs "Replace everything" ticked. A newer backup, or one
whose key can't open its secrets, gets no button.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- restore_in_place checks the backup's migration heads before pg_restore
writes anything, so a backup from another schema is refused at once.
- The integration tests read their own setting rows directly:
get_all_settings knows only Steward's own keys.
- check.py: drop an import left unused by the move to pgdata.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5815 "Restore engine: in place for a same-version backup".
restore_in_place (steward/backup/restore.py) runs in one transaction:
1. It keeps aside what Steward records as it runs: ACTIVITY columns of rows
that come back, and STATE_SETTINGS.
2. It drops every foreign key touching a replaced table.
3. It truncates the replaced tables and COPYs the backup's rows in
unparsed, through asyncpg.
4. It puts the kept state back and moves sequences past their rows.
5. It puts right the rows whose parent didn't come back, by each key's own
ON DELETE. A RESTRICT key that may be empty is cleared, so a run whose
user is gone keeps its record.
6. It re-adds the keys, which checks every row.
A failure anywhere rolls the whole restore back. A backup made at another
schema is refused with NotSameSchema ("older": a full replace is the way;
"newer": update Steward).
The COPY-text reading moves into steward/backup/pgdata.py, shared by the
check and the restore. The integration tests stand in for pg_restore with
this database's own COPY output:
- settings come back while the audit log and the history of what remains
stay;
- history of what didn't come back goes;
- a failure part-way changes nothing;
- a newer schema is refused.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5823 "Check a backup: compare what it would restore with what is in
place, without restoring".
- steward/backup/check.py: pg_restore writes the backup's rows as COPY
text with no database involved. The rows in place go out through COPY in
the same form. Tables are compared on their primary keys, over the columns
both sides have, and grouped by area.
- Sealed values are opened with each side's own key and compared as
plaintext digests, so a secret sealed twice still matches. They are never
shown. A backup whose key can't open its own secrets fails the check.
- What Steward records as it runs (last-seen times, a schedule's last run,
backup history, scraped UniFi fields) is left out of the comparison:
contents.ACTIVITY, STATE_SETTINGS and HAND_SET_ONLY.
- Settings → Backups:
- a Check button on each sent backup, which fetches it from the bucket
(new s3.get_file);
- "Check a file" to upload one, with the size limit raised before the
CSRF check reads the form;
- a results page.
- Each backup Steward sends checks itself once written, and its history
row says how that went.
- New CLI command `steward check-backup FILE`. CI's image smoke step checks a
real backup against its source and expects a match.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5822 "Backup contents: settings and keys only". The operator's ruling
(System 180, 2026-10-15) overturns 2026-10-12's "Config + audit + alerts":
a backup is for system recovery and carries what would otherwise have to
be set up again, with no logs or history.
- contents.py: audit_events, alert_events, alert_states, metric_anomalies,
metric_anomaly_spans and user_invites move to EXCLUDED.
- Alert state is no longer restored, and evaluation skips a rule with no
state row. ensure_rule_states (alerts/service.py) gives every
single-resource rule lacking one an inactive row; alert_reconcile runs
it each minute.
- Docs, the Backups page and the CLI help say what a backup holds now.
CI's image check also finds the audit log empty after a restore.
- tests/integration/test_backup_restore_fixes.py is removed: its case (an
alert event naming a left-out Ansible run) no longer arises. The SET NULL
fix-up is covered by a unit test on synthetic tables.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Overview: a verdict line when something is wrong ('1 alert firing, 1
monitor down.'); its picker description now says what it lists (alerts,
monitors down, anomalies). Host figures have their home in Hosts.
Comments no longer name the Status page that was folded into Monitors.
- Monitors widget: a monitor not checked yet says when it was added.
- Host page: filesystems fullest first, by the worse of space and inodes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Its problems and since come from the same container_problems and
problem_since the Docker page uses, read from the day's deaths and
health changes; troubled containers sort worst problem first, then name.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Routers: a router in trouble reads 'started 2h ago' or 'all of the last
24 h', from problem_since over the day's history, read for the troubled
routers only (a certificate's problem carries its own date). Hosts: a
down host reads 'a monitor is down for 3h'; host_glance keeps the
down_since host_status already had.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An agent keeps its log cursors in memory, so after a restart it seeds each
container from its last 50 lines, which Steward already holds; docker_logs
has no unique key, so every update or reboot doubled them. The ingest now
reads what it already has for each container over the batch's own span,
in one query, and skips those lines. A push of new lines overlaps nothing.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SQLAlchemy fires after_commit and after_rollback for a savepoint too. The
host-agent ingest releases one after recording its metrics, so an alert
raised by a sample started its email before the outer transaction
committed; twice on 2026-10-12 the outbox row hit the event's foreign key
first and the email was lost. A savepoint rolling back (a docker persist
failing) cleared every pending notification, the outer one's included.
_after_commit now runs its jobs only on the outermost commit, and a
rolled-back savepoint drops only the jobs added inside it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
smartctl reports every disk behind a PERC/MegaRAID controller as /dev/bus/0
with type megaraid,N; the helper named a drive by its path alone, so all of
VDNT-Docker02's and Docker03's disks read as one drive "0", each overwriting
the last. The slot is now part of the name (bus0-megaraid3), and Steward gives
a repeated name an ordinal so two drives never share a series. Agent 1.20.1.
fix(ansible): Steward stopping mid-run logs no traceback (#5749)
The run's done-callback called exception() on a cancelled task, which raises;
it now logs a run cut short by shutdown at INFO. A finished run's in-memory
state is dropped by a timer rather than a sleeping task, which shutdown found
still pending.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The root helper gains one read-only action, `drives`: it finds the drives
itself (smartctl --scan-open), reads each as JSON, and writes a summary the
agent carries on every sample (agent 1.20.0). Steward judges the figures in
drives.py (failing / worn / watch / ok / no SMART / unreadable), records them
per drive and as host roll-ups for covering rules, keeps the newest report on
the registration and lists it on the host page.
install-deps.sh installs smartmontools with apt, dnf, yum, zypper, pacman or
apk, run by install.sh and every agent playbook; one it can't install is
reported, never fatal.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On 2026-10-14 nine swarm services sat at 0/1 and nothing fired. From each
manager's swarm report the docker plugin now records three figures on
resource "swarm": swarm_services_short, swarm_nodes_down and
swarm_managers_unreachable. Drained nodes are left out of the last two,
since a drain is deliberate. The figures are recorded at most once every
30 s whichever manager reported, and are offered to alert rules.
Agent 1.19.0 sends each manager's ManagerStatus.Reachability. Older agents
fall back to the node's status.
A maintenance job on a swarm node also keeps a window over docker/swarm,
so its drain and reboot hold those alerts until the job ends.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
temp_c_max was recorded on every ingest, but host_agent didn't offer it
to rules, so "host running hot" couldn't be watched. The catalog guard
didn't see the row(...) helper ingest builds its rows with. Now offered:
temp_c_max, temp_c per sensor, and network and disk throughput per host,
interface and disk. A new guard holds every literal row(...) name to the
catalog, except NOT_ALERTABLE, where each exclusion is given a reason.
temp_c_max reads in °C.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the live instance the hourly refresh took 3 min 8 s, averaging a week of
raw temp_c samples each time. The hourly averages are now kept in memory.
The first refresh after startup reads the whole window. Each later one reads
raw samples from the start of the hour the last read ended in, so the hour
that was still filling is read again whole. merge_hours folds the new rows
in and drops hours older than the window.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Rollups exist only for hours older than the raw window (7 days by default),
so the first deploy read an empty week and judged nothing. refresh now
averages the raw samples by the hour and adds any rolled-up hours in the
window. Sensors are named from plugin_metric_latest, so each series is read
through its index instead of a pattern over every host_agent row. An
integration test covers both halves against Postgres.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A board's spare thermistor inputs report whatever an open input reads, and
the value rule from #5759 only catches the impossible ones. Once an hour,
sensors.refresh judges each sensor against the host's CPU (Package id N,
Tctl/Tdie/TccdN, PECI Agent N) over a week of hourly averages. A sensor is
ruled out if it falls as the CPU warms (r <= -0.5) or holds within 0.5 °C
while the CPU moved 10 °C or more. A judgment needs at least 72 shared hours.
The host temperature leaves judged sensors out at ingest. The host page
lists them under "Not counted", with each reason given once.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- #5780 "The error-pages service is graded on other routers' errors". The
Traefik scrape reads which services an errors middleware sends to, and
grades them healthy: their 4xx and 5xx are the codes of the errors they
cover for, already graded on the routers that failed.
- #5781 "The Maintenance failed rules sit at no data until a host has had
a maintenance job". Every host with an agent reads 0 until a job fails.
- #5782 "VDNT-Util01's host page lists SYSTIN at 115 °C in red". The host
page lists sensors that counts_as_hottest leaves out (unwired board
inputs, 0 °C or below) on one muted line, not as heat.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5712 "Each router problem says what it means, whether it's real, and what
to do". A problem card now names its kind and reads its codes:
- Server errors: 500 means the app is failing; 502/503/504 means Traefik
couldn't reach it or it timed out.
- Client errors: 404 scanners, a 401/403 login wall, 429 rate limiting, or
499 clients giving up on a slow app.
- Slow: p95 against p50.
Each card says whether the problem is likely real, and ends with one next
step. A fault links to the swarm service behind the router, matched by
name, and traffic links to Settings → Thresholds. The wording lives in
plugins/traefik/meaning.py.
The plugin manager now hands out a loaded plugin's submodule
(loaded_plugin_submodule), so Traefik and host_agent reach Docker's
models the same way (#5775).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5777 "Maintenance would make a node the operator keeps drained active after
its reboot". A swarm node already set to drain or pause, such as a manager
kept free of work, is no longer drained before its reboot or made active
after it. Its job only updates and reboots it, and back-in-service needs it
ready but not active.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Importing plugins.docker.models by path defines its tables a second time,
because the plugin manager loaded them as _steward_plugin_docker.models.
Inside the app that raises InvalidRequestError, not ImportError. The cadence
integration test hit it, and #5767's _swarm (used when a job is planned)
carried the same import. Both now go through maintenance.docker_models().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#5768 "A maintenance cadence: a group's updates and reboots, one host at a
time, on a schedule". A plan names an inventory group, a weekday and time
in a time zone, a window and a reboot policy (only when required by
default). At its slot a run takes the group's hosts in order and gives each
a #5767 maintenance job. The next host starts only when the last is back in
service: its agent is posting and, for a swarm node, the node is active and
every replicated service is at its replicas. A failure stops the run, and
no host is started once the window has passed. A host's latest failure
shows as host_agent/<host>/maintenance_failed, for an ordinary alert rule.
The page is Hosts → Maintenance.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>