feat(homeassistant): its page offers the metrics scrape under the same sign-in, so one user covers everything (#6744)
CI / lint (push) Successful in 1s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m58s
CI / publish (push) Successful in 1m13s

A Prometheus target's token_from names the plugin that supplies its token at each
scrape. Read its metrics makes the target; Use this sign-in moves one at that address
across, keeping its name, filters and history. A refusal stops the connection, so one
Try again or sign-in starts everything, and the sign-in is only sent to its own address.
A 404 says how to turn on Home Assistant's Prometheus integration, as Steward doesn't
change Home Assistant. Refused also joins the volatile columns a backup check ignores.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 12:06:55 -05:00
co-authored by Claude Opus 5.5
parent 66a5dd12fd
commit 4c9ed02652
19 changed files with 482 additions and 24 deletions
+27 -3
View File
@@ -18,7 +18,7 @@ from steward.core.crypto import encrypt_secret
from steward.core.settings import public_base_url
from steward.models.users import UserRole
from . import client
from . import client, metrics
from .models import HomeAssistantConnection, HomeAssistantUpdate
from .service import STALE_BACKUP_HOURS, backup_age_hours, backup_verdict, installable, parse_moment
from .updates import CannotStart, start_update
@@ -55,6 +55,7 @@ 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
if connection is None:
return await render_template("homeassistant/index.html", connection=None, error=error), status
now = datetime.now(timezone.utc)
@@ -63,7 +64,7 @@ async def _render_index(error: str | None = None, status: int = 200):
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,
"homeassistant/index.html", connection=connection, error=error, reach=reach, says=says, metrics=scrape,
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"]],
@@ -95,6 +96,26 @@ async def install():
return redirect("/plugins/homeassistant/")
@homeassistant_bp.post("/metrics")
@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/")
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})
return redirect("/plugins/homeassistant/")
# ── The connection ───────────────────────────────────────────────────────────
async def _render_form(connection, url: str | None = None, verify_tls: bool | None = None,
@@ -167,6 +188,7 @@ 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:
@@ -192,7 +214,9 @@ 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."""
"""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()
async with current_app.db_sessionmaker() as db:
async with db.begin():
connection = await _connection(db)