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