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>
214 lines
9.0 KiB
Python
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/")
|