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>
This commit is contained in:
@@ -13,14 +13,16 @@ from quart import Blueprint, current_app, redirect, render_template, request, se
|
||||
from sqlalchemy import select
|
||||
|
||||
from steward.auth.middleware import require_role
|
||||
from steward.core import weekly
|
||||
from steward.core.audit import log_audit
|
||||
from steward.core.crypto import encrypt_secret
|
||||
from steward.core.formatting import zone
|
||||
from steward.core.settings import public_base_url
|
||||
from steward.models.users import UserRole
|
||||
from steward.models.users import User, UserRole
|
||||
|
||||
from . import client, metrics
|
||||
from .models import HomeAssistantConnection, HomeAssistantUpdate
|
||||
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
|
||||
from . import client, schedule
|
||||
from .models import HomeAssistantConnection, HomeAssistantSchedule, HomeAssistantUpdate
|
||||
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment, problems_open
|
||||
from .updates import CannotStart, start_update
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -55,23 +57,29 @@ async def _render_index(error: str | None = None, status: int = 200):
|
||||
connection = await _connection(db)
|
||||
runs = list((await db.execute(select(HomeAssistantUpdate).order_by(
|
||||
HomeAssistantUpdate.started_at.desc()).limit(RECENT_UPDATES))).scalars())
|
||||
scrape = await metrics.state(db, connection) if connection else None
|
||||
plan = (await db.execute(select(HomeAssistantSchedule))).scalars().first()
|
||||
me = await db.get(User, session.get("user_id")) if session.get("user_id") else None
|
||||
if connection is None:
|
||||
return await render_template("homeassistant/index.html", connection=None, error=error), status
|
||||
default_zone = (me.time_zone if me and me.time_zone else "UTC")
|
||||
now = datetime.now(timezone.utc)
|
||||
reach, says = connection_says(connection, now)
|
||||
updates = connection.updates or []
|
||||
backup = connection.backup or {}
|
||||
tone, backup_says = backup_verdict(backup, now)
|
||||
return await render_template(
|
||||
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says, metrics=scrape,
|
||||
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says,
|
||||
installable=installable(connection),
|
||||
others=[u for u in updates if u["waiting"] and not u["kind"]],
|
||||
current=[u for u in updates if not u["waiting"] and u["kind"]],
|
||||
runs=runs, under_way=next((r for r in runs if r.state in ("backing_up", "installing")), None),
|
||||
backup=backup, backup_tone=tone, backup_says=backup_says,
|
||||
backup_at=parse_moment(backup.get("last_ok")), backup_next=parse_moment(backup.get("next")),
|
||||
backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS), status
|
||||
backup_age=backup_age_hours(backup, now), stale_hours=STALE_BACKUP_HOURS,
|
||||
health=connection.health or {}, problems=problems_open(connection.health),
|
||||
plan=plan, plan_when=weekly.describe(plan) if plan else None,
|
||||
plan_next=weekly.next_slot(plan, now) if plan and plan.enabled else None,
|
||||
weekdays=weekly.WEEKDAYS, default_zone=default_zone), status
|
||||
|
||||
|
||||
@homeassistant_bp.get("/")
|
||||
@@ -96,23 +104,23 @@ async def install():
|
||||
return redirect("/plugins/homeassistant/")
|
||||
|
||||
|
||||
@homeassistant_bp.post("/metrics")
|
||||
@homeassistant_bp.post("/schedule")
|
||||
@require_role(UserRole.admin)
|
||||
async def connect_metrics():
|
||||
"""Read Home Assistant's metrics with this sign-in, after asking for them once
|
||||
to show they're there. An admin's explicit action, so it may ask."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
connection = await _connection(db)
|
||||
if connection is None:
|
||||
return redirect("/plugins/homeassistant/")
|
||||
async def save_schedule():
|
||||
"""Set the weekly slot for installing what's waiting, or turn it off."""
|
||||
form = await request.form
|
||||
time_zone = (form.get("time_zone") or "UTC").strip()
|
||||
try:
|
||||
await metrics.try_reading(current_app, connection)
|
||||
target_id, name, created = await metrics.use_sign_in(current_app, connection)
|
||||
except metrics.CannotRead as exc:
|
||||
return await _render_index(f"Nothing changed: {exc}", 409)
|
||||
await log_audit(current_app, *_actor(), "prometheus.target_created" if created else "prometheus.target_updated",
|
||||
entity_type="prometheus_target", entity_id=target_id,
|
||||
detail={"name": name, "token_from": metrics.SOURCE})
|
||||
weekday, at_time = int(form.get("weekday", "")), (form.get("at_time") or "").strip()
|
||||
weekly.parse_at(at_time)
|
||||
except ValueError:
|
||||
return await _render_index("Choose a day and a time.", 422)
|
||||
if not 0 <= weekday <= 6 or (zone(time_zone) is timezone.utc and time_zone.upper() != "UTC"):
|
||||
return await _render_index(f"Choose a day, and a known time zone rather than “{time_zone}”.", 422)
|
||||
row = await schedule.save(current_app, enabled=bool(form.get("enabled")), weekday=weekday,
|
||||
at_time=at_time, time_zone=time_zone)
|
||||
await log_audit(current_app, *_actor(), "homeassistant.schedule_saved", entity_type="homeassistant_schedule",
|
||||
entity_id=row.id, detail={"enabled": row.enabled, "when": weekly.describe(row)})
|
||||
return redirect("/plugins/homeassistant/")
|
||||
|
||||
|
||||
@@ -188,7 +196,6 @@ async def sign_in_callback():
|
||||
connection.can_install, connection.signed_in_by = can_install, username
|
||||
connection.signed_in_at = datetime.now(timezone.utc)
|
||||
connection.refused, connection.last_error, connection.last_polled_at = False, None, None
|
||||
await metrics.follow_sign_in(db, connection)
|
||||
await db.flush()
|
||||
connection_id = connection.id
|
||||
if previous is not None:
|
||||
@@ -214,9 +221,7 @@ async def _end_sign_in(connection) -> bool:
|
||||
@homeassistant_bp.post("/connection/retry")
|
||||
@require_role(UserRole.admin)
|
||||
async def retry_connection():
|
||||
"""Ask again after a refusal, once a ban has been dealt with. A metrics scrape
|
||||
using this sign-in starts again with it."""
|
||||
client.forget_access()
|
||||
"""Ask again after a refusal, once a ban has been dealt with."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
connection = await _connection(db)
|
||||
|
||||
Reference in New Issue
Block a user