feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
CI / lint (push) Successful in 2s
CI / unit (push) Successful in 1m37s
CI / integration (push) Successful in 1m51s
CI / publish (push) Successful in 1m16s

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>
This commit is contained in:
2026-10-24 22:06:43 -04:00
co-authored by Claude Opus 5.5
parent 1ea86c0330
commit 2a561210c3
40 changed files with 1786 additions and 35 deletions
+121
View File
@@ -0,0 +1,121 @@
"""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&amp;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&amp;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
+257
View File
@@ -0,0 +1,257 @@
"""Integration: tickets (#6351, #6352), from the pages and through MCP. A
ticket opened from a firing alert links its rule and makes the alert a known
issue until it is resolved; opening, resolving and reopening go on the
timeline, the rest in the ticket's own log; every step is audited. Also the
recorded-change tools update_change and delete_change: only the author or an
admin. Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _user(app, name: str, role: str):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time())
return client, uid
async def _firing(app, tag: str, created_by: str):
"""A host with a disk rule firing on its root mount, and an anomaly on
its CPU: what a ticket is opened on."""
from steward.models.alerts import AlertOperator, AlertRule, AlertState, AlertStateEnum
from steward.models.hosts import Host
from steward.models.norms import MetricAnomaly
name = f"tk-{tag}"
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
host = Host(name=name, address="10.7.7.7")
rule = AlertRule(id=str(uuid.uuid4()), name=f"Disk full {tag}", source_module="host_agent",
resource_name=f"{name}:/", metric_name="disk_used_pct",
operator=AlertOperator.gt, threshold=90.0, consecutive_failures_required=1,
enabled=True, created_by=created_by)
anomaly = MetricAnomaly(source_module="host_agent", resource_name=name, metric_name="cpu_pct",
started_at=now - timedelta(hours=2), last_started_at=now - timedelta(hours=2),
ended_at=now - timedelta(hours=1), occurrences=1, peak_level=95.0,
peak_at=now - timedelta(hours=2), usual_hold=20.0, usual_peak=35.0)
s.add_all([host, rule, anomaly])
async with s.begin():
s.add(AlertState(rule_id=rule.id, state=AlertStateEnum.firing,
last_transition_at=now - timedelta(minutes=5)))
return host, rule, anomaly
async def _ticket_on(app, host: str):
from sqlalchemy import select
from steward.models.tickets import Ticket
async with app.db_sessionmaker() as s:
return (await s.execute(select(Ticket).where(Ticket.hosts.contains([host])))).scalar_one()
async def _timeline(app, host: str) -> list[tuple[str, str | None]]:
from sqlalchemy import select
from steward.models.timeline import TimelineEntry
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host]),
TimelineEntry.type.like("ticket.%"))
.order_by(TimelineEntry.at))).scalars().all()
return [(e.type, (e.detail or {}).get("note")) for e in rows]
async def _audited(app, entity_id: str) -> list[str]:
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
return list((await s.execute(select(AuditEvent.action).where(AuditEvent.entity_type == "ticket",
AuditEvent.entity_id == entity_id)
.order_by(AuditEvent.timestamp))).scalars())
async def _known(app, rule_id: str) -> list[int]:
from steward.alerts import service as alert_svc
async with app.db_sessionmaker() as s:
rows = await alert_svc.active_rows(s, datetime.now(timezone.utc))
return [t.id for r in rows if r["rule"].id == rule_id for t in r["tickets"]]
def test_a_ticket_opened_from_an_alert_is_worked_and_resolved_on_its_page(app):
async def _go():
tag = uuid.uuid4().hex[:8]
op, op_id = await _user(app, f"tk-op-{tag}", "operator")
viewer, _ = await _user(app, f"tk-v-{tag}", "viewer")
host, rule, _ = await _firing(app, tag, op_id)
# The form opens from the alert, filled in from its rule and linked to it.
page = await (await op.get(f"/tickets/new?rule={rule.id}&back=/alerts/")).get_data(as_text=True)
assert f'value="Disk full {tag} on {host.name}:/"' in page
assert f'value="alert_rule:{rule.id}" checked' in page and f'value="{host.name}" checked' in page
assert (await viewer.get("/tickets/new")).status_code == 403
form = {"csrf_token": "test-token", "title": "Root disk filling with old logs", "area": "storage",
"body": "journald keeps 4 GB.", "hosts": host.name, "subject": f"{host.name}:/",
"link": f"alert_rule:{rule.id}", "back": "/alerts/"}
bad = await op.post("/tickets/", form={**form, "link": "alert_rule:no-such-rule"})
assert bad.status_code == 400 and "No alert rule has the id no-such-rule" in await bad.get_data(as_text=True)
res = await op.post("/tickets/", form=form)
t = await _ticket_on(app, host.name)
assert res.status_code == 302 and res.headers["Location"] == f"/tickets/{t.id}"
assert (t.status, t.area, t.subject, t.opened_by) == ("open", "storage", f"{host.name}:/", f"tk-op-{tag}")
assert await _known(app, rule.id) == [t.id] # the alert is a known issue
alerts = await (await op.get("/alerts/")).get_data(as_text=True)
assert f'href="/tickets/{t.id}">#{t.id} Root disk filling' in alerts
card = await (await viewer.get(f"/tickets/card?host={host.name}&back=/hosts/{host.id}"))\
.get_data(as_text=True)
assert "Known issues (1 open)" in card and "Root disk filling with old logs" in card
assert (await viewer.post(f"/tickets/{t.id}/comment",
form={"csrf_token": "test-token", "text": "x"})).status_code == 403
await op.post(f"/tickets/{t.id}/comment", form={"csrf_token": "test-token", "text": "Vacuumed to 500 MB."})
refused = await op.post(f"/tickets/{t.id}/resolve", form={"csrf_token": "test-token", "text": " "})
assert refused.status_code == 400 and "Say what resolved it" in await refused.get_data(as_text=True)
await op.post(f"/tickets/{t.id}/resolve",
form={"csrf_token": "test-token", "text": "Capped journald at 500 MB."})
t = await _ticket_on(app, host.name)
assert (t.status, t.resolution, t.resolved_by) == ("resolved", "Capped journald at 500 MB.", f"tk-op-{tag}")
assert await _known(app, rule.id) == [] # resolved: no longer known
await op.post(f"/tickets/{t.id}/reopen", form={"csrf_token": "test-token", "text": "Full again."})
# An edit with the link's box unticked unlinks it.
await op.post(f"/tickets/{t.id}", form={k: v for k, v in form.items() if k != "link"})
assert await _known(app, rule.id) == [t.id] # still: it names the mount
shown = await (await viewer.get(f"/tickets/{t.id}")).get_data(as_text=True)
assert "Vacuumed to 500 MB." in shown and "unlinked alert rule" in shown and "Full again." in shown
assert await _timeline(app, host.name) == [
("ticket.opened", None), ("ticket.resolved", "Capped journald at 500 MB."),
("ticket.reopened", "Full again.")]
assert await _audited(app, str(t.id)) == [
"ticket.opened", "ticket.commented", "ticket.resolved", "ticket.reopened", "ticket.edited"]
asyncio.run(_go())
def test_tickets_are_worked_through_mcp_and_show_up_where_an_agent_looks(app):
from steward.core import anomalies as anom
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools import alerts, hosts, overview, tickets
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
_, uid = await _user(app, f"tm-{tag}", "operator")
host, rule, anomaly = await _firing(app, tag, uid)
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=f"tm-{tag}", role=UserRole.operator, token_id=7))
with pytest.raises(ValueError, match="list_hosts"):
await tickets.open_ticket("x", "storage", hosts=["nope"])
out = await tickets.open_ticket("Root disk filling", "Storage", description="journald",
hosts=host.name, subject=f"{host.name}:/", alert_rules=rule.id)
n = out["ticket"]
assert (out["status"], out["opened_by"], out["url"]) == ("open", f"tm-{tag} via MCP", f"/tickets/{n}")
assert [(lk["type"], lk["id"]) for lk in out["links"]] == [("alert_rule", rule.id)]
assert [e["kind"] for e in out["log"]] == ["opened"]
listed = await alerts.list_alerts()
[mine] = [a for a in listed["alerts"] if a["rule_id"] == rule.id]
assert mine["known_issue"] == [{"ticket": n, "title": "Root disk filling"}]
assert n in [t["ticket"] for t in (await tickets.list_tickets(host=host.name))["tickets"]]
assert [t["ticket"] for t in (await hosts.get_host(host.name))["open_tickets"]] == [n]
ov = await overview.get_overview()
assert ov["counts"]["tickets_open"] >= 1 and "open_tickets" in ov
# A link to the anomaly too; the host as a whole is not its subject, so
# the CPU anomaly is known only through the link.
async with app.db_sessionmaker() as s:
assert (await anom.views(s, [anomaly]))[0].tickets == []
changed = await tickets.update_ticket(f"#{n}", link_anomalies=[anomaly.id], title="Root disk filling up")
assert changed["changed"] == ["title", "linked anomaly"]
async with app.db_sessionmaker() as s:
assert [t.id for t in (await anom.views(s, [anomaly]))[0].tickets] == [n]
await tickets.comment_ticket(n, "Vacuumed the journal.")
done = await tickets.resolve_ticket(n, "Capped journald.")
assert (done["status"], done["resolution"]) == ("resolved", "Capped journald.")
with pytest.raises(ValueError, match="already resolved"):
await tickets.resolve_ticket(n, "again")
[mine] = [a for a in (await alerts.list_alerts())["alerts"] if a["rule_id"] == rule.id]
assert mine["known_issue"] == []
back = await tickets.reopen_ticket(n, "Full again.")
assert back["status"] == "open" and [e["kind"] for e in back["log"]] == [
"opened", "edited", "comment", "resolved", "reopened"]
with pytest.raises(ValueError, match="No ticket #999999"):
await tickets.get_ticket(999999)
assert await _audited(app, str(n)) == [
"ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"]
asyncio.run(_go())
def test_a_recorded_change_is_corrected_or_taken_back_by_its_author_or_an_admin(app):
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools.timeline import delete_change, log_change, update_change
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
_, author = await _user(app, f"uc-a-{tag}", "operator")
_, other = await _user(app, f"uc-o-{tag}", "operator")
_, admin = await _user(app, f"uc-ad-{tag}", "admin")
_context._app_ctx.set(app)
def as_(uid, name, role):
_context._caller_ctx.set(Caller(user_id=uid, username=name, role=role, token_id=7))
as_(author, f"uc-a-{tag}", UserRole.operator)
entry = await log_change(f"Swapped the PSU {tag}", "hardware", subject="psu", detail="old one whined")
as_(other, f"uc-o-{tag}", UserRole.operator)
with pytest.raises(ValueError, match="only they or an admin"):
await update_change(entry["entry_id"], summary="mine now")
with pytest.raises(ValueError, match="only they or an admin"):
await delete_change(entry["entry_id"])
as_(author, f"uc-a-{tag}", UserRole.operator)
fixed = await update_change(entry["entry_id"], summary=f"Swapped the PSU for a 650 W {tag}", subject="")
assert (fixed["summary"], fixed["subject"], fixed["note"], fixed["area"]) == (
f"Swapped the PSU for a 650 W {tag}", None, "old one whined", "hardware")
with pytest.raises(ValueError, match="No recorded change"):
await update_change("no-such-entry", summary="x")
as_(admin, f"uc-ad-{tag}", UserRole.admin)
assert (await delete_change(entry["entry_id"]))["deleted"] == entry["entry_id"]
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
actions = list((await s.execute(select(AuditEvent.action).where(
AuditEvent.entity_id == entry["entry_id"]).order_by(AuditEvent.timestamp))).scalars())
assert actions == ["timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"]
asyncio.run(_go())
+11 -2
View File
@@ -154,9 +154,18 @@ def test_a_quick_restart_is_shown_though_it_never_broke_the_minute():
assert _events.gaps_in(ticks, NOW + timedelta(minutes=2), NOW + timedelta(hours=1), [(restart, 35.0)]) == []
def test_the_timeline_tools_are_registered_with_their_roles(app):
def test_the_timeline_and_ticket_tools_are_registered_with_their_roles(app):
mcp = app.mcp_server
for name, role, read_only in (("get_timeline", UserRole.viewer, True),
("log_change", UserRole.operator, False)):
("log_change", UserRole.operator, False),
("update_change", UserRole.operator, False),
("delete_change", UserRole.operator, False),
("list_tickets", UserRole.viewer, True),
("get_ticket", UserRole.viewer, True),
("open_ticket", UserRole.operator, False),
("update_ticket", UserRole.operator, False),
("comment_ticket", UserRole.operator, False),
("resolve_ticket", UserRole.operator, False),
("reopen_ticket", UserRole.operator, False)):
assert mcp.tool_roles[name] is role, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
+1
View File
@@ -18,6 +18,7 @@ TOP_LEVEL = {
"steward/templates/ansible/index.html",
"steward/templates/audit/list.html",
"steward/templates/timeline/list.html",
"steward/templates/tickets/list.html",
"steward/templates/ai_access/index.html",
"steward/templates/account/index.html",
"plugins/docker/templates/docker/index.html",