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

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

144 lines
7.1 KiB
HTML

{% extends "base.html" %}
{% from "_macros.html" import crumbs, empty_state, page_header %}
{% block title %}Maintenance — Steward{% endblock %}
{% block breadcrumb %}{{ crumbs([("Hosts", "/hosts/"), ("Maintenance", "")]) }}{% endblock %}
{% block content %}
{# A group's updates and reboots on a weekly cadence, one host at a time
(#5768). What needs attention first: a run going now, a run that failed;
then the plans with their recent runs. #}
{% macro run_hosts(rows) -%}
<div style="display:flex;flex-direction:column;gap:0.15rem;font-size:0.82rem;">
{% for r in rows %}
<div style="display:flex;gap:0.6rem;align-items:baseline;" {% if r.detail %}title="{{ r.detail }}"{% endif %}>
<a href="/hosts/{{ r.host_id }}" style="min-width:10rem;">{{ r.name }}</a>
<span class="{{ 'tone-warn' if r.bad else '' }}" style="{{ '' if r.bad or r.busy else 'color:var(--text-muted);' }}">{{ r.text }}</span>
</div>
{% endfor %}
</div>
{%- endmacro %}
{{ page_header("Maintenance") }}
<p style="color:var(--text-muted);font-size:0.84rem;margin:-0.4rem 0 1.25rem;">
Each plan updates an inventory group's hosts once a week, one host at a time. A host reboots
only when its update needs it, unless the plan says otherwise; a swarm node is drained first
and made active again after. The next host starts once the last is back in service. A failure
stops the run, and the host's <em>Maintenance failed</em> figure can drive an alert rule.
</p>
{% if live %}
{% for l in live %}
<div class="card">
<h3 style="margin-bottom:0.5rem;">Running: {{ l.plan }} <span style="font-size:0.78rem;color:var(--text-muted);font-weight:normal;">· started {{ l.run.started_at|ago }}{% if l.run.manual %} · run now{% endif %}</span></h3>
{{ run_hosts(l.rows) }}
</div>
{% endfor %}
{% endif %}
{% for f in failed %}
<div class="card" style="border-color:color-mix(in srgb,var(--yellow) 40%,var(--border));">
<span class="tone-warn">Failed: {{ f.plan }}</span>
<span style="font-size:0.82rem;color:var(--text-muted);">· {{ f.run.finished_at|ago }} · {{ f.run.detail or "no detail" }}</span>
</div>
{% endfor %}
{% if plans %}
{% for p in plans %}
<div class="card">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;flex-wrap:wrap;">
<h3 style="margin-bottom:0.2rem;">{{ p.plan.name }}{% if not p.plan.enabled %} <span style="font-size:0.78rem;color:var(--text-muted);font-weight:normal;">· paused</span>{% endif %}</h3>
{% if session.user_role == 'admin' %}
<div style="display:flex;gap:0.4rem;">
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/run" style="margin:0;"
data-confirm="Run {{ p.plan.name }} now? Its {{ p.hosts }} host{{ 's' if p.hosts != 1 }} are updated one at a time; the schedule keeps its time.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm">Run now</button>
</form>
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/toggle" style="margin:0;">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-ghost">{{ 'Pause' if p.plan.enabled else 'Resume' }}</button>
</form>
<form method="post" action="/plugins/host_agent/maintenance/plans/{{ p.plan.id }}/delete" style="margin:0;"
data-confirm="Delete {{ p.plan.name }} and its run history? Hosts' own job history stays.">{{ csrf_field() }}
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
</form>
</div>
{% endif %}
</div>
<div style="font-size:0.82rem;color:var(--text-muted);margin-bottom:0.6rem;">
Group <a href="/hosts/?group={{ p.group|urlencode }}">{{ p.group }}</a> ({{ p.hosts }} host{{ 's' if p.hosts != 1 }})
· {{ p.when }}
· reboot {{ {'if_required': 'only when required', 'always': 'always', 'never': 'never'}[p.plan.reboot_policy] }}
{% if p.next %}· next {{ p.next|when }}{% endif %}
</div>
{% for r in p.runs %}
<details {% if loop.first %}open{% endif %} style="margin-top:0.3rem;">
<summary style="cursor:pointer;font-size:0.82rem;">
<span class="{{ 'tone-warn' if r.run.state == 'failed' else '' }}">{{ {'running': 'Running', 'done': 'Done', 'failed': 'Failed', 'stopped': 'Stopped'}[r.run.state] }}</span>
<span style="color:var(--text-muted);">· {{ r.run.started_at|when }}{% if r.run.manual %} · run now{% endif %}{% if r.run.detail %} · {{ r.run.detail|truncate(100) }}{% endif %}</span>
</summary>
<div style="margin:0.3rem 0 0.4rem 1rem;">{{ run_hosts(r.rows) }}</div>
</details>
{% else %}
<p style="font-size:0.82rem;color:var(--text-dim);margin:0;">No runs yet.</p>
{% endfor %}
</div>
{% endfor %}
{% else %}
<div class="card">{{ empty_state("No maintenance plans yet." ~ (" Add one below." if session.user_role == 'admin' else "")) }}</div>
{% endif %}
{% if session.user_role == 'admin' %}
<div class="card">
<h3 style="margin-bottom:0.6rem;">Add a plan</h3>
{% if groups %}
<form method="post" action="/plugins/host_agent/maintenance/plans"
style="display:flex;gap:0.75rem;align-items:flex-end;flex-wrap:wrap;">{{ csrf_field() }}
<div class="form-group" style="margin-bottom:0;">
<label>Name</label>
<input type="text" name="name" required maxlength="128" placeholder="Docker hosts, weekly" style="width:13rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Group</label>
<select name="group_id" required>
{% for g in groups %}<option value="{{ g.id }}">{{ g.name }}</option>{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Day</label>
<select name="weekday">
{% for d in weekdays %}<option value="{{ loop.index0 }}" {% if loop.index0 == 6 %}selected{% endif %}>{{ d }}</option>{% endfor %}
</select>
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Time</label>
<input type="time" name="at_time" value="03:00" required style="width:7rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Time zone</label>
<input type="text" name="time_zone" value="{{ default_zone }}" required style="width:11rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Window (min)</label>
<input type="number" name="window_minutes" value="240" min="30" max="1440" style="width:6rem;">
</div>
<div class="form-group" style="margin-bottom:0;">
<label>Reboot</label>
<select name="reboot_policy">
<option value="if_required" selected>Only when required</option>
<option value="always">Always</option>
<option value="never">Never</option>
</select>
</div>
<button type="submit" class="btn">Add plan</button>
</form>
<p style="font-size:0.78rem;color:var(--text-dim);margin:0.6rem 0 0;">
Hosts whose agent has no maintenance helper are skipped and listed; run Update agent on them.
No host is started once the window has passed.
</p>
{% else %}
<p style="font-size:0.85rem;color:var(--text-dim);margin:0;">
Plans work on inventory groups. Add one under <a href="/ansible/inventory/">Ansible → Inventory</a>.
</p>
{% endif %}
</div>
{% endif %}
{% endblock %}