Files
FabledSteward/steward/mcp/tools/timeline.py
T
bvandeusenandClaude Opus 5.5 2a561210c3
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s
feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
A ticket records a known problem: title, area, hosts, subject, description,
links to alert rules and anomalies, and a log of comments, edits, resolving
and reopening. Opening, resolving (with the resolution as the note) and
reopening go on the timeline; every step is audited, and the audit rows stay
off the timeline.

An alert or anomaly an open ticket covers (by link, by subject, or a
host-wide ticket on the host itself) shows a Known issue badge, and offers
"Open a ticket" prefilled from its rule or anomaly when none does. Hosts,
monitors and containers get a Known issues card.

MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket,
comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and
list_anomalies carry known_issue; get_overview, get_host, get_monitor and
get_container carry open_tickets. Also update_change and delete_change for
changes recorded by hand (author or admin).

Tickets are left out of backups, like the timeline (2026-10-15 ruling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:06:43 -04:00

222 lines
10 KiB
Python

"""The timeline: everything that happened to the infrastructure, in one
list, and changes recorded on it by hand."""
from __future__ import annotations
from datetime import datetime, timezone
from steward.mcp._context import current_caller
from steward.models.timeline import TimelineEntry
from steward.models.users import UserRole
from steward.timeline import catalog, changes, config as tl_config, service as tl
from ._audit import audit_mcp, mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso, lookback
def _entry(v: dict) -> dict:
e = v["entry"]
return {
"entry_id": e.id,
"at": iso(e.at),
"age_s": age_s(e.at),
"until": iso(e.until),
"type": e.type,
"kind": v["kind"],
"area": v["area"],
"summary": e.summary,
"note": v["note"],
"hosts": e.hosts,
"subject": e.subject,
"by": e.actor,
"url": v["url"],
"details_kept": not v["gone"],
"ref": {"type": e.ref_type, "id": e.ref_id} if e.ref_type else None,
}
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 = [_entry(v) 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 area_key(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it
(log_change, and the ticket tools)."""
want = (area or "").strip().lower()
for key, label in cfg.areas:
if want in (key, label.lower()):
return key
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
def _when(at: str):
"""An ISO-8601 time (no zone is UTC), or None for an empty one."""
if not (at or "").strip():
return None
try:
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
def host_hint(exc: ValueError) -> ValueError:
"""A refusal naming a host that isn't one, pointed at the tool that lists them."""
return ValueError(f"{exc} list_hosts gives the names.") if "No host is named" in str(exc) else exc
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
detail: str = "", at: str = "") -> dict:
"""Record on the timeline a change Steward can't see happen: a config
edited by hand, a package installed, a disk swapped, a service moved, a
setting changed in another system. Record one whenever you, or the person
you work with, change the infrastructure outside Steward's own tools, so
"what changed on this host lately?" has the answer later. Playbook runs,
maintenance, deploys and Steward's own settings are recorded already.
summary: what was done, one line (up to 300 characters), e.g. "Moved
Postgres data to the new SSD".
area: what part of the infrastructure it is about, by key or label:
service, host_os, hardware, storage, network, security, monitoring, as
Settings → Timeline names them (get_timeline's `areas`).
hosts: the host(s) it was done on, by exact name (list_hosts); none for a
change not on any one host.
subject: what it was done to, when that is not a host: a container, a
service, a file ("postgres", "/etc/fstab").
detail: why and how, and anything worth knowing if it goes wrong (up to
4000 characters).
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
Shown as by "<you> via MCP". It can be corrected with update_change or
taken back with delete_change, by the person whose token this is or an
admin. Recorded in the audit log.
"""
if isinstance(hosts, str):
hosts = hosts.split(",")
when = _when(at)
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await changes.fields(db, cfg, summary=summary, area=area_key(cfg, area), hosts=hosts or [],
subject=subject, note=detail, at=when)
except ValueError as exc:
raise host_hint(exc) from None
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
{"name": entry.summary, "hosts": entry.hosts, "area": entry.area})
return _entry(v)
async def _own(db, entry_id: str):
"""A recorded change the caller may edit: one their token's user wrote,
or any for an admin."""
caller = current_caller()
entry = await changes.get(db, entry_id)
if entry is None:
raise ValueError(f"No recorded change has the id {entry_id!r} (get_timeline's `entry_id`, "
"on an entry of type change.recorded).")
if not changes.can_edit(entry, caller.user_id, getattr(caller.role, "value", caller.role)):
raise ValueError(f"Change {entry_id} was recorded by {entry.actor}; only they or an admin can change it.")
return entry
async def update_change(entry_id: str, summary: str | None = None, area: str | None = None,
hosts: list[str] | str | None = None, subject: str | None = None,
detail: str | None = None, at: str | None = None) -> dict:
"""Correct a change recorded with log_change (or on the Timeline page):
only what you pass changes; an empty string clears the subject or
detail, an empty list the hosts. Only its author or an admin can.
entry_id: the change's `entry_id` (log_change's reply, or get_timeline).
The rest as log_change. Recorded in the audit log.
"""
async with db_session() as db:
async with db.begin():
entry = await _own(db, entry_id)
cfg = await tl_config.load(db)
if isinstance(hosts, str):
hosts = hosts.split(",")
try:
f = await changes.fields(
db, cfg, summary=entry.summary if summary is None else summary,
area=entry.area if area is None else area_key(cfg, area),
hosts=entry.hosts if hosts is None else hosts,
subject=(entry.subject or "") if subject is None else subject,
note=((entry.detail or {}).get("note") or "") if detail is None else detail,
at=entry.at if at is None else _when(at))
except ValueError as exc:
raise host_hint(exc) from None
changes.edit(entry, f)
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_edited", "timeline_entry", entry.id, {"name": entry.summary})
return _entry(v)
async def delete_change(entry_id: str) -> dict:
"""Take a recorded change off the timeline: one logged by mistake, or
twice. Only its author or an admin can; the audit log keeps that it was.
entry_id: the change's `entry_id`.
"""
async with db_session() as db:
async with db.begin():
entry = await _own(db, entry_id)
summary = entry.summary
await db.delete(entry)
await audit_mcp("timeline.change_deleted", "timeline_entry", entry_id, {"name": summary})
return {"deleted": entry_id, "summary": summary}
def register(register_tool) -> None:
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
for fn in (log_change, update_change, delete_change):
register_tool(fn, role=UserRole.operator, read_only=False)