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
+3 -3
View File
@@ -13,10 +13,10 @@ each argument's empty value means, and what comes back.
from __future__ import annotations
from . import (alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session,
timeline)
tickets, timeline)
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, automation,
backups)
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, tickets,
automation, backups)
def register_all(register_tool) -> None:
+9 -3
View File
@@ -10,6 +10,7 @@ from steward.core import outbox
from steward.mcp._context import current_caller, mcp_app
from steward.models.alerts import AlertOperator, AlertRule, AlertState, MaintenanceWindow
from steward.models.users import UserRole
from steward.tickets.service import known_issues
from ._audit import audit_mcp, mcp_username
from ._shape import (
@@ -44,13 +45,17 @@ async def list_alerts() -> dict:
(no sample for three of its usual gaps, at least 5 minutes); a firing
alert that goes silent stays "firing" with `no_data_since` set. Each
carries the rule's condition, the resource and metric it watches, `since`
(its latest state change) and `last_sample_at`. Firing, unacknowledged
alerts come first. An alert that cleared is not here; get_alert_history
shows the transitions.
(its latest state change), `last_sample_at`, and `known_issue`: the open
tickets covering it (get_ticket says what is known and tried). Firing,
unacknowledged alerts come first. An alert that cleared is not here;
get_alert_history shows the transitions.
"""
async with db_session() as db:
active = await alert_svc.active_alerts(db)
names = await alert_svc.usernames(db, (s.acknowledged_by for s, _ in active))
known = await known_issues(db, {
rule.id: ([("alert_rule", rule.id), ("alert_rule", rule.parent_id or "")], rule.resource_name)
for _, rule in active})
order = {"firing": 0, "no_data": 1, "pending": 2, "acknowledged": 3}
rows = [
{
@@ -67,6 +72,7 @@ async def list_alerts() -> dict:
"age_s": age_s(state.last_transition_at),
"acknowledged_by": names.get(state.acknowledged_by) if state.acknowledged_by else None,
"acknowledged_at": iso(state.acknowledged_at),
"known_issue": [{"ticket": t.id, "title": t.title} for t in known.get(rule.id, [])],
}
for state, rule in active
]
+3 -1
View File
@@ -45,6 +45,7 @@ def _row(v: anom.AnomalyView) -> dict:
"reviewed_at": iso(a.reviewed_at),
"note": a.note,
"page": v.href,
"known_issue": [{"ticket": t.id, "title": t.title} for t in v.tickets],
}
@@ -64,7 +65,8 @@ async def list_anomalies(review: str = "to_review", limit: int = 50) -> dict:
`usual_hold` (the level that hour usually holds) and `usual_peak` (how high
it usually goes), all in the metric's own unit. `ongoing` is true while an
occurrence is still going. To-review rows come first, newest occurrence
first. review_anomaly marks one.
first. `known_issue` lists the open tickets covering it. review_anomaly
marks one; open_ticket keeps a real one's notes until it is fixed.
"""
if review and review not in anom.REVIEWS:
raise ValueError(f"review must be one of {', '.join(anom.REVIEWS)}, or empty for all")
+6 -2
View File
@@ -15,6 +15,7 @@ from steward.models.users import UserRole
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, pick, rnd, summarize,
)
from .tickets import open_tickets_for
AGENT_SOURCE = "host_agent"
@@ -141,8 +142,9 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
Returns the host's monitors (latest result, error, uptime), the agent's
latest values, alerts currently pending, firing or acknowledged on this
host's resources, and the Ansible inventory target linked to it with its
groups (`ansible_target`: null when unlinked).
host's resources, its open tickets (`open_tickets`: known problems someone
recorded, list_tickets / get_ticket), and the Ansible inventory target
linked to it with its groups (`ansible_target`: null when unlinked).
The agent's values are `agent.metrics` (host-level) and its sub-resources
grouped by kind: `mounts` (fullest first), `interfaces` and `disk_io`
@@ -166,6 +168,7 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
select(AnsibleTarget).where(AnsibleTarget.host_id == h.id)
.options(selectinload(AnsibleTarget.groups))
)).scalar_one_or_none()
tickets = await open_tickets_for(db, host=h.name)
agent = None
if samples:
@@ -218,6 +221,7 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
],
"agent": agent,
"active_alerts": alerts,
"open_tickets": tickets,
"ansible_target": {
"target_id": target.id,
"target_name": target.name,
+4
View File
@@ -16,6 +16,7 @@ from ._audit import audit_mcp
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
)
from .tickets import open_tickets_for
_STATES = ("up", "down", "pending", "disabled")
@@ -126,6 +127,7 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
often it runs; `interval_from` says whether that is its own setting
("monitor") or the global monitors interval it follows ("global").
Header values in an HTTP monitor's configuration are redacted.
`open_tickets` are the open tickets about it (list_tickets).
"""
failures = clamp_limit(failures, 10, 50)
async with db_session() as db:
@@ -133,11 +135,13 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
failed = await monitor_svc.recent_failures(db, m.id, failures)
tickets = await open_tickets_for(db, subject=m.name)
return {
**_row(m, st, host_names),
**_interval(m),
"config": _config(m),
"heartbeat": "".join("+" if r.is_up else "-" for r in st["recent"]),
"open_tickets": tickets,
"recent_failures": [
{
"checked_at": iso(r.checked_at),
+13
View File
@@ -18,12 +18,16 @@ from steward.models.hosts import Host
from steward.models.monitors import Monitor
from steward.models.users import UserRole
from steward.monitors import service as monitor_svc
from steward.tickets import service as tk
from ._shape import age_s, db_session, iso, now_utc, rnd
from .tickets import brief
logger = logging.getLogger(__name__)
FINDINGS_CAP = 50
# Open tickets get_overview lists; list_tickets has the rest.
TICKETS_CAP = 10
# A scheduled backup this long past its slot with none made is overdue: one
# 15-minute tick and the hour a failed try waits, with room to spare.
BACKUP_OVERDUE_AFTER = timedelta(hours=2)
@@ -291,6 +295,10 @@ async def get_overview() -> dict:
`counts` sizes the fleet; `counts.anomalies_to_review` is how many metric
anomalies wait for someone to mark them normal or real (list_anomalies).
`open_tickets` are known problems someone recorded and is working, most
recently worked first (up to 10; `counts.tickets_open` counts them all,
list_tickets lists them). A finding an open ticket covers is already
known: read the ticket (get_ticket) before treating it as new.
`active_maintenance` names windows currently silencing alerts.
`all_clear` is true when nothing is wrong.
"""
@@ -322,6 +330,8 @@ async def get_overview() -> dict:
open_anomalies = await anom.open_anomalies(db)
to_review = await anom.to_review_count(db)
settings = await get_all_settings(db)
open_tickets = list((await db.execute(tk.query(status=tk.OPEN).limit(TICKETS_CAP + 1))).scalars())
tickets_open = await tk.open_count(db)
thresholds = app.config.get("THRESHOLDS") or {}
listed, silent, speaking = fold_agent_silence(active, agent)
@@ -347,7 +357,10 @@ async def get_overview() -> dict:
"critical": sum(1 for f in findings if f["severity"] == "critical"),
"warning": sum(1 for f in findings if f["severity"] == "warning"),
"anomalies_to_review": to_review,
"tickets_open": tickets_open,
},
# Known problems someone is working: what is wrong that is already known.
"open_tickets": [brief(t) for t in open_tickets[:TICKETS_CAP]],
"active_maintenance": [
{"window_id": w.id, "name": w.name, "end_at": iso(w.end_at),
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
+224
View File
@@ -0,0 +1,224 @@
"""Tickets: known problems, open until someone resolves them, worked from MCP
the same as from the Tickets pages (steward.tickets.service)."""
from __future__ import annotations
from steward.mcp._context import current_caller
from steward.models.users import UserRole
from steward.tickets import service as tk
from steward.timeline import config as tl_config
from ._audit import audit_mcp, mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso
from .timeline import area_key, host_hint
LIST_MAX = 200
def brief(t) -> dict:
"""A ticket in a list, or carried by another tool's reply (get_overview,
get_host, get_monitor, get_container, list_alerts, list_anomalies)."""
return {"ticket": t.id, "title": t.title, "status": t.status, "area": t.area, "hosts": t.hosts,
"subject": t.subject, "opened_at": iso(t.opened_at), "opened_by": t.opened_by,
"updated_at": iso(t.updated_at), "age_s": age_s(t.opened_at),
"resolved_at": iso(t.resolved_at), "resolved_by": t.resolved_by}
async def open_tickets_for(db, *, host: str = "", subject: str = "") -> list[dict]:
return [brief(t) for t in await tk.for_page(db, host=host, subject=subject)]
async def _full(db, t) -> dict:
log = await tk.notes(db, t.id)
return {
**brief(t),
"description": t.body,
"resolution": t.resolution,
"links": await tk.link_views(db, await tk.links_of(db, t.id)),
"log": [{"at": iso(n.at), "kind": n.kind, "by": n.actor, "text": n.body} for n in log],
"url": f"/tickets/{t.id}",
}
async def _ticket(db, ticket):
t = await tk.get(db, ticket)
if t is None:
raise ValueError(f"No ticket #{str(ticket).lstrip('#')}; list_tickets lists them.")
return t
def _hosts(hosts) -> list[str]:
if isinstance(hosts, str):
hosts = hosts.split(",")
return [h.strip() for h in (hosts or []) if h and h.strip()]
def _links(alert_rules=None, anomalies=None) -> list[tuple[str, str]]:
def ids(v):
return [i.strip() for i in (v.split(",") if isinstance(v, str) else (v or [])) if i and str(i).strip()]
return [("alert_rule", i) for i in ids(alert_rules)] + [("anomaly", i) for i in ids(anomalies)]
async def list_tickets(status: str = "open", host: str = "", subject: str = "", area: str = "",
limit: int = 50) -> dict:
"""Tickets: known problems someone recorded, open until resolved. Open
ones come first, most recently worked on first.
Read the open tickets before reporting what is wrong: an alert or anomaly
an open ticket covers is already known (list_alerts and list_anomalies
mark it `known_issue`), and the ticket says what has been tried.
status: "open" (default), "resolved" or "all".
host: a host's name, exactly; empty for every host.
subject: what a ticket is about, exactly (a resource, "<host>/<container>",
a monitor's name); empty for any.
area: an area key (get_timeline's `areas`); empty for all.
limit: default 50, max 200.
"""
if status not in (*tk.STATUSES, "all"):
raise ValueError('status is "open", "resolved" or "all".')
limit = clamp_limit(limit, 50, LIST_MAX)
async with db_session() as db:
rows = list((await db.execute(tk.query(status=status, host=host.strip(), subject=subject.strip(),
area=area.strip()).limit(limit + 1))).scalars())
open_n = await tk.open_count(db)
out = {**bounded([brief(t) for t in rows], limit, key="tickets"), "open_total": open_n}
if out["truncated"]:
out["total"] = None
out["more"] = f"More than {limit} tickets. Pass a larger `limit` (max {LIST_MAX}) or filter."
return out
async def get_ticket(ticket: int | str) -> dict:
"""One ticket in full: its description, what it links to (alert rules,
anomalies), its resolution, and its log of comments, edits, resolving and
reopening, oldest first.
ticket: its number (12 or "#12").
"""
async with db_session() as db:
return await _full(db, await _ticket(db, ticket))
async def open_ticket(title: str, area: str, description: str = "", hosts: list[str] | str | None = None,
subject: str = "", alert_rules: list[str] | str | None = None,
anomalies: list[str] | str | None = None) -> dict:
"""Open a ticket for a known problem that will take more than a moment to
fix: a disk failing slowly, a service that keeps restarting, a backup that
times out at night. Acknowledging an alert only quiets it; a ticket keeps
what is known, what was tried and, once resolved, what fixed it, and it
marks the alerts and anomalies it covers as a known issue. Check
list_tickets first for one already open on it.
title: what is wrong, one line (up to 200 characters).
area: what part of the infrastructure it is about, by key or label:
service, host_os, hardware, storage, network, security, monitoring
(get_timeline's `areas`).
description: what is seen, since when, what has been tried (optional).
hosts: the host(s) it is on, by exact name (list_hosts).
subject: what it is about when that is not a host as a whole: an alert
rule's resource, "<host>/<container>", a monitor's name. An alert or
anomaly on this resource reads as a known issue while it is open.
alert_rules: alert rule ids to link (list_alerts' `rule_id`).
anomalies: anomaly ids to link (list_anomalies' `anomaly_id`).
Opening it goes on the timeline. Recorded in the audit log as "<you> via
MCP".
"""
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await tk.fields(db, cfg, title=title, area=area_key(cfg, area), body=description,
hosts=_hosts(hosts), subject=subject)
except ValueError as exc:
raise host_hint(exc) from None
links = await tk.check_links(db, _links(alert_rules, anomalies))
t = await tk.open_ticket(db, f, actor=mcp_username(caller.username), author_id=caller.user_id,
links=links)
out = await _full(db, t)
await audit_mcp("ticket.opened", "ticket", str(t.id),
{"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]})
return out
async def update_ticket(ticket: int | str, title: str | None = None, area: str | None = None,
description: str | None = None, hosts: list[str] | str | None = None,
subject: str | None = None, link_alert_rules: list[str] | str | None = None,
link_anomalies: list[str] | str | None = None,
unlink_alert_rules: list[str] | str | None = None,
unlink_anomalies: list[str] | str | None = None) -> dict:
"""Change a ticket: its title, area, description, hosts or subject, and
what it links to. Only what you pass changes; an empty string clears the
description or subject, an empty list clears the hosts. To add to the
log, use comment_ticket; to close it, resolve_ticket.
ticket: its number. The rest as open_ticket; link_*/unlink_* take ids.
Recorded in the ticket's log and the audit log.
"""
caller = current_caller()
async with db_session() as db:
async with db.begin():
t = await _ticket(db, ticket)
cfg = await tl_config.load(db)
try:
f = await tk.fields(db, cfg, title=t.title if title is None else title,
area=t.area if area is None else area_key(cfg, area),
body=t.body if description is None else description,
hosts=t.hosts if hosts is None else _hosts(hosts),
subject=t.subject if subject is None else subject)
except ValueError as exc:
raise host_hint(exc) from None
add = await tk.check_links(db, _links(link_alert_rules, link_anomalies))
changed = await tk.edit(db, t, f, actor=mcp_username(caller.username), add_links=add,
remove_links=_links(unlink_alert_rules, unlink_anomalies))
out = await _full(db, t)
if changed:
await audit_mcp("ticket.edited", "ticket", str(t.id), {"name": t.title, "changed": changed})
return {**out, "changed": changed}
async def comment_ticket(ticket: int | str, comment: str) -> dict:
"""Add a comment to a ticket's log: what you found, what you tried, what
happened. Works on a resolved ticket too.
ticket: its number. comment: up to 8000 characters.
Recorded in the audit log.
"""
return await _act(ticket, "ticket.commented", tk.comment, comment)
async def resolve_ticket(ticket: int | str, resolution: str) -> dict:
"""Resolve an open ticket. `resolution` says what fixed it, or why it no
longer matters; it goes on the timeline as the record of the fix, so
write it for someone reading the host's history later.
ticket: its number. Recorded in the audit log.
"""
return await _act(ticket, "ticket.resolved", tk.resolve, resolution)
async def reopen_ticket(ticket: int | str, reason: str = "") -> dict:
"""Reopen a resolved ticket: the problem is back, or the fix did not
hold. `reason` says why (optional). Goes on the timeline; recorded in the
audit log.
"""
return await _act(ticket, "ticket.reopened", tk.reopen, reason)
async def _act(ticket, action: str, fn, text: str) -> dict:
caller = current_caller()
async with db_session() as db:
async with db.begin():
t = await _ticket(db, ticket)
await fn(db, t, text, actor=mcp_username(caller.username))
out = await _full(db, t)
await audit_mcp(action, "ticket", str(t.id), {"name": t.title})
return out
def register(register_tool) -> None:
register_tool(list_tickets, role=UserRole.viewer, read_only=True)
register_tool(get_ticket, role=UserRole.viewer, read_only=True)
for fn in (open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket):
register_tool(fn, role=UserRole.operator, read_only=False)
+87 -16
View File
@@ -84,8 +84,9 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
return out
def _area(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it."""
def area_key(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it
(log_change, and the ticket tools)."""
want = (area or "").strip().lower()
for key, label in cfg.areas:
if want in (key, label.lower()):
@@ -93,6 +94,22 @@ def _area(cfg, area: str) -> str:
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
def _when(at: str):
"""An ISO-8601 time (no zone is UTC), or None for an empty one."""
if not (at or "").strip():
return None
try:
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
def host_hint(exc: ValueError) -> ValueError:
"""A refusal naming a host that isn't one, pointed at the tool that lists them."""
return ValueError(f"{exc} list_hosts gives the names.") if "No host is named" in str(exc) else exc
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
detail: str = "", at: str = "") -> dict:
"""Record on the timeline a change Steward can't see happen: a config
@@ -115,29 +132,22 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
4000 characters).
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
Shown as by "<you> via MCP"; the person whose token this is can edit or
delete it from the Timeline page. Recorded in the audit log.
Shown as by "<you> via MCP". It can be corrected with update_change or
taken back with delete_change, by the person whose token this is or an
admin. Recorded in the audit log.
"""
if isinstance(hosts, str):
hosts = hosts.split(",")
when = None
if (at or "").strip():
try:
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
when = _when(at)
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [],
f = await changes.fields(db, cfg, summary=summary, area=area_key(cfg, area), hosts=hosts or [],
subject=subject, note=detail, at=when)
except ValueError as exc:
if "No host is named" in str(exc):
raise ValueError(f"{exc} list_hosts gives the names.") from None
raise
raise host_hint(exc) from None
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
@@ -145,6 +155,67 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
return _entry(v)
async def _own(db, entry_id: str):
"""A recorded change the caller may edit: one their token's user wrote,
or any for an admin."""
caller = current_caller()
entry = await changes.get(db, entry_id)
if entry is None:
raise ValueError(f"No recorded change has the id {entry_id!r} (get_timeline's `entry_id`, "
"on an entry of type change.recorded).")
if not changes.can_edit(entry, caller.user_id, getattr(caller.role, "value", caller.role)):
raise ValueError(f"Change {entry_id} was recorded by {entry.actor}; only they or an admin can change it.")
return entry
async def update_change(entry_id: str, summary: str | None = None, area: str | None = None,
hosts: list[str] | str | None = None, subject: str | None = None,
detail: str | None = None, at: str | None = None) -> dict:
"""Correct a change recorded with log_change (or on the Timeline page):
only what you pass changes; an empty string clears the subject or
detail, an empty list the hosts. Only its author or an admin can.
entry_id: the change's `entry_id` (log_change's reply, or get_timeline).
The rest as log_change. Recorded in the audit log.
"""
async with db_session() as db:
async with db.begin():
entry = await _own(db, entry_id)
cfg = await tl_config.load(db)
if isinstance(hosts, str):
hosts = hosts.split(",")
try:
f = await changes.fields(
db, cfg, summary=entry.summary if summary is None else summary,
area=entry.area if area is None else area_key(cfg, area),
hosts=entry.hosts if hosts is None else hosts,
subject=(entry.subject or "") if subject is None else subject,
note=((entry.detail or {}).get("note") or "") if detail is None else detail,
at=entry.at if at is None else _when(at))
except ValueError as exc:
raise host_hint(exc) from None
changes.edit(entry, f)
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_edited", "timeline_entry", entry.id, {"name": entry.summary})
return _entry(v)
async def delete_change(entry_id: str) -> dict:
"""Take a recorded change off the timeline: one logged by mistake, or
twice. Only its author or an admin can; the audit log keeps that it was.
entry_id: the change's `entry_id`.
"""
async with db_session() as db:
async with db.begin():
entry = await _own(db, entry_id)
summary = entry.summary
await db.delete(entry)
await audit_mcp("timeline.change_deleted", "timeline_entry", entry_id, {"name": summary})
return {"deleted": entry_id, "summary": summary}
def register(register_tool) -> None:
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
register_tool(log_change, role=UserRole.operator, read_only=False)
for fn in (log_change, update_change, delete_change):
register_tool(fn, role=UserRole.operator, read_only=False)