Files
FabledSteward/plugins/homeassistant/templates/homeassistant/index.html
T
bvandeusenandClaude Opus 5.5 f7a856cb90
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Failing after 1m54s
CI / publish (push) Skipped
feat(homeassistant): it shows system problems and installs updates on a weekly slot, and leaves device readings alone (#6748, #6749, #6747)
The connection's job is keeping Home Assistant running. Each poll now reads open
repairs, Supervisor health, disk, recovery mode and add-ons that should be running
over its websocket, for the page, the overview and the problems_open, disk_free_pct
and update_failed metrics. That needs a websocket client, so websockets joins the
dependencies.

A weekly slot, set on the page or through MCP and off until then, installs add-ons,
then Core, then the OS, each after its own confirmed backup, ending at the first
failure. The slot arithmetic moves from the host agent's cadence to steward.core.weekly
so both share it.

The device scrape goes: the page's Metrics section and a target's token_from from
4c9ed02 are removed, and a migration deletes targets using it, as they'd otherwise ask
with no token. A refused token still stops a scrape.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-01 14:16:42 -05:00

151 lines
9.5 KiB
HTML

{# plugins/homeassistant/templates/homeassistant/index.html — whether Home
Assistant answers, which updates are waiting with Install beside each, how its
backups stand, and the updates Steward has run. Refreshes itself while one is
under way. #}
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}Home Assistant — Steward{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{% call page_header("Home Assistant", connection.url if connection else none) %}
{% if admin and connection %}<a class="btn btn-sm btn-ghost" href="/plugins/homeassistant/connection">Connection</a>{% endif %}
{% endcall %}
{% if not connection %}
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div class="card">{{ empty_state("Not connected. Sign in to a Home Assistant instance to see its waiting updates and backups, and to install updates after a confirmed backup.",
"Connect" if admin else none, "/plugins/homeassistant/connection") }}</div>
{% else %}
{% if error %}<p class="field-error" role="alert">{{ error }}</p>{% endif %}
<div id="ha-body"{% if under_way %} hx-get="/plugins/homeassistant/" hx-select="#ha-body" hx-trigger="every 15s" hx-swap="outerHTML"{% endif %}>
<p class="page-verdict">
{% if under_way %}{{ status_badge('pending', 'Updating') }}
{{ under_way.title }}: {{ "taking a backup first" if under_way.state == 'backing_up' else "installing " ~ under_way.to_version }}. Home Assistant may stop answering for a while.
{% elif reach == 'up' %}{{ status_badge('up', 'Answering') }}
{% if installable %}{{ installable|length }} update{{ "s" if installable|length != 1 }} waiting.{% else %}Everything Steward can update is up to date.{% endif %}
Read {{ connection.last_polled_at|ago }}.
{% elif reach == 'down' %}{{ status_badge('down', 'Not answering') }} <span class="tone-crit">{{ says }}</span>
{% if connection.last_ok_at %}Last answered {{ connection.last_ok_at|ago }}; what's below is from then.{% endif %}
{% if admin and connection.refused %}<form method="post" action="/plugins/homeassistant/connection/retry" style="display:inline;">{{ csrf_field() }}<button class="btn btn-sm btn-ghost" type="submit">Try again</button></form>{% endif %}
{% else %}{{ status_badge('pending', 'Not read yet') }} {{ says }}{% endif %}
</p>
{% if not connection.can_install %}
<p class="tone-warn">The Home Assistant user who signed in isn't an administrator, so updates can't be installed from here.
{% if admin %}<a href="/plugins/homeassistant/connection">Sign in again</a> as an administrator to install them.{% endif %}</p>
{% endif %}
<div class="stat-strip">
{{ stat_tile("System problems", problems|length if health.repairs_read else "—", state="degraded" if problems else none) }}
{{ stat_tile("Disk free", (health.disk_free_gb ~ " GB") if health.disk_free_gb is not none and health.disk_free_gb is defined else "—") }}
{{ stat_tile("Updates waiting", installable|length if connection.last_ok_at else "—") }}
{{ stat_tile("Last good backup", (backup_at|ago) if backup_at else "—", state="degraded" if backup_tone == 'warn' else none) }}
{{ stat_tile("Next backup", (backup_next|ago) if backup_next else "—") }}
</div>
<p class="{{ 'tone-warn' if backup_tone == 'warn' else 'field-hint' }}">{{ backup_says }}</p>
<section class="card" aria-labelledby="ha-system">
<h2 id="ha-system" class="section-title">System</h2>
{% if problems %}
<table class="table">
<thead><tr><th scope="col">Problem</th><th scope="col">Severity</th></tr></thead>
<tbody>
{% for p in problems %}<tr><td>{{ p.title }}</td><td>{{ status_badge('down' if p.severity in ('critical', 'error') else 'degraded', p.severity|capitalize) }}</td></tr>{% endfor %}
</tbody>
</table>
<p class="field-hint">Fix these in Home Assistant: Settings → System → Repairs.</p>
{% elif health.repairs_read %}
<p class="field-hint">No repairs open, and nothing wrong reported.</p>
{% elif connection.last_ok_at %}
<p class="tone-warn">Home Assistant wouldn't share its repairs with this user. {% if admin %}<a href="/plugins/homeassistant/connection">Sign in again</a> as an administrator to see them.{% endif %}</p>
{% endif %}
{% if health.repairs_read and not health.supervised %}<p class="field-hint">No Supervisor answered, so disk space and add-ons aren't checked here.</p>{% endif %}
{% if health.error %}<p class="tone-warn" style="margin-bottom:0;">This is from an earlier read. {{ health.error }}</p>{% endif %}
</section>
<section class="card" aria-labelledby="ha-updates">
<h2 id="ha-updates" class="section-title">Updates</h2>
{% if installable %}
<table class="table">
<thead><tr><th scope="col">What</th><th scope="col">Now</th><th scope="col">New</th>{% if admin %}<th scope="col"></th>{% endif %}</tr></thead>
<tbody>
{% for u in installable %}
<tr>
<td>{{ u.title }}<div class="cell-sub">{{ u.kind_word }}{% if u.auto_update %}, updates itself{% endif %}</div></td>
<td class="mono">{{ u.installed or "—" }}</td>
<td class="mono">{% if u.release_url %}<a href="{{ u.release_url }}" rel="noopener noreferrer" target="_blank">{{ u.latest or "—" }}</a>{% else %}{{ u.latest or "—" }}{% endif %}</td>
{% if admin %}<td style="text-align:right;">
{% if connection.can_install and not under_way and not connection.refused and not u.in_progress %}
<form method="post" action="/plugins/homeassistant/install"
data-confirm="Install {{ u.title }} {{ u.latest }}? Steward takes a backup first and won't install without it. {{ 'Home Assistant restarts.' if u.kind == 'core' else ('The machine reboots.' if u.kind == 'os' else 'The add-on restarts.') }}">{{ csrf_field() }}
<input type="hidden" name="entity_id" value="{{ u.entity_id }}">
<button class="btn btn-sm" type="submit">Install</button>
</form>
{% endif %}
</td>{% endif %}
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="field-hint" style="margin:0;">Nothing waiting.</p>
{% endif %}
{% if current %}<p class="field-hint">Up to date: {% for u in current %}{{ u.title }} {{ u.installed }}{{ ", " if not loop.last }}{% endfor %}.</p>{% endif %}
{% if others %}<p class="field-hint" style="margin-bottom:0;">Waiting, for Home Assistant to install: {% for u in others %}{{ u.title }}{{ ", " if not loop.last }}{% endfor %}. Steward installs Core, OS and add-on updates only.</p>{% endif %}
</section>
<section class="card" aria-labelledby="ha-schedule">
<h2 id="ha-schedule" class="section-title">Scheduled updates</h2>
{% if plan and plan.enabled %}
<p class="field-hint">{{ plan_when }}. Next: {{ plan_next|ago }}. Add-ons first, then Core, then the OS, each after a confirmed backup. A failure ends the run and the rest wait.</p>
{% else %}
<p class="field-hint">Off. Updates are only installed when someone chooses Install.</p>
{% endif %}
{% if plan and plan.stopped %}<p class="tone-warn">The last run stopped early. {{ plan.stopped }}</p>{% endif %}
{% if admin %}
<p class="tone-warn">A Core update restarts Home Assistant and an OS update reboots the machine, with nobody there to watch. An update can also change how automations behave.</p>
<form method="post" action="/plugins/homeassistant/schedule" style="display:flex;flex-wrap:wrap;gap:0.75rem;align-items:flex-end;">{{ csrf_field() }}
<label style="display:flex;gap:0.5rem;align-items:center;cursor:pointer;margin-bottom:0.4rem;">
<input type="checkbox" name="enabled" value="on"{% if plan and plan.enabled %} checked{% endif %}> Install on a schedule
</label>
<div class="field" style="margin-bottom:0;">
<label class="field-label" for="ha-day">Day</label>
<select class="input" id="ha-day" name="weekday">{% for d in weekdays %}<option value="{{ loop.index0 }}"{% if loop.index0 == (plan.weekday if plan else 5) %} selected{% endif %}>{{ d }}</option>{% endfor %}</select>
</div>
<div class="field" style="margin-bottom:0;">
<label class="field-label" for="ha-time">Time</label>
<input class="input" id="ha-time" type="time" name="at_time" required value="{{ plan.at_time if plan else '05:00' }}">
</div>
<div class="field" style="margin-bottom:0;">
<label class="field-label" for="ha-zone">Time zone</label>
<input class="input" id="ha-zone" name="time_zone" required value="{{ plan.time_zone if plan else default_zone }}">
</div>
<button class="btn btn-sm" type="submit">Save</button>
</form>
{% endif %}
</section>
{% if runs %}
<section class="card" aria-labelledby="ha-runs">
<h2 id="ha-runs" class="section-title">Updates run from Steward</h2>
<table class="table">
<thead><tr><th scope="col">What</th><th scope="col">Versions</th><th scope="col">Outcome</th><th scope="col">Started</th></tr></thead>
<tbody>
{% for r in runs %}
<tr>
<td>{{ r.title }}<div class="cell-sub">by {{ r.started_by or "Steward" }}</div></td>
<td class="mono">{{ r.from_version or "—" }} → {{ r.to_version or "—" }}</td>
<td>{% if r.state == 'done' %}{{ status_badge('up', 'Done') }}
{% elif r.state == 'failed' %}{{ status_badge('down', 'Failed') }}<div class="cell-sub">{{ r.message }}</div>
{% else %}{{ status_badge('pending', 'Taking a backup' if r.state == 'backing_up' else 'Installing') }}{% endif %}</td>
<td>{{ r.started_at|ago }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endif %}
</div>
{% endif %}
{% endblock %}