feat(timeline): one record of what happened to the infrastructure, newest first (#6348)
A stored log, timeline_entries, with one row per thing that happened. Each source writes its row in the same transaction as its own record, so the history outlives the 30/90/365-day retention of the tables it points to: - alert events (core/alerts._add_event): fired, resolved, acknowledged, went silent, readings back; - finished Ansible runs (executor, and the startup sweep of interrupted runs): changed something, changed nothing, failed, dry run; - audit rows (core/audit.log_audit): Steward's settings, users and access, sign-ins, backups. Actions another writer records are left to it. Entries carry a kind (fixed) and an area (Service, Host OS, Hardware, Storage, Network, Security, Monitoring). Settings → Timeline edits the areas and each type's area and visibility; both are applied when the timeline is read, so a change re-sorts past entries and a hidden type comes back when shown. Kept forever unless a retention is set. The first start fills the timeline from the last 90 days of records once, skipping any already covered. A host rename carries its entries. UI: Timeline in the sidebar (filter by host, area, kind; scrolls on), a History card on each host page. MCP: get_timeline. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,10 @@ each argument's empty value means, and what comes back.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from . import alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session
|
||||
from . import (alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session,
|
||||
timeline)
|
||||
|
||||
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, automation,
|
||||
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, automation,
|
||||
backups)
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from steward.mcp._context import current_caller, mcp_app
|
||||
from steward.models.alerts import AlertOperator, AlertRule, AlertState, MaintenanceWindow
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from ._audit import audit_mcp
|
||||
from ._audit import audit_mcp, mcp_username
|
||||
from ._shape import (
|
||||
age_s, bounded, clamp_limit, db_session, iso, lookback, now_utc, pick, rnd,
|
||||
)
|
||||
@@ -389,7 +389,8 @@ async def acknowledge_alert(rule: str) -> dict:
|
||||
async with db_session() as db, db.begin():
|
||||
r = await _rule(db, rule)
|
||||
try:
|
||||
state = await alert_svc.acknowledge(db, r.id, caller.user_id)
|
||||
state = await alert_svc.acknowledge(db, r.id, caller.user_id,
|
||||
mcp_username(caller.username))
|
||||
except LookupError:
|
||||
raise ValueError(f"{r.name} has never been evaluated, so there is nothing to "
|
||||
"acknowledge.") from None
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""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; 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" 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).
|
||||
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)
|
||||
Reference in New Issue
Block a user