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>
122 lines
6.9 KiB
Python
122 lines
6.9 KiB
Python
"""Tickets (#6351, #6352): what a ticket must say before it is opened, how
|
|
its steps reach the timeline and the audit log, and the pages that show it.
|
|
Opening, linking, resolving and "known issue" run against Postgres in
|
|
tests/integration/test_tickets.py."""
|
|
from datetime import datetime, timezone
|
|
from types import SimpleNamespace as NS
|
|
|
|
import pytest
|
|
from quart import render_template
|
|
|
|
from steward.core.formatting import status_info
|
|
from steward.mcp.tools.tickets import _hosts, _links
|
|
from steward.tickets import service as tk
|
|
from steward.timeline import catalog
|
|
from steward.timeline.config import Config
|
|
|
|
NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
|
|
AREAS = [list(a) for a in catalog.DEFAULT_AREAS]
|
|
|
|
|
|
def _ticket(**kw):
|
|
base = dict(id=12, title="Backups time out at night", body="Since the 20th.", area="storage",
|
|
hosts=["nas-1"], subject=None, status="open", opened_at=NOW, opened_by="bryan",
|
|
resolved_at=None, resolved_by=None, resolution=None, updated_at=NOW)
|
|
return NS(**{**base, **kw})
|
|
|
|
|
|
async def test_a_ticket_is_checked_before_it_is_opened():
|
|
cfg = Config(areas=AREAS)
|
|
f = await tk.fields(None, cfg, title=" Backups time out ", area="storage", body=" ", subject=" nas-1/restic ")
|
|
assert f == {"title": "Backups time out", "area": "storage", "hosts": [], "body": None,
|
|
"subject": "nas-1/restic"}
|
|
for kw, said in ((dict(title=" ", area="storage"), "Give the ticket a title"),
|
|
(dict(title="x" * 201, area="storage"), "200 characters"),
|
|
(dict(title="x", area="plumbing"), "Pick an area: Service, Host OS"),
|
|
(dict(title="x", area="storage", body="b" * 8001), "8000 characters")):
|
|
with pytest.raises(ValueError, match=said):
|
|
await tk.fields(None, cfg, **kw)
|
|
|
|
|
|
async def test_a_ticket_links_only_to_an_alert_rule_or_an_anomaly():
|
|
with pytest.raises(ValueError, match="not 'monitor'"):
|
|
await tk.check_links(None, [("monitor", "m1")])
|
|
|
|
|
|
def test_opening_resolving_and_reopening_go_on_the_timeline_and_the_audit_rows_stay_off():
|
|
for type_ in ("ticket.opened", "ticket.resolved", "ticket.reopened"):
|
|
t = catalog.TYPES[type_]
|
|
assert (t.kind, t.shown) == ("ticket", True), type_
|
|
for action in ("ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"):
|
|
assert catalog.audit_type(action) is None, action
|
|
|
|
|
|
def test_a_ticket_s_states_read_as_words():
|
|
assert status_info("open") == ("warn", "Open")
|
|
assert status_info("resolved") == ("ok", "Resolved")
|
|
assert status_info("known_issue") == ("info", "Known issue")
|
|
|
|
|
|
def test_the_mcp_tools_take_hosts_and_ids_as_a_list_or_a_comma_list():
|
|
assert _hosts("web-1, ,db-1 ") == ["web-1", "db-1"]
|
|
assert _hosts(None) == [] and _hosts(["web-1", ""]) == ["web-1"]
|
|
assert _links("r1, r2", ["a1"]) == [("alert_rule", "r1"), ("alert_rule", "r2"), ("anomaly", "a1")]
|
|
assert _links(None, None) == []
|
|
|
|
|
|
# ── Pages ───────────────────────────────────────────────────────────────────
|
|
|
|
async def test_the_list_shows_where_each_ticket_is_and_who_opened_it(app):
|
|
tickets = [_ticket(), _ticket(id=9, title="Fan noisy", area="hardware", hosts=[], subject="rack fan",
|
|
status="resolved", resolved_by="sam", resolved_at=NOW)]
|
|
async with app.test_request_context("/tickets/"):
|
|
from quart import session
|
|
session["user_role"] = "viewer"
|
|
page = await render_template("tickets/list.html", tickets=tickets, counts={12: 2}, status="all",
|
|
host="", open_count=1, labels=dict(AREAS))
|
|
assert '<a href="/tickets/12"><span class="tone-dim">#12</span> Backups time out at night</a>' in page
|
|
assert "2 comments" in page and ">Storage<" in page and "rack fan" in page
|
|
assert '<a href="/tickets/?host=nas-1&status=all">nas-1</a>' in page
|
|
assert "1 open" in page and "sam, " in page
|
|
assert "Open a ticket" not in page # a viewer
|
|
|
|
|
|
async def test_an_operator_comments_and_resolves_and_a_viewer_only_reads(app):
|
|
log = [NS(at=NOW, kind="opened", actor="bryan", body=None),
|
|
NS(at=NOW, kind="edited", actor="bryan", body="title, linked alert rule"),
|
|
NS(at=NOW, kind="comment", actor="sam", body="restic prune runs long")]
|
|
links = [{"type": "alert_rule", "id": "r1", "label": "Alert rule Backup late", "url": "/alerts/history?rule=r1",
|
|
"gone": False},
|
|
{"type": "anomaly", "id": "a1", "label": "An anomaly no longer kept", "url": None, "gone": True}]
|
|
async with app.test_request_context("/tickets/12"):
|
|
from quart import session
|
|
session["user_role"] = "operator"
|
|
mine = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
|
|
resolved = await render_template("tickets/ticket.html", t=_ticket(status="resolved", resolved_by="sam",
|
|
resolved_at=NOW, resolution="Moved prune to Sunday"),
|
|
log=log, links=[], area_label="Storage")
|
|
session["user_role"] = "viewer"
|
|
viewer = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
|
|
assert '<a href="/alerts/history?rule=r1">Alert rule Backup late</a>' in mine
|
|
assert '<span class="tone-dim">An anomaly no longer kept</span>' in mine
|
|
assert "edited: title, linked alert rule" in mine and "restic prune runs long" in mine
|
|
assert 'action="/tickets/12/comment"' in mine and 'formaction="/tickets/12/resolve"' in mine
|
|
assert 'formaction="/tickets/12/reopen"' in resolved and "Moved prune to Sunday" in resolved
|
|
assert "/tickets/12/comment" not in viewer and "/tickets/12/edit" not in viewer
|
|
|
|
|
|
async def test_a_page_s_card_lists_its_open_tickets_and_offers_to_open_one(app):
|
|
async with app.test_request_context("/tickets/card"):
|
|
from quart import session
|
|
session["user_role"] = "operator"
|
|
card = await render_template("tickets/_card.html", tickets=[_ticket()], host="nas-1", subject="",
|
|
back="/hosts/h1")
|
|
empty = await render_template("tickets/_card.html", tickets=[], host="", subject="nas-1/restic",
|
|
back="/hosts/h1")
|
|
session["user_role"] = "viewer"
|
|
nothing = await render_template("tickets/_card.html", tickets=[], host="nas-1", subject="", back="/")
|
|
assert "Known issues (1 open)" in card and '<a href="/tickets/12">' in card
|
|
assert 'href="/tickets/new?host=nas-1&back=%2Fhosts%2Fh1"' in card
|
|
assert "No open tickets." in empty and "subject=nas-1%2Frestic" in empty
|
|
assert "<section" not in nothing # a viewer, nothing open
|