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>
This commit is contained in:
@@ -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&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
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user