"""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)