Files
FabledSteward/steward/tickets/service.py
T
bvandeusenandClaude Opus 5.5 2a561210c3
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s
feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
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>
2026-10-24 22:06:43 -04:00

317 lines
14 KiB
Python

"""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