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:
@@ -21,11 +21,12 @@ the host it runs on, picks up where it was.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime, time, timedelta, timezone
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from steward.core.weekly import WEEKDAYS, describe, fired_slot, is_due, last_slot, next_slot, parse_at # noqa: F401
|
||||
|
||||
from . import maintenance, rebalance, timeline
|
||||
|
||||
WEEKDAYS = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday")
|
||||
# How long a host may take to be back in service after its job finished.
|
||||
SETTLE_LIMIT = timedelta(minutes=30)
|
||||
# An agent heard from this recently is posting.
|
||||
@@ -33,58 +34,6 @@ POSTING_WITHIN = timedelta(minutes=3)
|
||||
RUN_STATES = ("running", "done", "failed", "stopped")
|
||||
|
||||
|
||||
def _zone(name: str):
|
||||
from steward.core.formatting import zone
|
||||
return zone(name)
|
||||
|
||||
|
||||
def parse_at(at_time: str) -> time:
|
||||
h, m = (int(x) for x in at_time.split(":", 1))
|
||||
if not (0 <= h < 24 and 0 <= m < 60):
|
||||
raise ValueError(at_time)
|
||||
return time(h, m)
|
||||
|
||||
|
||||
def last_slot(plan, now: datetime) -> datetime:
|
||||
"""The most recent scheduled time at or before `now`, in UTC."""
|
||||
tz = _zone(plan.time_zone)
|
||||
local = now.astimezone(tz)
|
||||
day = local.date() - timedelta(days=(local.weekday() - plan.weekday) % 7)
|
||||
slot = datetime.combine(day, parse_at(plan.at_time), tzinfo=tz)
|
||||
if slot > local:
|
||||
slot -= timedelta(days=7)
|
||||
return slot.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def fired_slot(plan) -> datetime | None:
|
||||
"""The last slot that fired, or None while none has. A new plan's
|
||||
last_slot_at holds the moment it was made, so its first slot is the
|
||||
next one rather than one already past; that moment is no slot (#5863)."""
|
||||
at = plan.last_slot_at
|
||||
if at is None:
|
||||
return None
|
||||
return at if last_slot(plan, at) == at else None
|
||||
|
||||
|
||||
def next_slot(plan, now: datetime) -> datetime:
|
||||
return last_slot(plan, now) + timedelta(days=7)
|
||||
|
||||
|
||||
def is_due(plan, now: datetime) -> datetime | None:
|
||||
"""The slot to fire now, or None. A slot fires once (last_slot_at), and
|
||||
only while its window is still open: a Steward that was down over the slot
|
||||
starts late, not a day late."""
|
||||
if not plan.enabled:
|
||||
return None
|
||||
slot = last_slot(plan, now)
|
||||
since = plan.last_slot_at or plan.created_at
|
||||
if since is not None and slot <= since:
|
||||
return None
|
||||
if now - slot >= timedelta(minutes=plan.window_minutes):
|
||||
return None
|
||||
return slot
|
||||
|
||||
|
||||
def back_in_service(*, last_seen_at, node, services, job_finished_at, now: datetime,
|
||||
expect_active: bool = True) -> str | None:
|
||||
"""None when the host is back in service, else why not yet. `node` is the
|
||||
@@ -134,13 +83,6 @@ def next_move(run, plan, job, back: str | None, now: datetime) -> tuple[str, str
|
||||
return "settling", back
|
||||
|
||||
|
||||
def describe(plan) -> str:
|
||||
""""Sundays at 03:00 (Europe/London), up to 4 h" for the page."""
|
||||
hours = plan.window_minutes / 60
|
||||
span = f"{hours:g} h" if plan.window_minutes % 60 == 0 else f"{plan.window_minutes} min"
|
||||
return f"{WEEKDAYS[plan.weekday]}s at {plan.at_time} ({plan.time_zone}), up to {span}"
|
||||
|
||||
|
||||
# ── applied to the database: the minute tick, Run now, the page ──────────────
|
||||
|
||||
async def group_hosts(session, group_id: str) -> list:
|
||||
|
||||
Reference in New Issue
Block a user