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
+224
View File
@@ -0,0 +1,224 @@
"""Tickets: known problems, open until someone resolves them, worked from MCP
the same as from the Tickets pages (steward.tickets.service)."""
from __future__ import annotations
from steward.mcp._context import current_caller
from steward.models.users import UserRole
from steward.tickets import service as tk
from steward.timeline import config as tl_config
from ._audit import audit_mcp, mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso
from .timeline import area_key, host_hint
LIST_MAX = 200
def brief(t) -> dict:
"""A ticket in a list, or carried by another tool's reply (get_overview,
get_host, get_monitor, get_container, list_alerts, list_anomalies)."""
return {"ticket": t.id, "title": t.title, "status": t.status, "area": t.area, "hosts": t.hosts,
"subject": t.subject, "opened_at": iso(t.opened_at), "opened_by": t.opened_by,
"updated_at": iso(t.updated_at), "age_s": age_s(t.opened_at),
"resolved_at": iso(t.resolved_at), "resolved_by": t.resolved_by}
async def open_tickets_for(db, *, host: str = "", subject: str = "") -> list[dict]:
return [brief(t) for t in await tk.for_page(db, host=host, subject=subject)]
async def _full(db, t) -> dict:
log = await tk.notes(db, t.id)
return {
**brief(t),
"description": t.body,
"resolution": t.resolution,
"links": await tk.link_views(db, await tk.links_of(db, t.id)),
"log": [{"at": iso(n.at), "kind": n.kind, "by": n.actor, "text": n.body} for n in log],
"url": f"/tickets/{t.id}",
}
async def _ticket(db, ticket):
t = await tk.get(db, ticket)
if t is None:
raise ValueError(f"No ticket #{str(ticket).lstrip('#')}; list_tickets lists them.")
return t
def _hosts(hosts) -> list[str]:
if isinstance(hosts, str):
hosts = hosts.split(",")
return [h.strip() for h in (hosts or []) if h and h.strip()]
def _links(alert_rules=None, anomalies=None) -> list[tuple[str, str]]:
def ids(v):
return [i.strip() for i in (v.split(",") if isinstance(v, str) else (v or [])) if i and str(i).strip()]
return [("alert_rule", i) for i in ids(alert_rules)] + [("anomaly", i) for i in ids(anomalies)]
async def list_tickets(status: str = "open", host: str = "", subject: str = "", area: str = "",
limit: int = 50) -> dict:
"""Tickets: known problems someone recorded, open until resolved. Open
ones come first, most recently worked on first.
Read the open tickets before reporting what is wrong: an alert or anomaly
an open ticket covers is already known (list_alerts and list_anomalies
mark it `known_issue`), and the ticket says what has been tried.
status: "open" (default), "resolved" or "all".
host: a host's name, exactly; empty for every host.
subject: what a ticket is about, exactly (a resource, "<host>/<container>",
a monitor's name); empty for any.
area: an area key (get_timeline's `areas`); empty for all.
limit: default 50, max 200.
"""
if status not in (*tk.STATUSES, "all"):
raise ValueError('status is "open", "resolved" or "all".')
limit = clamp_limit(limit, 50, LIST_MAX)
async with db_session() as db:
rows = list((await db.execute(tk.query(status=status, host=host.strip(), subject=subject.strip(),
area=area.strip()).limit(limit + 1))).scalars())
open_n = await tk.open_count(db)
out = {**bounded([brief(t) for t in rows], limit, key="tickets"), "open_total": open_n}
if out["truncated"]:
out["total"] = None
out["more"] = f"More than {limit} tickets. Pass a larger `limit` (max {LIST_MAX}) or filter."
return out
async def get_ticket(ticket: int | str) -> dict:
"""One ticket in full: its description, what it links to (alert rules,
anomalies), its resolution, and its log of comments, edits, resolving and
reopening, oldest first.
ticket: its number (12 or "#12").
"""
async with db_session() as db:
return await _full(db, await _ticket(db, ticket))
async def open_ticket(title: str, area: str, description: str = "", hosts: list[str] | str | None = None,
subject: str = "", alert_rules: list[str] | str | None = None,
anomalies: list[str] | str | None = None) -> dict:
"""Open a ticket for a known problem that will take more than a moment to
fix: a disk failing slowly, a service that keeps restarting, a backup that
times out at night. Acknowledging an alert only quiets it; a ticket keeps
what is known, what was tried and, once resolved, what fixed it, and it
marks the alerts and anomalies it covers as a known issue. Check
list_tickets first for one already open on it.
title: what is wrong, one line (up to 200 characters).
area: what part of the infrastructure it is about, by key or label:
service, host_os, hardware, storage, network, security, monitoring
(get_timeline's `areas`).
description: what is seen, since when, what has been tried (optional).
hosts: the host(s) it is on, by exact name (list_hosts).
subject: what it is about when that is not a host as a whole: an alert
rule's resource, "<host>/<container>", a monitor's name. An alert or
anomaly on this resource reads as a known issue while it is open.
alert_rules: alert rule ids to link (list_alerts' `rule_id`).
anomalies: anomaly ids to link (list_anomalies' `anomaly_id`).
Opening it goes on the timeline. Recorded in the audit log as "<you> via
MCP".
"""
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await tk.fields(db, cfg, title=title, area=area_key(cfg, area), body=description,
hosts=_hosts(hosts), subject=subject)
except ValueError as exc:
raise host_hint(exc) from None
links = await tk.check_links(db, _links(alert_rules, anomalies))
t = await tk.open_ticket(db, f, actor=mcp_username(caller.username), author_id=caller.user_id,
links=links)
out = await _full(db, t)
await audit_mcp("ticket.opened", "ticket", str(t.id),
{"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]})
return out
async def update_ticket(ticket: int | str, title: str | None = None, area: str | None = None,
description: str | None = None, hosts: list[str] | str | None = None,
subject: str | None = None, link_alert_rules: list[str] | str | None = None,
link_anomalies: list[str] | str | None = None,
unlink_alert_rules: list[str] | str | None = None,
unlink_anomalies: list[str] | str | None = None) -> dict:
"""Change a ticket: its title, area, description, hosts or subject, and
what it links to. Only what you pass changes; an empty string clears the
description or subject, an empty list clears the hosts. To add to the
log, use comment_ticket; to close it, resolve_ticket.
ticket: its number. The rest as open_ticket; link_*/unlink_* take ids.
Recorded in the ticket's log and the audit log.
"""
caller = current_caller()
async with db_session() as db:
async with db.begin():
t = await _ticket(db, ticket)
cfg = await tl_config.load(db)
try:
f = await tk.fields(db, cfg, title=t.title if title is None else title,
area=t.area if area is None else area_key(cfg, area),
body=t.body if description is None else description,
hosts=t.hosts if hosts is None else _hosts(hosts),
subject=t.subject if subject is None else subject)
except ValueError as exc:
raise host_hint(exc) from None
add = await tk.check_links(db, _links(link_alert_rules, link_anomalies))
changed = await tk.edit(db, t, f, actor=mcp_username(caller.username), add_links=add,
remove_links=_links(unlink_alert_rules, unlink_anomalies))
out = await _full(db, t)
if changed:
await audit_mcp("ticket.edited", "ticket", str(t.id), {"name": t.title, "changed": changed})
return {**out, "changed": changed}
async def comment_ticket(ticket: int | str, comment: str) -> dict:
"""Add a comment to a ticket's log: what you found, what you tried, what
happened. Works on a resolved ticket too.
ticket: its number. comment: up to 8000 characters.
Recorded in the audit log.
"""
return await _act(ticket, "ticket.commented", tk.comment, comment)
async def resolve_ticket(ticket: int | str, resolution: str) -> dict:
"""Resolve an open ticket. `resolution` says what fixed it, or why it no
longer matters; it goes on the timeline as the record of the fix, so
write it for someone reading the host's history later.
ticket: its number. Recorded in the audit log.
"""
return await _act(ticket, "ticket.resolved", tk.resolve, resolution)
async def reopen_ticket(ticket: int | str, reason: str = "") -> dict:
"""Reopen a resolved ticket: the problem is back, or the fix did not
hold. `reason` says why (optional). Goes on the timeline; recorded in the
audit log.
"""
return await _act(ticket, "ticket.reopened", tk.reopen, reason)
async def _act(ticket, action: str, fn, text: str) -> dict:
caller = current_caller()
async with db_session() as db:
async with db.begin():
t = await _ticket(db, ticket)
await fn(db, t, text, actor=mcp_username(caller.username))
out = await _full(db, t)
await audit_mcp(action, "ticket", str(t.id), {"name": t.title})
return out
def register(register_tool) -> None:
register_tool(list_tickets, role=UserRole.viewer, read_only=True)
register_tool(get_ticket, role=UserRole.viewer, read_only=True)
for fn in (open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket):
register_tool(fn, role=UserRole.operator, read_only=False)