feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s

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>
This commit is contained in:
2026-10-24 22:06:43 -04:00
co-authored by Claude Opus 5.5
parent 1ea86c0330
commit 2a561210c3
40 changed files with 1786 additions and 35 deletions
+87 -16
View File
@@ -84,8 +84,9 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
return out
def _area(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it."""
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()):
@@ -93,6 +94,22 @@ def _area(cfg, area: str) -> str:
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
@@ -115,29 +132,22 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
4000 characters).
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
Shown as by "<you> via MCP"; the person whose token this is can edit or
delete it from the Timeline page. Recorded in the audit log.
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 = None
if (at or "").strip():
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
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
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(cfg, area), hosts=hosts or [],
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:
if "No host is named" in str(exc):
raise ValueError(f"{exc} list_hosts gives the names.") from None
raise
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,
@@ -145,6 +155,67 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
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)
register_tool(log_change, role=UserRole.operator, read_only=False)
for fn in (log_change, update_change, delete_change):
register_tool(fn, role=UserRole.operator, read_only=False)