Files
FabledSteward/steward/mcp/tools/timeline.py
T
bvandeusenandClaude Opus 5.5 2b97c36fd4
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m36s
CI / integration (push) Successful in 1m52s
CI / publish (push) Successful in 1m12s
feat(timeline): maintenance, reboots, deploys, crash-loops and reviewed anomalies on the timeline (#6349)
The host agent writes each maintenance job once it ends, and each stretch a
host was away (a reboot, a crash, lost contact, the agent restarting), kept up
to date until it is back. Docker judges each snapshot: a unit on a new image,
or a container recreated, is a deploy; deaths past the Thresholds crash-loop
count are one crash-loop entry that later deaths extend; single restarts are
recorded but hidden. An anomaly has one entry whose type follows its review:
only those judged real are shown by default.

A source whose record changes keeps one entry for it (record_or_update).
Plugins register the records their entries point to, so an aged-out job or
downtime reads "Details no longer kept". The first fill runs once per source
(timeline.filled), the host agent's jobs and downtime included.

get_anomaly's "what else was going on" reads the timeline for the host or
resource instead of querying alerts, runs, maintenance, downtime and container
deaths itself; Docker's own source keeps only starts, stops and health
changes. A new kind, Observed, is for what Steward saw happen with no rule or
person behind it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 17:21:44 -04:00

81 lines
3.9 KiB
Python

"""The timeline: everything that happened to the infrastructure, in one list."""
from __future__ import annotations
from steward.models.timeline import TimelineEntry
from steward.models.users import UserRole
from steward.timeline import catalog, config as tl_config, service as tl
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str = "",
range: str = "7d", include_hidden: bool = False, limit: int = 50) -> dict:
"""What happened to the infrastructure, newest first, from every source in
one list: alerts firing, resolving and going silent; playbook runs and
what they changed; maintenance runs, reboots and lost contact; deploys
and crash-loops; anomalies once reviewed as real; changes to Steward's
own settings, users and backups. Each entry outlives the record it came
from, so this reaches further back than get_alert_history,
list_ansible_runs or list_audit_events.
Reach for it first for "what changed on this host lately?" or "what else
was going on when this broke?". Follow an entry's `url` or ids into the
tool for that record while it is still kept (`details_kept`).
host: a host's name, exactly; empty for every host.
area: what part of the infrastructure an entry is about, as Settings →
Timeline names its areas (`areas` in the reply lists them); empty for all.
kind: how it came about: "change", "automation", "alert", "anomaly",
"ticket", "observed" (seen happening: a reboot, a crash-loop) or
"steward"; empty for all.
type: one entry type (`types` in the reply); it is returned even if hidden.
range: "1h", "6h", "24h", "7d" (default) or "30d".
include_hidden: also the types Settings hides from the timeline (dry runs,
sign-ins, playbook runs that changed nothing, single container
restarts, anomalies not reviewed as real).
limit: default 50, max 500.
"""
if kind and kind not in catalog.KINDS:
raise ValueError(f"kind {kind!r} is not one of {', '.join(catalog.KINDS)}.")
since, key = lookback(range)
limit = clamp_limit(limit, 50, 500)
async with db_session() as db:
cfg = await tl_config.load(db)
if area and area not in cfg.labels and area != catalog.OTHER[0]:
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)}.")
stmt = tl.query(cfg, host=host.strip(), area=area, kind=kind, type_=type,
since=since, include_hidden=include_hidden)
rows = (await db.execute(stmt.order_by(TimelineEntry.at.desc(), TimelineEntry.id.desc())
.limit(limit + 1))).scalars().all()
items = await tl.views(db, cfg, rows)
entries = [
{
"at": iso(v["entry"].at),
"age_s": age_s(v["entry"].at),
"until": iso(v["entry"].until),
"type": v["entry"].type,
"kind": v["kind"],
"area": v["area"],
"summary": v["entry"].summary,
"note": v["note"],
"hosts": v["entry"].hosts,
"subject": v["entry"].subject,
"by": v["entry"].actor,
"url": v["url"],
"details_kept": not v["gone"],
"ref": {"type": v["entry"].ref_type, "id": v["entry"].ref_id} if v["entry"].ref_type else None,
}
for v in items
]
out = {"range": key, **bounded(entries, limit, key="entries"),
"areas": dict(cfg.labels), "types": sorted(catalog.TYPES)}
if out["truncated"]:
out["total"] = None
out["more"] = (f"More than {limit} entries in range. Pass a larger `limit` (max 500), a "
"shorter `range`, or filter by `host`, `area`, `kind` or `type`.")
return out
def register(register_tool) -> None:
register_tool(get_timeline, role=UserRole.viewer, read_only=True)