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
+1
View File
@@ -0,0 +1 @@
"""Tickets: known problems, open until someone resolves them."""
+213
View File
@@ -0,0 +1,213 @@
"""The Tickets pages: the list (open first), one ticket with its log, the
form that opens or edits one, and the card a host's or a resource's page
loads with its open tickets. Writes are for operators; each is audited."""
from __future__ import annotations
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from steward.auth.middleware import require_role, safe_next_url
from steward.core.audit import audit_request
from steward.models.users import UserRole
from steward.tickets import service as tk
from steward.timeline import config as tl_config
tickets_bp = Blueprint("tickets", __name__)
def _actor() -> str:
return session.get("username", "")
@tickets_bp.get("/tickets/")
@require_role(UserRole.viewer)
async def list_tickets():
status = request.args.get("status", tk.OPEN)
status = status if status in (*tk.STATUSES, "all") else tk.OPEN
host = request.args.get("host", "").strip()
async with current_app.db_sessionmaker() as db:
cfg = await tl_config.load(db)
tickets = list((await db.execute(tk.query(status=status, host=host).limit(500))).scalars())
counts = await tk.comment_counts(db, [t.id for t in tickets])
open_n = await tk.open_count(db)
return await render_template("tickets/list.html", tickets=tickets, counts=counts, status=status,
host=host, open_count=open_n, labels=cfg.labels)
def _parse_links(values) -> list[tuple[str, str]]:
out = []
for v in values:
ref_type, _, ref_id = (v or "").partition(":")
if ref_type and ref_id:
out.append((ref_type, ref_id))
return out
async def _form_page(db, cfg, *, values: dict, links, ticket=None, back: str, error: str | None = None,
status: int = 200):
from sqlalchemy import select
from steward.models.hosts import Host
hosts = list((await db.execute(select(Host.name).order_by(Host.name))).scalars())
hosts += [h for h in values["hosts"] if h not in hosts]
return await render_template("tickets/form.html", values=values, ticket=ticket, back=back, error=error,
hosts=hosts, areas=cfg.areas, links=await tk.link_views(db, links)), status
@tickets_bp.get("/tickets/new")
@require_role(UserRole.operator)
async def new_ticket():
"""The form for opening a ticket; ?rule=, ?anomaly=, ?host= or ?subject=
fill it from what it is opened on, ?back= is where Cancel goes."""
a = request.args
async with current_app.db_sessionmaker() as db:
cfg = await tl_config.load(db)
values, links = await tk.draft(db, rule_id=a.get("rule", ""), anomaly_id=a.get("anomaly", ""),
host=a.get("host", "").strip(), subject=a.get("subject", "").strip())
return await _form_page(db, cfg, values=values, links=links,
back=safe_next_url(a.get("back")) or "/tickets/")
def _values(form) -> dict:
return {"title": form.get("title", ""), "area": form.get("area", ""), "body": form.get("body", ""),
"hosts": form.getlist("hosts"), "subject": form.get("subject", "")}
async def _checked(db, cfg, values) -> tuple[dict | None, str | None]:
try:
return await tk.fields(db, cfg, title=values["title"], area=values["area"], body=values["body"],
hosts=values["hosts"], subject=values["subject"]), None
except ValueError as exc:
return None, str(exc)
@tickets_bp.post("/tickets/")
@require_role(UserRole.operator)
async def open_ticket():
form = await request.form
values = _values(form)
back = safe_next_url(form.get("back")) or "/tickets/"
async with current_app.db_sessionmaker() as db:
async with db.begin():
cfg = await tl_config.load(db)
links = _parse_links(form.getlist("link"))
f, error = await _checked(db, cfg, values)
if error is None:
try:
links = await tk.check_links(db, links)
except ValueError as exc:
error = str(exc)
if error:
return await _form_page(db, cfg, values=values, links=links, back=back, error=error, status=400)
t = await tk.open_ticket(db, f, actor=_actor(), author_id=session.get("user_id"), links=links)
await audit_request("ticket.opened", entity_type="ticket", entity_id=str(t.id),
detail={"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]})
return redirect(f"/tickets/{t.id}")
async def _ticket(db, number: int):
t = await tk.get(db, number)
if t is None:
abort(404)
return t
@tickets_bp.get("/tickets/<int:number>")
@require_role(UserRole.viewer)
async def ticket_page(number: int):
async with current_app.db_sessionmaker() as db:
t = await _ticket(db, number)
cfg = await tl_config.load(db)
log = await tk.notes(db, t.id)
links = await tk.link_views(db, await tk.links_of(db, t.id))
return await render_template("tickets/ticket.html", t=t, log=log, links=links,
area_label=cfg.area_label(cfg.follow(t.area)))
@tickets_bp.get("/tickets/<int:number>/edit")
@require_role(UserRole.operator)
async def edit_ticket(number: int):
async with current_app.db_sessionmaker() as db:
t = await _ticket(db, number)
cfg = await tl_config.load(db)
values = {"title": t.title, "area": t.area, "body": t.body or "", "hosts": list(t.hosts),
"subject": t.subject or ""}
return await _form_page(db, cfg, values=values, links=await tk.links_of(db, t.id), ticket=t,
back=f"/tickets/{t.id}")
@tickets_bp.post("/tickets/<int:number>")
@require_role(UserRole.operator)
async def save_ticket(number: int):
"""Save an edit. Each link the form lists is kept while its box stays
ticked; an unticked one is unlinked."""
form = await request.form
values = _values(form)
async with current_app.db_sessionmaker() as db:
async with db.begin():
t = await _ticket(db, number)
cfg = await tl_config.load(db)
have = await tk.links_of(db, t.id)
keep = set(_parse_links(form.getlist("link")))
f, error = await _checked(db, cfg, values)
if error:
return await _form_page(db, cfg, values=values, links=have, ticket=t, back=f"/tickets/{t.id}",
error=error, status=400)
changed = await tk.edit(db, t, f, actor=_actor(), remove_links=[r for r in have if r not in keep])
if changed:
await audit_request("ticket.edited", entity_type="ticket", entity_id=str(number),
detail={"name": f["title"], "changed": changed})
return redirect(f"/tickets/{number}")
async def _act(number: int, action: str, fn):
"""Comment, resolve or reopen from the ticket page: back to it, or the
page again with why it was refused."""
form = await request.form
text = form.get("text", "")
async with current_app.db_sessionmaker() as db:
async with db.begin():
t = await _ticket(db, number)
try:
await fn(db, t, text, actor=_actor())
except ValueError as exc:
error, title = str(exc), t.title
else:
error, title = None, t.title
if error:
cfg = await tl_config.load(db)
log = await tk.notes(db, t.id)
links = await tk.link_views(db, await tk.links_of(db, t.id))
return await render_template("tickets/ticket.html", t=t, log=log, links=links, error=error,
text=text, area_label=cfg.area_label(cfg.follow(t.area))), 400
await audit_request(action, entity_type="ticket", entity_id=str(number), detail={"name": title})
return redirect(f"/tickets/{number}")
@tickets_bp.post("/tickets/<int:number>/comment")
@require_role(UserRole.operator)
async def comment_ticket(number: int):
return await _act(number, "ticket.commented", tk.comment)
@tickets_bp.post("/tickets/<int:number>/resolve")
@require_role(UserRole.operator)
async def resolve_ticket(number: int):
return await _act(number, "ticket.resolved", tk.resolve)
@tickets_bp.post("/tickets/<int:number>/reopen")
@require_role(UserRole.operator)
async def reopen_ticket(number: int):
return await _act(number, "ticket.reopened", tk.reopen)
@tickets_bp.get("/tickets/card")
@require_role(UserRole.viewer)
async def card():
"""The open tickets a page lists (?host= or ?subject=), loaded into a
host's, a monitor's or a container's page; ?back= returns there."""
host = request.args.get("host", "").strip()
subject = request.args.get("subject", "").strip()
async with current_app.db_sessionmaker() as db:
tickets = await tk.for_page(db, host=host, subject=subject)
return await render_template("tickets/_card.html", tickets=tickets, host=host, subject=subject,
back=safe_next_url(request.args.get("back")) or "/tickets/")
+316
View File
@@ -0,0 +1,316 @@
"""Tickets: known problems, open until someone resolves them. The pages and
the MCP tools both go through here, so a ticket opened, commented on,
edited, resolved or reopened either way leaves the same record.
A ticket is attached to hosts and to what it is about (`subject`: an alert
rule's resource, a container "<host>/<name>", a monitor's name), and may link
to alert rules and metric anomalies. Opening, resolving and reopening each
write a timeline entry under the ticket's area, so the resolution note is
the changelog's record of the fix. Edits and comments go in the ticket's own
log (TicketNote).
An alert or anomaly is a known issue while an open ticket links to it, names
its resource as its subject, or is about its host as a whole (no subject,
the resource is the host).
"""
from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import and_, func, or_, select, tuple_
from steward.models.tickets import Ticket, TicketLink, TicketNote
from steward.timeline.config import Config
from steward.timeline.record import record
OPEN, RESOLVED = "open", "resolved"
STATUSES = (OPEN, RESOLVED)
TITLE_MAX = 200
TEXT_MAX = 8000
SUBJECT_MAX = 255
# What a ticket links to: an alert rule or a metric anomaly, by id.
LINK_TYPES = {"alert_rule": "alert rule", "anomaly": "anomaly"}
def _now() -> datetime:
return datetime.now(timezone.utc)
def _clean(text: str | None, limit: int, what: str) -> str | None:
text = (text or "").strip()
if len(text) > limit:
raise ValueError(f"Keep {what} to {limit} characters.")
return text or None
async def fields(db, cfg: Config, *, title: str, area: str, body: str = "", hosts=(),
subject: str = "") -> dict:
"""A ticket's fields, checked: a title, an area Settings → Timeline lists,
and hosts Steward knows by name. Raises ValueError saying what to fix."""
from steward.models.hosts import Host
title = " ".join((title or "").split())
if not title:
raise ValueError("Give the ticket a title: what is wrong.")
if len(title) > TITLE_MAX:
raise ValueError(f"Keep the title to {TITLE_MAX} characters; put the rest in the description.")
if area not in cfg.labels:
raise ValueError(f"Pick an area: {', '.join(cfg.labels.values())}.")
wanted = sorted({h.strip() for h in hosts if h and h.strip()})
known = set((await db.execute(select(Host.name).where(Host.name.in_(wanted)))).scalars()) if wanted else set()
unknown = [h for h in wanted if h not in known]
if unknown:
raise ValueError(f"No host is named {', '.join(unknown)}.")
return {"title": title, "area": area, "hosts": wanted,
"body": _clean(body, TEXT_MAX, "the description"),
"subject": ((subject or "").strip()[:SUBJECT_MAX]) or None}
async def check_links(db, links) -> list[tuple[str, str]]:
"""`links` ((type, id) pairs) as they are stored, each checked to name an
alert rule or anomaly that exists. Raises ValueError for one that doesn't."""
from steward.models.alerts import AlertRule
from steward.models.norms import MetricAnomaly
models = {"alert_rule": AlertRule, "anomaly": MetricAnomaly}
out = []
for ref_type, ref_id in links:
if ref_type not in models:
raise ValueError(f"A ticket links to an {' or '.join(LINK_TYPES.values())}, not {ref_type!r}.")
if await db.get(models[ref_type], str(ref_id)) is None:
raise ValueError(f"No {LINK_TYPES[ref_type]} has the id {ref_id}.")
if (ref_type, str(ref_id)) not in out:
out.append((ref_type, str(ref_id)))
return out
def _entry(db, t: Ticket, type_: str, at: datetime, actor: str, note: str | None = None) -> None:
said = {"ticket.opened": "opened", "ticket.resolved": "resolved", "ticket.reopened": "reopened"}[type_]
record(db, type=type_, at=at, summary=f"Ticket #{t.id} {said}: {t.title}", area=t.area,
hosts=t.hosts, subject=t.subject, actor=actor,
detail={"note": note, "ticket": t.id, "url": f"/tickets/{t.id}"})
def _note(db, t: Ticket, kind: str, actor: str, at: datetime, body: str | None = None) -> None:
db.add(TicketNote(ticket_id=t.id, at=at, kind=kind, actor=(actor or "system")[:64], body=body))
async def open_ticket(db, f: dict, *, actor: str, author_id: str | None, links=(),
now: datetime | None = None) -> Ticket:
"""Open a ticket from checked `fields` and checked `links`, with its
first log line and its timeline entry. Call inside a transaction."""
now = now or _now()
t = Ticket(title=f["title"], body=f["body"], area=f["area"], hosts=f["hosts"], subject=f["subject"],
status=OPEN, opened_at=now, opened_by=(actor or "system")[:64], opened_by_id=author_id,
updated_at=now)
db.add(t)
await db.flush() # its number
for ref_type, ref_id in links:
db.add(TicketLink(ticket_id=t.id, ref_type=ref_type, ref_id=ref_id))
_note(db, t, "opened", actor, now)
_entry(db, t, "ticket.opened", now, actor)
return t
async def edit(db, t: Ticket, f: dict, *, actor: str, add_links=(), remove_links=(),
now: datetime | None = None) -> list[str]:
"""Apply checked `fields` and link changes; the names of what changed,
logged as one "edited" line. Nothing changed logs nothing."""
now = now or _now()
changed = [k for k in ("title", "body", "area", "hosts", "subject") if getattr(t, k) != f[k]]
for k in changed:
setattr(t, k, f[k])
have = set(await links_of(db, t.id))
for ref in add_links:
if ref not in have:
db.add(TicketLink(ticket_id=t.id, ref_type=ref[0], ref_id=ref[1]))
changed.append(f"linked {LINK_TYPES[ref[0]]}")
for ref in remove_links:
if ref in have:
await db.delete(await db.get(TicketLink, (t.id, ref[0], ref[1])))
changed.append(f"unlinked {LINK_TYPES[ref[0]]}")
if changed:
t.updated_at = now
_note(db, t, "edited", actor, now, ", ".join(changed))
return changed
async def comment(db, t: Ticket, body: str, *, actor: str, now: datetime | None = None) -> None:
body = _clean(body, TEXT_MAX, "a comment")
if not body:
raise ValueError("Write the comment.")
now = now or _now()
t.updated_at = now
_note(db, t, "comment", actor, now, body)
async def resolve(db, t: Ticket, resolution: str, *, actor: str, now: datetime | None = None) -> None:
"""Resolve an open ticket. The resolution says what fixed it (or why it
no longer matters) and becomes its timeline entry's note."""
if t.status != OPEN:
raise ValueError(f"Ticket #{t.id} is already resolved.")
resolution = _clean(resolution, TEXT_MAX, "the resolution")
if not resolution:
raise ValueError("Say what resolved it: what was done, or why it no longer matters.")
now = now or _now()
t.status, t.resolved_at, t.resolved_by, t.resolution, t.updated_at = (
RESOLVED, now, (actor or "system")[:64], resolution, now)
_note(db, t, "resolved", actor, now, resolution)
_entry(db, t, "ticket.resolved", now, actor, resolution)
async def reopen(db, t: Ticket, reason: str = "", *, actor: str, now: datetime | None = None) -> None:
if t.status != RESOLVED:
raise ValueError(f"Ticket #{t.id} is open already.")
reason = _clean(reason, TEXT_MAX, "the reason")
now = now or _now()
t.status, t.resolved_at, t.resolved_by, t.resolution, t.updated_at = OPEN, None, None, None, now
_note(db, t, "reopened", actor, now, reason)
_entry(db, t, "ticket.reopened", now, actor, reason)
# ── Reading ─────────────────────────────────────────────────────────────────
def query(*, status: str = OPEN, host: str = "", subject: str = "", area: str = ""):
"""Tickets matching the filters: open ones by most recently touched,
or resolved ones by when they were resolved. `status` "all" is both,
open first."""
stmt = select(Ticket)
if status in STATUSES:
stmt = stmt.where(Ticket.status == status)
if host:
stmt = stmt.where(Ticket.hosts.contains([host]))
if subject:
stmt = stmt.where(Ticket.subject == subject)
if area:
stmt = stmt.where(Ticket.area == area)
if status == RESOLVED:
return stmt.order_by(Ticket.resolved_at.desc(), Ticket.id.desc())
return stmt.order_by((Ticket.status != OPEN), Ticket.updated_at.desc(), Ticket.id.desc())
async def get(db, number) -> Ticket | None:
try:
return await db.get(Ticket, int(str(number).lstrip("#")))
except ValueError:
return None
async def notes(db, ticket_id: int) -> list[TicketNote]:
return list((await db.execute(select(TicketNote).where(TicketNote.ticket_id == ticket_id)
.order_by(TicketNote.at, TicketNote.id))).scalars())
async def links_of(db, ticket_id: int) -> list[tuple[str, str]]:
return [(r.ref_type, r.ref_id) for r in (await db.execute(
select(TicketLink).where(TicketLink.ticket_id == ticket_id))).scalars()]
async def comment_counts(db, ids) -> dict[int, int]:
if not ids:
return {}
return dict((await db.execute(
select(TicketNote.ticket_id, func.count()).where(TicketNote.ticket_id.in_(list(ids)),
TicketNote.kind == "comment")
.group_by(TicketNote.ticket_id))).all())
async def link_views(db, links) -> list[dict]:
"""Each link as a reader follows it: {type, id, label, url, gone}."""
from steward.core.formatting import metric_label
from steward.models.alerts import AlertRule
from steward.models.norms import MetricAnomaly
out = []
for ref_type, ref_id in links:
if ref_type == "alert_rule":
rule = await db.get(AlertRule, ref_id)
out.append({"type": ref_type, "id": ref_id, "gone": rule is None,
"label": f"Alert rule {rule.name}" if rule else "An alert rule since deleted",
"url": f"/alerts/history?rule={ref_id}" if rule else None})
elif ref_type == "anomaly":
a = await db.get(MetricAnomaly, ref_id)
out.append({"type": ref_type, "id": ref_id, "gone": a is None,
"label": (f"Anomaly: {metric_label(a.metric_name)} on {a.resource_name}" if a
else "An anomaly no longer kept"),
"url": "/alerts/anomalies" if a else None})
return out
async def known_issues(db, wants: dict) -> dict:
"""{key: [open Ticket, …]} for each key of `wants`, {key: (refs,
resource)}: the open tickets linked to any of `refs` ((type, id) pairs),
naming `resource` as their subject, or about `resource` as a host whole
(no subject, the host in their hosts). Keys with none are left out."""
refs = {r for rs, _ in wants.values() for r in rs if r[1]}
resources = {res for _, res in wants.values() if res}
if not refs and not resources:
return {}
conds = []
if resources:
conds.append(Ticket.subject.in_(resources))
conds.append(and_(Ticket.subject.is_(None), Ticket.hosts.overlap(list(resources))))
if refs:
conds.append(Ticket.id.in_(select(TicketLink.ticket_id).where(
tuple_(TicketLink.ref_type, TicketLink.ref_id).in_(list(refs)))))
tickets = list((await db.execute(select(Ticket).where(Ticket.status == OPEN, or_(*conds))
.order_by(Ticket.id))).scalars())
if not tickets:
return {}
linked: dict[int, set] = {}
for row in (await db.execute(select(TicketLink).where(
TicketLink.ticket_id.in_([t.id for t in tickets])))).scalars():
linked.setdefault(row.ticket_id, set()).add((row.ref_type, row.ref_id))
out = {}
for key, (rs, res) in wants.items():
hit = [t for t in tickets
if linked.get(t.id, set()) & set(rs)
or (res and (t.subject == res or (t.subject is None and res in t.hosts)))]
if hit:
out[key] = hit
return out
async def for_page(db, *, host: str = "", subject: str = "") -> list[Ticket]:
"""The open tickets a host's or a resource's page lists: on the host, or
about the subject."""
conds = []
if host:
conds.append(Ticket.hosts.contains([host]))
if subject:
conds.append(Ticket.subject == subject)
if not conds:
return []
return list((await db.execute(select(Ticket).where(Ticket.status == OPEN, or_(*conds))
.order_by(Ticket.updated_at.desc()))).scalars())
async def open_count(db) -> int:
return (await db.execute(select(func.count()).select_from(Ticket).where(Ticket.status == OPEN))).scalar() or 0
async def draft(db, *, rule_id: str = "", anomaly_id: str = "", host: str = "",
subject: str = "") -> tuple[dict, list[tuple[str, str]]]:
"""A new ticket's starting fields and links, from what it is opened on:
an alert rule or anomaly (its resource, hosts, area and a title, linked),
a host, or a subject."""
from steward.core.formatting import metric_label
from steward.models.alerts import AlertRule
from steward.models.norms import MetricAnomaly
from steward.timeline import catalog
from steward.timeline.record import hosts_of_rule
values = {"title": "", "area": "", "body": "", "hosts": [host] if host else [], "subject": subject}
links: list[tuple[str, str]] = []
rule = await db.get(AlertRule, rule_id) if rule_id else None
anomaly = await db.get(MetricAnomaly, anomaly_id) if anomaly_id else None
if rule is not None:
values.update(title=f"{rule.name} on {rule.resource_name}", subject=rule.resource_name,
area=catalog.area_of_metric(rule.source_module, rule.metric_name),
hosts=await hosts_of_rule(db, rule.source_module, rule.resource_name))
links.append(("alert_rule", rule.id))
elif anomaly is not None:
values.update(title=f"{metric_label(anomaly.metric_name)} on {anomaly.resource_name} out of its normal",
subject=anomaly.resource_name,
area=catalog.area_of_metric(anomaly.source_module, anomaly.metric_name),
hosts=await hosts_of_rule(db, anomaly.source_module, anomaly.resource_name))
links.append(("anomaly", anomaly.id))
if values["subject"] and values["subject"] in values["hosts"]:
values["subject"] = "" # about the host as a whole
return values, links