Files
FabledSteward/steward/tickets/routes.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

214 lines
9.0 KiB
Python

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