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>
222 lines
10 KiB
Python
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)
|