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