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>
317 lines
14 KiB
Python
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
|