From 2a561210c36803a7b4cf53306162b9d49a35b0f3 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 24 Oct 2026 22:06:43 -0400 Subject: [PATCH] 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 --- README.md | 1 + docs/core/mcp.md | 4 +- docs/core/tickets.md | 55 +++ docs/core/timeline.md | 3 +- plugins/docker/mcp_tools.py | 7 +- .../templates/docker/container_detail.html | 2 + steward/alerts/service.py | 11 +- steward/app.py | 2 + steward/audit/service.py | 15 +- steward/backup/contents.py | 3 + steward/core/anomalies.py | 8 +- steward/core/formatting.py | 5 + steward/mcp/tools/__init__.py | 6 +- steward/mcp/tools/alerts.py | 12 +- steward/mcp/tools/anomalies.py | 4 +- steward/mcp/tools/hosts.py | 8 +- steward/mcp/tools/monitors.py | 4 + steward/mcp/tools/overview.py | 13 + steward/mcp/tools/tickets.py | 224 +++++++++++++ steward/mcp/tools/timeline.py | 103 +++++- steward/migrations/versions/0048_tickets.py | 62 ++++ steward/models/__init__.py | 2 + steward/models/tickets.py | 67 ++++ steward/templates/_anomaly.html | 9 +- steward/templates/alerts/_active.html | 11 +- steward/templates/base.html | 1 + steward/templates/hosts/detail.html | 3 + steward/templates/monitors/detail.html | 1 + steward/templates/tickets/_card.html | 22 ++ steward/templates/tickets/form.html | 81 +++++ steward/templates/tickets/list.html | 62 ++++ steward/templates/tickets/ticket.html | 78 +++++ steward/tickets/__init__.py | 1 + steward/tickets/routes.py | 213 ++++++++++++ steward/tickets/service.py | 316 ++++++++++++++++++ steward/timeline/catalog.py | 10 + tests/core/test_tickets.py | 121 +++++++ tests/integration/test_tickets.py | 257 ++++++++++++++ tests/mcp/test_anomaly_review.py | 13 +- tests/test_breadcrumbs.py | 1 + 40 files changed, 1786 insertions(+), 35 deletions(-) create mode 100644 docs/core/tickets.md create mode 100644 steward/mcp/tools/tickets.py create mode 100644 steward/migrations/versions/0048_tickets.py create mode 100644 steward/models/tickets.py create mode 100644 steward/templates/tickets/_card.html create mode 100644 steward/templates/tickets/form.html create mode 100644 steward/templates/tickets/list.html create mode 100644 steward/templates/tickets/ticket.html create mode 100644 steward/tickets/__init__.py create mode 100644 steward/tickets/routes.py create mode 100644 steward/tickets/service.py create mode 100644 tests/core/test_tickets.py create mode 100644 tests/integration/test_tickets.py diff --git a/README.md b/README.md index b5b87ec..17271cc 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,7 @@ See `docs/plugins/` for a full plugin development guide. | `docs/core/ansible.md` | Playbook sources, run lifecycle, SSE streaming | | `docs/core/backups.md` | Steward's daily backup to an S3 bucket (Fabled Archives), what it holds, and `steward restore` | | `docs/core/timeline.md` | The timeline: what it records from each source, areas and kinds, Settings → Timeline | +| `docs/core/tickets.md` | Tickets: known problems, linked to alerts and anomalies, shown as known issues, resolved onto the timeline | | `docs/plugins/overview.md` | Plugin system: how loading works, plugin.yaml schema, required exports | | `docs/plugins/writing-a-plugin.md` | Step-by-step plugin development guide | | `docs/plugins/traefik.md` | Traefik plugin: config, metrics, alert rule examples | diff --git a/docs/core/mcp.md b/docs/core/mcp.md index 422a64a..abcb742 100644 --- a/docs/core/mcp.md +++ b/docs/core/mcp.md @@ -79,6 +79,7 @@ Each role includes the tools of the roles above it in this list. | Automation | `list_playbooks`, `get_playbook`, `list_inventory`, `list_ansible_runs`, `get_ansible_run`, `wait_for_run`, `list_schedules` | | Audit | `list_audit_events` | | Timeline | `get_timeline` (everything that happened, newest first, in one list: alerts firing and clearing, playbook runs and what they changed, maintenance, reboots, deploys and crash-loops, reviewed anomalies, changes recorded by hand, changes to Steward's settings; by host, area or kind, and kept after the records it came from age out) | +| Tickets | `list_tickets` (known problems someone recorded, open first; by host, subject or area), `get_ticket` (one ticket: its description, the alert rules and anomalies it links, its resolution and its log). `list_alerts` and `list_anomalies` mark what an open ticket covers as `known_issue`; `get_overview`, `get_host`, `get_monitor` and `get_container` carry `open_tickets` | | Docker plugin | `list_containers`, `get_container`, `list_swarm_services`, `search_container_logs`, `get_docker_disk_usage` | | Host agent (built in, always on) | `list_agents`, `list_maintenance_plans` (each plan's group, weekly slot, next run and latest run: how far it got, who it skipped and why), `get_maintenance_job` (a host's update jobs or one job: state, packages updated, whether it rebooted, why it failed), `get_host_senders` (who sent or received a host's network traffic over a window: its containers and NFS mounts ranked at the host's peak, and what none of them accounts for) | | Traefik plugin | `list_routers` (each router graded as the Traefik page grades it, with the status codes behind a problem, what they mean and the swarm service behind it), `get_router` (one router's history, codes, normal and anomalies), `list_certificates` (served certificates, days left, the routers they cover), `get_traffic` (the access log's clients, countries and routers, and Traefik's own figures) | @@ -95,7 +96,8 @@ Each role includes the tools of the roles above it in this list. | Monitors | `create_monitor`, `update_monitor`, `set_monitor_enabled` | | Alerts | `create_alert_rule`, `update_alert_rule`, `acknowledge_alert`, `send_test_alert`, `create_maintenance_window`, `end_maintenance_window` | | Metric anomalies | `review_anomaly` (mark one or several normal, so their hours are learned, or real, with a note saying why) | -| Timeline | `log_change` (record a change Steward can't see happen — a config edited by hand, a disk swapped — on the hosts it was done on, under an area; shown as by " via MCP") | +| Timeline | `log_change` (record a change Steward can't see happen — a config edited by hand, a disk swapped — on the hosts it was done on, under an area; shown as by " via MCP"), `update_change` and `delete_change` (correct or take back a recorded change: its author or an admin) | +| Tickets | `open_ticket` (a known problem, on its hosts and what it is about, linked to alert rules and anomalies; goes on the timeline), `update_ticket` (its fields and links), `comment_ticket`, `resolve_ticket` (with what fixed it, which goes on the timeline), `reopen_ticket` | ### Admin: destructive and remediation diff --git a/docs/core/tickets.md b/docs/core/tickets.md new file mode 100644 index 0000000..a92509d --- /dev/null +++ b/docs/core/tickets.md @@ -0,0 +1,55 @@ +# Tickets + +A ticket records a known problem that takes more than a moment to fix: a disk failing slowly, a service that keeps restarting, a backup that times out at night. It says what is wrong, what has been tried and, once resolved, what fixed it. Acknowledging an alert only quiets it. A ticket keeps the story, and while it is open the alerts and anomalies it covers read as a **known issue**. + +Open the list from **Tickets** in the sidebar. A host's page, a monitor's page and a container's page each show a **Known issues** card with their open tickets. MCP clients use `list_tickets`, `get_ticket`, `open_ticket`, `update_ticket`, `comment_ticket`, `resolve_ticket` and `reopen_ticket`. + +**Source:** `steward/tickets/` (`service.py` for every write, `routes.py` for the pages), `steward/mcp/tools/tickets.py` +**Tables:** `tickets`, `ticket_links`, `ticket_notes` (`steward/models/tickets.py`) + +--- + +## What a ticket holds + +| Field | | +|---|---| +| Number | `#12`, in order of opening | +| Title | What is wrong, one line (up to 200 characters) | +| Area | One of the timeline's areas (Settings → Timeline) | +| Hosts | The hosts it is on, by name; none for a problem not on any one host | +| Subject | What it is about when that is not a host as a whole: an alert rule's resource (`web-1:/`), a container (`web-1/nginx`), a monitor's name | +| Description | What is seen, since when, what has been tried | +| Links | Alert rules and metric anomalies it covers | +| Log | Opened, comments, edits (which fields changed, what was linked or unlinked), resolved, reopened, each with who and when | + +A ticket is **open** or **resolved**. Resolving one needs a resolution: what fixed it, or why it no longer matters. Reopening clears it, with an optional reason. + +## Opening one + +- **From an alert:** **Open a ticket** on an active alert (Alerts page) fills in the title, area, hosts and subject from the rule, and links the rule. +- **From an anomaly:** **Open a ticket** on an anomaly to review, or one reviewed as real, does the same and links the anomaly. +- **From a page:** the **Known issues** card on a host, monitor or container opens one about it. +- **From scratch:** **Open a ticket** on the Tickets page. +- **Through MCP:** `open_ticket(title, area, description, hosts, subject, alert_rules, anomalies)`. + +Opening, editing, commenting, resolving and reopening need an operator or admin. Viewers read everything. + +## Known issues + +An alert or anomaly is a known issue while an open ticket: + +- links its alert rule (or the covering group rule it came from), or the anomaly; +- names its resource as its subject; or +- is about its host as a whole (no subject), when the resource is the host itself. + +The Alerts page and the anomaly lists show a **Known issue** badge with the ticket. `list_alerts` and `list_anomalies` return the same as `known_issue`. `get_overview` lists up to ten open tickets in `open_tickets` and counts them all in `counts.tickets_open`. `get_host`, `get_monitor` and `get_container` carry their own `open_tickets`. + +## On the timeline + +Opening, resolving and reopening a ticket each write a timeline entry under the ticket's area, on its hosts and subject (`ticket.opened`, `ticket.resolved`, `ticket.reopened`, kind Ticket). The resolution is the resolved entry's note, so the timeline keeps what fixed it. Comments and edits stay in the ticket's own log. + +Every step is audited (`ticket.opened`, `ticket.edited`, `ticket.commented`, `ticket.resolved`, `ticket.reopened`). Those audit rows stay off the timeline, which holds the ticket's own entries. + +## Backups + +Tickets are not in Steward's backups yet, the same as the timeline itself (`steward/backup/contents.py`, `EXCLUDED`). diff --git a/docs/core/timeline.md b/docs/core/timeline.md index c0fb63a..89cafe2 100644 --- a/docs/core/timeline.md +++ b/docs/core/timeline.md @@ -23,6 +23,7 @@ Every source writes its entry in the same transaction as its own record, through | A host away: a reboot, a crash, lost contact, the agent restarting; kept up to date until it ends | host agent: `downtime.record_events` | `host.rebooted`, `host.lost_contact`, `host.agent_restarted` | | Container snapshots: a new image, a recreated container, a restart or death | Docker: `ingest.persist_host_docker` → `timeline.from_snapshot` | `docker.deployed`, `docker.crash_loop`, `docker.restarted` | | A change recorded by hand, from the Timeline page, a host's History card or MCP `log_change` | `timeline/changes.py` | `change.recorded` | +| Tickets: opened, resolved (the resolution is the entry's note) and reopened | `tickets/service.py` | `ticket.opened`, `ticket.resolved`, `ticket.reopened` | A source whose record changes after it is written keeps one entry for it (`record.record_or_update`): a downtime gets its length when it ends, an anomaly changes type when it is reviewed. @@ -38,7 +39,7 @@ On its first start, Steward fills the timeline from the last 90 days of records Steward can't see every change happen: a config edited on a host, a disk swapped, a service moved. Whoever made it records it, with **Record a change** on the Timeline page or a host's History card, or the `log_change` MCP tool. It says what was done, the area it is about, when, the hosts it was done on (none for a change not on any one host), optionally what it was done to (a container, a file) and a longer detail. -An operator or admin can record one. Its author can edit or delete it from its **Edit** link, and so can an admin. One recorded through MCP is shown as by " via MCP" and is editable by that token's user. Recording, editing and deleting are audited (`timeline.change_recorded`, `…_edited`, `…_deleted`), and those audit rows stay off the timeline, which holds the change itself. +An operator or admin can record one. Its author can edit or delete it from its **Edit** link, or with the `update_change` and `delete_change` MCP tools, and so can an admin. One recorded through MCP is shown as by " via MCP" and is editable by that token's user. Recording, editing and deleting are audited (`timeline.change_recorded`, `…_edited`, `…_deleted`), and those audit rows stay off the timeline, which holds the change itself. ## Areas and kinds diff --git a/plugins/docker/mcp_tools.py b/plugins/docker/mcp_tools.py index f6aa4a1..30fa9fd 100644 --- a/plugins/docker/mcp_tools.py +++ b/plugins/docker/mcp_tools.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta from sqlalchemy import select +from steward.mcp.tools.tickets import open_tickets_for from steward.ansible.runner import trigger_run from steward.ansible.sources import BUILTIN_SOURCE_NAME from steward.mcp._context import current_caller, mcp_app @@ -390,7 +391,8 @@ async def get_container(container: str, host: str = "", range: str = "24h", even still resolves by its exact name, or by a part of it that matches one removed container, while Steward retains its logs or events: the reply has `removed: true`, `last_seen_at`, its history and its events, and no - live state. A live container has `removed: false`. + live state. A live container has `removed: false`. `open_tickets` are the + open tickets about it (list_tickets). Memory (`mem_used_bytes`, `mem_used_pct`, of its limit or of the host's RAM when it has none) is what its processes hold: anon + shmem. @@ -413,6 +415,8 @@ async def get_container(container: str, host: str = "", range: str = "24h", even mem = await docker_svc.container_points(db, c.host_id, c.name, "mem_used_pct", since) evs = await docker_svc.container_events(db, c.host_id, c.name, events) sick = await docker_svc.unhealthy_since(db, [(c.host_id, c.name)]) + tickets = await open_tickets_for( + db, subject=docker_svc.container_resource(host_names.get(c.host_id, c.host_id), c.name)) hour_ago = now - timedelta(hours=1) recent = sum(1 for e in failures if e.at >= hour_ago) try: @@ -433,6 +437,7 @@ async def get_container(container: str, host: str = "", range: str = "24h", even "cpu_pct_history": summarize(cpu), "mem_used_pct_history": summarize(mem), "events": _event_rows(evs, now), + "open_tickets": tickets, "next": f'search_container_logs(container="{c.name}", host="{c.host_id}")', } diff --git a/plugins/docker/templates/docker/container_detail.html b/plugins/docker/templates/docker/container_detail.html index fa399f8..1a0c709 100644 --- a/plugins/docker/templates/docker/container_detail.html +++ b/plugins/docker/templates/docker/container_detail.html @@ -66,6 +66,8 @@ {# ── Its CPU or memory out of its normal for the hour, and past anomalies (#5742) #} {% with back=anomaly_back, base_resource=anomaly_resource %}{% include "_anomaly_card.html" %}{% endwith %} +{# ── Its open tickets ──────────────────────────────────────────────────────── #} +
{# ── What it is set up with, and what it moved since it started ─────────── #}
diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 7c32bad..ec84c79 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -222,7 +222,8 @@ async def active_rows(db, now: datetime) -> list[dict]: """The alerts page's active list, firing first, then no data, then acknowledged, then pending; within each the longest-running first. Each row: its state, rule, label, sentence, the link to what it watches, when - the breach began, and who acknowledged it.""" + the breach began, who acknowledged it, and the open tickets that make it + a known issue.""" alerts = await active_alerts(db) started = await breach_started(db, {rule.id for state, rule in alerts if state.state.value in _BREACHING}) @@ -245,6 +246,14 @@ async def active_rows(db, now: datetime) -> list[dict]: "says": alert_sentence(rule, label, value, since, now), "url": await resource_url(db, rule), }) + # An open ticket on the rule (or its covering rule) or on what it + # watches makes it a known issue. + from steward.tickets.service import known_issues + known = await known_issues(db, { + r["rule"].id: ([("alert_rule", r["rule"].id), ("alert_rule", r["rule"].parent_id or "")], + r["rule"].resource_name) for r in rows}) + for r in rows: + r["tickets"] = known.get(r["rule"].id, []) epoch = datetime.max.replace(tzinfo=timezone.utc) rows.sort(key=lambda r: (ACTIVE_ORDER.get(r["label"], 9), r["since"] or epoch)) return rows diff --git a/steward/app.py b/steward/app.py index f5337cc..d2d322d 100644 --- a/steward/app.py +++ b/steward/app.py @@ -234,6 +234,7 @@ def create_app( from .ai_access.routes import ai_access_bp from .account.routes import account_bp from .timeline.routes import timeline_bp + from .tickets.routes import tickets_bp app.register_blueprint(auth_bp) app.register_blueprint(dashboard_bp) @@ -249,6 +250,7 @@ def create_app( app.register_blueprint(ai_access_bp) app.register_blueprint(account_bp) app.register_blueprint(timeline_bp) + app.register_blueprint(tickets_bp) # Register the unified Monitor status source for the Status page. Plugins # may register additional sources from setup(). Idempotent. diff --git a/steward/audit/service.py b/steward/audit/service.py index a79fd48..68932af 100644 --- a/steward/audit/service.py +++ b/steward/audit/service.py @@ -94,6 +94,13 @@ async def entity_labels(db, items: list[dict]) -> dict[tuple[str, str], dict]: labels[(etype, r.id)] = { "label": run_title(r.playbook_path, scopes.get(r.inventory_scope, r.inventory_scope)), "url": f"/ansible/runs/{r.id}", "gone": False} + elif etype == "ticket": + from steward.models.tickets import Ticket + nums = [int(i) for i in wanted if i.isdigit()] + for num, title in (await db.execute( + select(Ticket.id, Ticket.title).where(Ticket.id.in_(nums)))).all() if nums else (): + labels[(etype, str(num))] = {"label": f"#{num} {title}", "url": f"/tickets/{num}", + "gone": False} elif etype == "timeline_entry": from steward.models.timeline import TimelineEntry for row_id, summary in (await db.execute( @@ -215,6 +222,11 @@ ACTIONS = { "timeline.change_recorded": "recorded a change:", "timeline.change_edited": "edited the recorded change", "timeline.change_deleted": "deleted the recorded change", + "ticket.opened": "opened ticket", + "ticket.edited": "edited ticket", + "ticket.commented": "commented on ticket", + "ticket.resolved": "resolved ticket", + "ticket.reopened": "reopened ticket", "backup.made": "sent a backup", "backup.failed": "tried to send a backup, which failed:", "backup.restored": "restored Steward from a backup", @@ -227,7 +239,8 @@ AREAS = (("auth.", "Sign-ins"), ("account.", "Accounts"), ("user.", "Users"), (" ("ansible.run", "Playbook runs"), ("ansible.", "Ansible (all)"), ("settings.", "Settings"), ("plugin.", "Plugins"), ("api_token.", "AI access"), ("docker.", "Docker"), ("snmp.", "SNMP"), ("prometheus.", "Prometheus"), - ("syslog.", "Syslog"), ("backup.", "Backups"), ("timeline.", "Timeline")) + ("syslog.", "Syslog"), ("backup.", "Backups"), ("timeline.", "Timeline"), + ("ticket.", "Tickets")) def action_text(action: str) -> str: diff --git a/steward/backup/contents.py b/steward/backup/contents.py index 167e6b3..95993f2 100644 --- a/steward/backup/contents.py +++ b/steward/backup/contents.py @@ -37,6 +37,9 @@ EXCLUDED: frozenset[str] = frozenset({ "audit_events", "alert_events", # The timeline of what happened to the infrastructure: history. "timeline_entries", + # Tickets: a record of known problems and how they were worked, not + # setup. Left out like the timeline, by the 2026-10-15 ruling. + "tickets", "ticket_links", "ticket_notes", # Live state: each rule starts inactive, and alert_reconcile gives a # restored rule its state row within a minute (ensure_rule_states). "alert_states", diff --git a/steward/core/anomalies.py b/steward/core/anomalies.py index b27ca89..64e137c 100644 --- a/steward/core/anomalies.py +++ b/steward/core/anomalies.py @@ -17,7 +17,7 @@ What the operator ruled (System 182 "Metric Norms and Anomalies"): """ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from datetime import datetime, timedelta, timezone from sqlalchemy import ARRAY, String, bindparam, case, delete, func, or_, select, text, update @@ -276,6 +276,8 @@ class AnomalyView: anomaly: MetricAnomaly last_span: MetricAnomalySpan | None href: str | None = None + # Open tickets that make it a known issue (tickets.service.known_issues). + tickets: list = field(default_factory=list) @property def ongoing(self) -> bool: @@ -317,6 +319,10 @@ async def views(session, rows: list[MetricAnomaly], *, links: bool = True) -> li if links and key not in hrefs: hrefs[key] = await resource_url_for(session, *key) out.append(AnomalyView(a, latest.get(a.id), hrefs.get(key))) + from steward.tickets.service import known_issues + known = await known_issues(session, {a.id: ([("anomaly", a.id)], a.resource_name) for a in rows}) + for v in out: + v.tickets = known.get(v.anomaly.id, []) return out diff --git a/steward/core/formatting.py b/steward/core/formatting.py index f6a467b..12aa794 100644 --- a/steward/core/formatting.py +++ b/steward/core/formatting.py @@ -215,6 +215,11 @@ STATES: dict[str, tuple[str, str]] = { "normal": ("ok", "Marked normal"), "real": ("crit", "Real problem"), "lapsed": ("dim", "Not reviewed"), + # A ticket (steward.tickets): open is a known problem, so amber; an + # alert or anomaly a ticket covers is a known issue, drawn blue like an + # acknowledged alert. + "open": ("warn", "Open"), + "known_issue": ("info", "Known issue"), } diff --git a/steward/mcp/tools/__init__.py b/steward/mcp/tools/__init__.py index 3748857..a6544ac 100644 --- a/steward/mcp/tools/__init__.py +++ b/steward/mcp/tools/__init__.py @@ -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: diff --git a/steward/mcp/tools/alerts.py b/steward/mcp/tools/alerts.py index 77a9297..35f701a 100644 --- a/steward/mcp/tools/alerts.py +++ b/steward/mcp/tools/alerts.py @@ -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 ] diff --git a/steward/mcp/tools/anomalies.py b/steward/mcp/tools/anomalies.py index 8b8983a..5b6a6f0 100644 --- a/steward/mcp/tools/anomalies.py +++ b/steward/mcp/tools/anomalies.py @@ -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") diff --git a/steward/mcp/tools/hosts.py b/steward/mcp/tools/hosts.py index 9632b4a..7b04701 100644 --- a/steward/mcp/tools/hosts.py +++ b/steward/mcp/tools/hosts.py @@ -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, diff --git a/steward/mcp/tools/monitors.py b/steward/mcp/tools/monitors.py index 2cbc693..7e8ab5d 100644 --- a/steward/mcp/tools/monitors.py +++ b/steward/mcp/tools/monitors.py @@ -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), diff --git a/steward/mcp/tools/overview.py b/steward/mcp/tools/overview.py index 9b0ee83..acb1443 100644 --- a/steward/mcp/tools/overview.py +++ b/steward/mcp/tools/overview.py @@ -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}} diff --git a/steward/mcp/tools/tickets.py b/steward/mcp/tools/tickets.py new file mode 100644 index 0000000..1d18b9c --- /dev/null +++ b/steward/mcp/tools/tickets.py @@ -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, "/", + 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, "/", 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 " 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) diff --git a/steward/mcp/tools/timeline.py b/steward/mcp/tools/timeline.py index 4d0084c..83c0903 100644 --- a/steward/mcp/tools/timeline.py +++ b/steward/mcp/tools/timeline.py @@ -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 " 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 " 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) diff --git a/steward/migrations/versions/0048_tickets.py b/steward/migrations/versions/0048_tickets.py new file mode 100644 index 0000000..014ebca --- /dev/null +++ b/steward/migrations/versions/0048_tickets.py @@ -0,0 +1,62 @@ +"""Tickets: known problems, their links to alert rules and anomalies, and their log. + +Revision ID: 0048_tickets +Revises: 0047_timeline +Create Date: 2026-10-25 +""" +from typing import Sequence, Union +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects.postgresql import ARRAY + +revision: str = "0048_tickets" +down_revision: Union[str, None] = "0047_timeline" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "tickets", + sa.Column("id", sa.Integer, primary_key=True, autoincrement=True), + sa.Column("title", sa.String(200), nullable=False), + sa.Column("body", sa.Text, nullable=True), + sa.Column("area", sa.String(32), nullable=False), + sa.Column("hosts", ARRAY(sa.String(128)), nullable=False, server_default="{}"), + sa.Column("subject", sa.String(255), nullable=True), + sa.Column("status", sa.String(16), nullable=False, server_default="open"), + sa.Column("opened_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("opened_by", sa.String(64), nullable=False), + sa.Column("opened_by_id", sa.String(36), nullable=True), + sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("resolved_by", sa.String(64), nullable=True), + sa.Column("resolution", sa.Text, nullable=True), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + ) + op.create_index("ix_tickets_status", "tickets", ["status", "updated_at"]) + op.create_index("ix_tickets_hosts", "tickets", ["hosts"], postgresql_using="gin") + op.create_table( + "ticket_links", + sa.Column("ticket_id", sa.Integer, sa.ForeignKey("tickets.id", ondelete="CASCADE"), + primary_key=True), + sa.Column("ref_type", sa.String(32), primary_key=True), + sa.Column("ref_id", sa.String(64), primary_key=True), + ) + op.create_index("ix_ticket_links_ref", "ticket_links", ["ref_type", "ref_id"]) + op.create_table( + "ticket_notes", + sa.Column("id", sa.String(36), primary_key=True), + sa.Column("ticket_id", sa.Integer, sa.ForeignKey("tickets.id", ondelete="CASCADE"), + nullable=False), + sa.Column("at", sa.DateTime(timezone=True), nullable=False), + sa.Column("kind", sa.String(16), nullable=False), + sa.Column("actor", sa.String(64), nullable=False), + sa.Column("body", sa.Text, nullable=True), + ) + op.create_index("ix_ticket_notes_ticket", "ticket_notes", ["ticket_id", "at"]) + + +def downgrade() -> None: + op.drop_table("ticket_notes") + op.drop_table("ticket_links") + op.drop_table("tickets") diff --git a/steward/models/__init__.py b/steward/models/__init__.py index f860889..1d23473 100644 --- a/steward/models/__init__.py +++ b/steward/models/__init__.py @@ -15,6 +15,7 @@ from .settings import AppSetting from .dashboard import Dashboard, DashboardWidget, DashboardShareToken from .outbox import EmailOutbox, email_outbox_events from .timeline import TimelineEntry +from .tickets import Ticket, TicketLink, TicketNote __all__ = [ "Base", "User", "ApiToken", "UserInvite", "PasswordReset", @@ -29,4 +30,5 @@ __all__ = [ "Dashboard", "DashboardWidget", "DashboardShareToken", "EmailOutbox", "email_outbox_events", "TimelineEntry", + "Ticket", "TicketLink", "TicketNote", ] diff --git a/steward/models/tickets.py b/steward/models/tickets.py new file mode 100644 index 0000000..961d9be --- /dev/null +++ b/steward/models/tickets.py @@ -0,0 +1,67 @@ +from __future__ import annotations +from datetime import datetime +from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy.orm import Mapped, mapped_column +from .base import Base +from steward.core.ids import time_ordered_id + + +class Ticket(Base): + """A known problem, open until someone resolves it: attached to hosts + and to what it is about (a container, a monitor, a resource), or to + nothing. Numbered, so a person can say "#12".""" + __tablename__ = "tickets" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + title: Mapped[str] = mapped_column(String(200), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + # An area key of Settings → Timeline, as a timeline entry names one. + area: Mapped[str] = mapped_column(String(32), nullable=False) + hosts: Mapped[list[str]] = mapped_column(ARRAY(String(128)), nullable=False, default=list) + # What it is about when that is not a host: an alert rule's resource, a + # container ("/"), a monitor's name. + subject: Mapped[str | None] = mapped_column(String(255), nullable=True) + status: Mapped[str] = mapped_column(String(16), nullable=False, default="open") + opened_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # A username, or " via MCP"; the id is who may be named its author. + opened_by: Mapped[str] = mapped_column(String(64), nullable=False) + opened_by_id: Mapped[str | None] = mapped_column(String(36), nullable=True) + resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + resolved_by: Mapped[str | None] = mapped_column(String(64), nullable=True) + resolution: Mapped[str | None] = mapped_column(Text, nullable=True) + updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + + __table_args__ = ( + Index("ix_tickets_status", "status", "updated_at"), + Index("ix_tickets_hosts", "hosts", postgresql_using="gin"), + ) + + +class TicketLink(Base): + """What a ticket is linked to: an alert rule or a metric anomaly, by id.""" + __tablename__ = "ticket_links" + + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), + primary_key=True) + ref_type: Mapped[str] = mapped_column(String(32), primary_key=True) + ref_id: Mapped[str] = mapped_column(String(64), primary_key=True) + + __table_args__ = (Index("ix_ticket_links_ref", "ref_type", "ref_id"),) + + +class TicketNote(Base): + """One line of a ticket's log: a comment, or it being opened, edited, + resolved or reopened.""" + __tablename__ = "ticket_notes" + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id) + ticket_id: Mapped[int] = mapped_column(Integer, ForeignKey("tickets.id", ondelete="CASCADE"), + nullable=False) + at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False) + # comment | opened | edited | resolved | reopened + kind: Mapped[str] = mapped_column(String(16), nullable=False) + actor: Mapped[str] = mapped_column(String(64), nullable=False) + body: Mapped[str | None] = mapped_column(Text, nullable=True) + + __table_args__ = (Index("ix_ticket_notes_ticket", "ticket_id", "at"),) diff --git a/steward/templates/_anomaly.html b/steward/templates/_anomaly.html index 03b5f29..8fcd55e 100644 --- a/steward/templates/_anomaly.html +++ b/steward/templates/_anomaly.html @@ -50,7 +50,8 @@ Held {{ metric_value(a.metric_name, a.peak_level) }}; usual for that hour {%- endif -%} {%- endmacro %} -{# One anomaly as a row: badge, resource and metric, when, level, review. #} +{# One anomaly as a row: badge, resource and metric, when, level, review, and + the open tickets that make it a known issue (or a link to open one). #} {% macro anomaly_row(v, back=none, show_resource=true) -%} {%- set a = v.anomaly -%}
@@ -68,6 +69,12 @@ Held {{ metric_value(a.metric_name, a.peak_level) }}; usual for that hour {{ anomaly_when(v) }} · {{ anomaly_level(a) }}
{{ anomaly_review(a, back) }}
+ {%- if v.tickets %} +
{{ status_badge("known_issue") }} + {% for t in v.tickets %}#{{ t.id }} {{ t.title }}{{ ", " if not loop.last }}{% endfor %}
+ {%- elif session.user_role in ('operator', 'admin') and a.review in ('to_review', 'real') %} + + {%- endif %} {%- if "syslog" in enabled_plugins and v.last_span %} {#- The device's own log around its latest occurrence (#6002), read only when opened. #}
{{ row.says.resource }}{% else %}{{ row.says.resource }}{% endif -%} {{- row.says.tail -}} + {%- if row.tickets %} +
{{ status_badge("known_issue") }} + {% for t in row.tickets %}#{{ t.id }} {{ t.title }}{{ ", " if not loop.last }}{% endfor %}
+ {%- endif %} {%- if row.label == 'acknowledged' and row.state.acknowledged_at %}
Acknowledged{% if row.acked_by %} by {{ row.acked_by }}{% endif %} {{ row.state.acknowledged_at|ago }}
{%- endif %} @@ -57,6 +62,10 @@ {% endif %} + {% if can_edit and not row.tickets %} + Open a ticket + {% endif %} {% endfor %} diff --git a/steward/templates/base.html b/steward/templates/base.html index 6a4902b..c119504 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -43,6 +43,7 @@ Dashboard Timeline + Tickets
{# Ansible card #} +{# ── Known issues: this host's open tickets ──────────────────────────────── #} +
+ {# ── History: what happened to this host lately (timeline) ─────────────────── #}
Loading history…
diff --git a/steward/templates/monitors/detail.html b/steward/templates/monitors/detail.html index 2337d7e..13715f4 100644 --- a/steward/templates/monitors/detail.html +++ b/steward/templates/monitors/detail.html @@ -36,6 +36,7 @@
{% with back="/monitors/" ~ monitor.id, base_resource=monitor.name %}{% include "_anomaly_card.html" %}{% endwith %} +
{% set timed = history | selectattr("ms", "ne", none) | list %} {% if timed|length > 1 %} diff --git a/steward/templates/tickets/_card.html b/steward/templates/tickets/_card.html new file mode 100644 index 0000000..c0f5eeb --- /dev/null +++ b/steward/templates/tickets/_card.html @@ -0,0 +1,22 @@ +{# tickets/_card.html — the open tickets a host's, monitor's or container's + page lists, loaded into it; nothing but the button when there are none. #} +{% set can_edit = session.user_role in ('operator', 'admin') %} +{% set query = ({"host": host} if host else {"subject": subject}) %} +{% if tickets or can_edit %} +
+
+

{% if tickets %}Known issues ({{ tickets|length }} open){% else %}Known issues{% endif %}

+ {% if can_edit %}Open a ticket{% endif %} +
+ {% if tickets %} + + {% else %} +

No open tickets.

+ {% endif %} +
+{% endif %} diff --git a/steward/templates/tickets/form.html b/steward/templates/tickets/form.html new file mode 100644 index 0000000..8fcf516 --- /dev/null +++ b/steward/templates/tickets/form.html @@ -0,0 +1,81 @@ +{# tickets/form.html — open a ticket or edit one (tickets.service): its title, + area, description, hosts and what it is about, and what it links to. A new + ticket opened from an alert or anomaly arrives filled in and linked. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header %} +{% set title = ("Edit #" ~ ticket.id) if ticket else "Open a ticket" %} +{% block title %}{{ title }} — Tickets — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Tickets", "/tickets/")] + ([("#" ~ ticket.id, "/tickets/" ~ ticket.id)] if ticket else []) + [(title if not ticket else "Edit", "")]) }}{% endblock %} +{% block content %} +{{ page_header(title, "A known problem, open until someone resolves it.") }} + +{% if error %}{% endif %} + +
{{ csrf_field() }} + +
+

The problem

+
+ + +
+
+ + + What part of the infrastructure it is about. +
+
+ + + An alert or anomaly on this resource reads as a known issue while the ticket is open. +
+
+ + +
+
+ +
+

Hosts

+

The ticket shows on each ticked host's page.

+ {% if hosts %} +
+ {% for h in hosts %} + + {% endfor %} +
+ {% else %} +

No hosts yet.

+ {% endif %} +
+ + {% if links %} +
+ +
+ {% for l in links %} + + {% endfor %} +
+ {% if ticket %}

Untick one to unlink it.

{% endif %} +
+ {% endif %} + +
+ + Cancel +
+
+{% endblock %} diff --git a/steward/templates/tickets/list.html b/steward/templates/tickets/list.html new file mode 100644 index 0000000..ed7b7c6 --- /dev/null +++ b/steward/templates/tickets/list.html @@ -0,0 +1,62 @@ +{# tickets/list.html — known problems, open first (tickets.service): each with + what it is about, its area, who opened it and when, and how many comments + it has; resolved ones say who resolved them and how. #} +{% extends "base.html" %} +{% from "_macros.html" import page_header, empty_state, status_badge %} +{% block title %}Tickets — Steward{% endblock %} +{% block content %} +{% call page_header("Tickets", "Known problems, open until someone resolves them. Opening and resolving one goes on the timeline.") %} + {% if session.user_role in ('operator', 'admin') %}Open a ticket{% endif %} +{% endcall %} + +

+ {%- if open_count %}{{ open_count }} open{% else %}No open tickets{% endif %} + {%- if host %} · on {{ host }} · every host{% endif %} +

+ + + +{% if tickets %} +
+ + + + + + + + + + + + {% for t in tickets %} + + + + + + + + {% endfor %} + +
TicketWhereAreaOpened ({{ tz_label() }})State
+ #{{ t.id }} {{ t.title }} + {%- if counts.get(t.id) %}
{{ counts[t.id] }} comment{{ "s" if counts[t.id] != 1 }}
{% endif %} +
+ {%- for h in t.hosts %}{{ h }}{{ ", " if not loop.last }}{% endfor -%} + {%- if t.subject %}{% if t.hosts %}
{% endif %}{{ t.subject }}{% endif -%} + {%- if not t.hosts and not t.subject %}—{% endif -%} +
{{ labels.get(t.area, t.area) }}{{ t.opened_at|when }}
{{ t.opened_by }}
+ {{ status_badge(t.status) }} + {%- if t.status == 'resolved' %}
{{ t.resolved_by }}, {{ t.resolved_at|when }}
{% endif %} +
+
+{% else %} +
{{ empty_state({"open": "No open tickets.", "resolved": "No ticket has been resolved yet.", "all": "No tickets yet."}[status]) }}
+{% endif %} +{% endblock %} diff --git a/steward/templates/tickets/ticket.html b/steward/templates/tickets/ticket.html new file mode 100644 index 0000000..b001e54 --- /dev/null +++ b/steward/templates/tickets/ticket.html @@ -0,0 +1,78 @@ +{# tickets/ticket.html — one ticket: what is wrong and where, what it links to, + its log (opened, comments, edits, resolved, reopened), then for operators a + comment box and Resolve, or Reopen once resolved. #} +{% extends "base.html" %} +{% from "_macros.html" import crumbs, page_header, status_badge %} +{% block title %}#{{ t.id }} {{ t.title }} — Tickets — Steward{% endblock %} +{% block breadcrumb %}{{ crumbs([("Tickets", "/tickets/"), ("#" ~ t.id, "")]) }}{% endblock %} +{% block content %} +{% set can_edit = session.user_role in ('operator', 'admin') %} +{% call page_header("#" ~ t.id ~ " " ~ t.title) %} + {% if can_edit %}Edit{% endif %} +{% endcall %} + +

+ {{ status_badge(t.status) }} + {{ area_label }} + {%- for h in t.hosts %}{% if loop.first %} · {% endif %}{{ h }}{{ ", " if not loop.last }}{% endfor %} + {%- if t.subject %} · {{ t.subject }}{% endif %} + · opened by {{ t.opened_by }}, {{ t.opened_at|when }} + {%- if t.status == 'resolved' %} · resolved by {{ t.resolved_by }}, {{ t.resolved_at|when }}{% endif %} +

+ +{% if error %}{% endif %} + +{% if t.status == 'resolved' %} +
+

Resolution

+

{{ t.resolution }}

+
+{% endif %} + +
+

What is wrong

+ {% if t.body %}

{{ t.body }}

+ {% else %}

No description.

{% endif %} + {% if links %} +

Linked

+
    + {% for l in links %}
  • {% if l.url %}{{ l.label }}{% else %}{{ l.label }}{% endif %}
  • {% endfor %} +
+ {% endif %} +
+ +
+

Log ({{ tz_label() }})

+ {% set said = {"opened": "opened it", "comment": "commented", "edited": "edited", "resolved": "resolved it", "reopened": "reopened it"} %} + + + {% for n in log %} + + + + + {% endfor %} + +
{{ n.at|when }} + {{ n.actor }} {{ said.get(n.kind, n.kind) }}{% if n.kind == "edited" and n.body %}: {{ n.body }}{% endif %} + {%- if n.body and n.kind != "edited" %}
{{ n.body }}
{% endif %} +
+ + {% if can_edit %} +
{{ csrf_field() }} +
+ + +
+
+ + {% if t.status == 'open' %} + + {% else %} + + {% endif %} +
+
+ {% endif %} +
+{% endblock %} diff --git a/steward/tickets/__init__.py b/steward/tickets/__init__.py new file mode 100644 index 0000000..65b5ed2 --- /dev/null +++ b/steward/tickets/__init__.py @@ -0,0 +1 @@ +"""Tickets: known problems, open until someone resolves them.""" diff --git a/steward/tickets/routes.py b/steward/tickets/routes.py new file mode 100644 index 0000000..0d3a66d --- /dev/null +++ b/steward/tickets/routes.py @@ -0,0 +1,213 @@ +"""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/") +@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//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/") +@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//comment") +@require_role(UserRole.operator) +async def comment_ticket(number: int): + return await _act(number, "ticket.commented", tk.comment) + + +@tickets_bp.post("/tickets//resolve") +@require_role(UserRole.operator) +async def resolve_ticket(number: int): + return await _act(number, "ticket.resolved", tk.resolve) + + +@tickets_bp.post("/tickets//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/") diff --git a/steward/tickets/service.py b/steward/tickets/service.py new file mode 100644 index 0000000..6c875ee --- /dev/null +++ b/steward/tickets/service.py @@ -0,0 +1,316 @@ +"""Tickets: known problems, open until someone resolves them. The pages and +the MCP tools both go through here, so a ticket opened, commented on, +edited, resolved or reopened either way leaves the same record. + +A ticket is attached to hosts and to what it is about (`subject`: an alert +rule's resource, a container "/", a monitor's name), and may link +to alert rules and metric anomalies. Opening, resolving and reopening each +write a timeline entry under the ticket's area, so the resolution note is +the changelog's record of the fix. Edits and comments go in the ticket's own +log (TicketNote). + +An alert or anomaly is a known issue while an open ticket links to it, names +its resource as its subject, or is about its host as a whole (no subject, +the resource is the host). +""" +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import and_, func, or_, select, tuple_ + +from steward.models.tickets import Ticket, TicketLink, TicketNote +from steward.timeline.config import Config +from steward.timeline.record import record + +OPEN, RESOLVED = "open", "resolved" +STATUSES = (OPEN, RESOLVED) +TITLE_MAX = 200 +TEXT_MAX = 8000 +SUBJECT_MAX = 255 +# What a ticket links to: an alert rule or a metric anomaly, by id. +LINK_TYPES = {"alert_rule": "alert rule", "anomaly": "anomaly"} + + +def _now() -> datetime: + return datetime.now(timezone.utc) + + +def _clean(text: str | None, limit: int, what: str) -> str | None: + text = (text or "").strip() + if len(text) > limit: + raise ValueError(f"Keep {what} to {limit} characters.") + return text or None + + +async def fields(db, cfg: Config, *, title: str, area: str, body: str = "", hosts=(), + subject: str = "") -> dict: + """A ticket's fields, checked: a title, an area Settings → Timeline lists, + and hosts Steward knows by name. Raises ValueError saying what to fix.""" + from steward.models.hosts import Host + title = " ".join((title or "").split()) + if not title: + raise ValueError("Give the ticket a title: what is wrong.") + if len(title) > TITLE_MAX: + raise ValueError(f"Keep the title to {TITLE_MAX} characters; put the rest in the description.") + if area not in cfg.labels: + raise ValueError(f"Pick an area: {', '.join(cfg.labels.values())}.") + wanted = sorted({h.strip() for h in hosts if h and h.strip()}) + known = set((await db.execute(select(Host.name).where(Host.name.in_(wanted)))).scalars()) if wanted else set() + unknown = [h for h in wanted if h not in known] + if unknown: + raise ValueError(f"No host is named {', '.join(unknown)}.") + return {"title": title, "area": area, "hosts": wanted, + "body": _clean(body, TEXT_MAX, "the description"), + "subject": ((subject or "").strip()[:SUBJECT_MAX]) or None} + + +async def check_links(db, links) -> list[tuple[str, str]]: + """`links` ((type, id) pairs) as they are stored, each checked to name an + alert rule or anomaly that exists. Raises ValueError for one that doesn't.""" + from steward.models.alerts import AlertRule + from steward.models.norms import MetricAnomaly + models = {"alert_rule": AlertRule, "anomaly": MetricAnomaly} + out = [] + for ref_type, ref_id in links: + if ref_type not in models: + raise ValueError(f"A ticket links to an {' or '.join(LINK_TYPES.values())}, not {ref_type!r}.") + if await db.get(models[ref_type], str(ref_id)) is None: + raise ValueError(f"No {LINK_TYPES[ref_type]} has the id {ref_id}.") + if (ref_type, str(ref_id)) not in out: + out.append((ref_type, str(ref_id))) + return out + + +def _entry(db, t: Ticket, type_: str, at: datetime, actor: str, note: str | None = None) -> None: + said = {"ticket.opened": "opened", "ticket.resolved": "resolved", "ticket.reopened": "reopened"}[type_] + record(db, type=type_, at=at, summary=f"Ticket #{t.id} {said}: {t.title}", area=t.area, + hosts=t.hosts, subject=t.subject, actor=actor, + detail={"note": note, "ticket": t.id, "url": f"/tickets/{t.id}"}) + + +def _note(db, t: Ticket, kind: str, actor: str, at: datetime, body: str | None = None) -> None: + db.add(TicketNote(ticket_id=t.id, at=at, kind=kind, actor=(actor or "system")[:64], body=body)) + + +async def open_ticket(db, f: dict, *, actor: str, author_id: str | None, links=(), + now: datetime | None = None) -> Ticket: + """Open a ticket from checked `fields` and checked `links`, with its + first log line and its timeline entry. Call inside a transaction.""" + now = now or _now() + t = Ticket(title=f["title"], body=f["body"], area=f["area"], hosts=f["hosts"], subject=f["subject"], + status=OPEN, opened_at=now, opened_by=(actor or "system")[:64], opened_by_id=author_id, + updated_at=now) + db.add(t) + await db.flush() # its number + for ref_type, ref_id in links: + db.add(TicketLink(ticket_id=t.id, ref_type=ref_type, ref_id=ref_id)) + _note(db, t, "opened", actor, now) + _entry(db, t, "ticket.opened", now, actor) + return t + + +async def edit(db, t: Ticket, f: dict, *, actor: str, add_links=(), remove_links=(), + now: datetime | None = None) -> list[str]: + """Apply checked `fields` and link changes; the names of what changed, + logged as one "edited" line. Nothing changed logs nothing.""" + now = now or _now() + changed = [k for k in ("title", "body", "area", "hosts", "subject") if getattr(t, k) != f[k]] + for k in changed: + setattr(t, k, f[k]) + have = set(await links_of(db, t.id)) + for ref in add_links: + if ref not in have: + db.add(TicketLink(ticket_id=t.id, ref_type=ref[0], ref_id=ref[1])) + changed.append(f"linked {LINK_TYPES[ref[0]]}") + for ref in remove_links: + if ref in have: + await db.delete(await db.get(TicketLink, (t.id, ref[0], ref[1]))) + changed.append(f"unlinked {LINK_TYPES[ref[0]]}") + if changed: + t.updated_at = now + _note(db, t, "edited", actor, now, ", ".join(changed)) + return changed + + +async def comment(db, t: Ticket, body: str, *, actor: str, now: datetime | None = None) -> None: + body = _clean(body, TEXT_MAX, "a comment") + if not body: + raise ValueError("Write the comment.") + now = now or _now() + t.updated_at = now + _note(db, t, "comment", actor, now, body) + + +async def resolve(db, t: Ticket, resolution: str, *, actor: str, now: datetime | None = None) -> None: + """Resolve an open ticket. The resolution says what fixed it (or why it + no longer matters) and becomes its timeline entry's note.""" + if t.status != OPEN: + raise ValueError(f"Ticket #{t.id} is already resolved.") + resolution = _clean(resolution, TEXT_MAX, "the resolution") + if not resolution: + raise ValueError("Say what resolved it: what was done, or why it no longer matters.") + now = now or _now() + t.status, t.resolved_at, t.resolved_by, t.resolution, t.updated_at = ( + RESOLVED, now, (actor or "system")[:64], resolution, now) + _note(db, t, "resolved", actor, now, resolution) + _entry(db, t, "ticket.resolved", now, actor, resolution) + + +async def reopen(db, t: Ticket, reason: str = "", *, actor: str, now: datetime | None = None) -> None: + if t.status != RESOLVED: + raise ValueError(f"Ticket #{t.id} is open already.") + reason = _clean(reason, TEXT_MAX, "the reason") + now = now or _now() + t.status, t.resolved_at, t.resolved_by, t.resolution, t.updated_at = OPEN, None, None, None, now + _note(db, t, "reopened", actor, now, reason) + _entry(db, t, "ticket.reopened", now, actor, reason) + + +# ── Reading ───────────────────────────────────────────────────────────────── + +def query(*, status: str = OPEN, host: str = "", subject: str = "", area: str = ""): + """Tickets matching the filters: open ones by most recently touched, + or resolved ones by when they were resolved. `status` "all" is both, + open first.""" + stmt = select(Ticket) + if status in STATUSES: + stmt = stmt.where(Ticket.status == status) + if host: + stmt = stmt.where(Ticket.hosts.contains([host])) + if subject: + stmt = stmt.where(Ticket.subject == subject) + if area: + stmt = stmt.where(Ticket.area == area) + if status == RESOLVED: + return stmt.order_by(Ticket.resolved_at.desc(), Ticket.id.desc()) + return stmt.order_by((Ticket.status != OPEN), Ticket.updated_at.desc(), Ticket.id.desc()) + + +async def get(db, number) -> Ticket | None: + try: + return await db.get(Ticket, int(str(number).lstrip("#"))) + except ValueError: + return None + + +async def notes(db, ticket_id: int) -> list[TicketNote]: + return list((await db.execute(select(TicketNote).where(TicketNote.ticket_id == ticket_id) + .order_by(TicketNote.at, TicketNote.id))).scalars()) + + +async def links_of(db, ticket_id: int) -> list[tuple[str, str]]: + return [(r.ref_type, r.ref_id) for r in (await db.execute( + select(TicketLink).where(TicketLink.ticket_id == ticket_id))).scalars()] + + +async def comment_counts(db, ids) -> dict[int, int]: + if not ids: + return {} + return dict((await db.execute( + select(TicketNote.ticket_id, func.count()).where(TicketNote.ticket_id.in_(list(ids)), + TicketNote.kind == "comment") + .group_by(TicketNote.ticket_id))).all()) + + +async def link_views(db, links) -> list[dict]: + """Each link as a reader follows it: {type, id, label, url, gone}.""" + from steward.core.formatting import metric_label + from steward.models.alerts import AlertRule + from steward.models.norms import MetricAnomaly + out = [] + for ref_type, ref_id in links: + if ref_type == "alert_rule": + rule = await db.get(AlertRule, ref_id) + out.append({"type": ref_type, "id": ref_id, "gone": rule is None, + "label": f"Alert rule {rule.name}" if rule else "An alert rule since deleted", + "url": f"/alerts/history?rule={ref_id}" if rule else None}) + elif ref_type == "anomaly": + a = await db.get(MetricAnomaly, ref_id) + out.append({"type": ref_type, "id": ref_id, "gone": a is None, + "label": (f"Anomaly: {metric_label(a.metric_name)} on {a.resource_name}" if a + else "An anomaly no longer kept"), + "url": "/alerts/anomalies" if a else None}) + return out + + +async def known_issues(db, wants: dict) -> dict: + """{key: [open Ticket, …]} for each key of `wants`, {key: (refs, + resource)}: the open tickets linked to any of `refs` ((type, id) pairs), + naming `resource` as their subject, or about `resource` as a host whole + (no subject, the host in their hosts). Keys with none are left out.""" + refs = {r for rs, _ in wants.values() for r in rs if r[1]} + resources = {res for _, res in wants.values() if res} + if not refs and not resources: + return {} + conds = [] + if resources: + conds.append(Ticket.subject.in_(resources)) + conds.append(and_(Ticket.subject.is_(None), Ticket.hosts.overlap(list(resources)))) + if refs: + conds.append(Ticket.id.in_(select(TicketLink.ticket_id).where( + tuple_(TicketLink.ref_type, TicketLink.ref_id).in_(list(refs))))) + tickets = list((await db.execute(select(Ticket).where(Ticket.status == OPEN, or_(*conds)) + .order_by(Ticket.id))).scalars()) + if not tickets: + return {} + linked: dict[int, set] = {} + for row in (await db.execute(select(TicketLink).where( + TicketLink.ticket_id.in_([t.id for t in tickets])))).scalars(): + linked.setdefault(row.ticket_id, set()).add((row.ref_type, row.ref_id)) + out = {} + for key, (rs, res) in wants.items(): + hit = [t for t in tickets + if linked.get(t.id, set()) & set(rs) + or (res and (t.subject == res or (t.subject is None and res in t.hosts)))] + if hit: + out[key] = hit + return out + + +async def for_page(db, *, host: str = "", subject: str = "") -> list[Ticket]: + """The open tickets a host's or a resource's page lists: on the host, or + about the subject.""" + conds = [] + if host: + conds.append(Ticket.hosts.contains([host])) + if subject: + conds.append(Ticket.subject == subject) + if not conds: + return [] + return list((await db.execute(select(Ticket).where(Ticket.status == OPEN, or_(*conds)) + .order_by(Ticket.updated_at.desc()))).scalars()) + + +async def open_count(db) -> int: + return (await db.execute(select(func.count()).select_from(Ticket).where(Ticket.status == OPEN))).scalar() or 0 + + +async def draft(db, *, rule_id: str = "", anomaly_id: str = "", host: str = "", + subject: str = "") -> tuple[dict, list[tuple[str, str]]]: + """A new ticket's starting fields and links, from what it is opened on: + an alert rule or anomaly (its resource, hosts, area and a title, linked), + a host, or a subject.""" + from steward.core.formatting import metric_label + from steward.models.alerts import AlertRule + from steward.models.norms import MetricAnomaly + from steward.timeline import catalog + from steward.timeline.record import hosts_of_rule + values = {"title": "", "area": "", "body": "", "hosts": [host] if host else [], "subject": subject} + links: list[tuple[str, str]] = [] + rule = await db.get(AlertRule, rule_id) if rule_id else None + anomaly = await db.get(MetricAnomaly, anomaly_id) if anomaly_id else None + if rule is not None: + values.update(title=f"{rule.name} on {rule.resource_name}", subject=rule.resource_name, + area=catalog.area_of_metric(rule.source_module, rule.metric_name), + hosts=await hosts_of_rule(db, rule.source_module, rule.resource_name)) + links.append(("alert_rule", rule.id)) + elif anomaly is not None: + values.update(title=f"{metric_label(anomaly.metric_name)} on {anomaly.resource_name} out of its normal", + subject=anomaly.resource_name, + area=catalog.area_of_metric(anomaly.source_module, anomaly.metric_name), + hosts=await hosts_of_rule(db, anomaly.source_module, anomaly.resource_name)) + links.append(("anomaly", anomaly.id)) + if values["subject"] and values["subject"] in values["hosts"]: + values["subject"] = "" # about the host as a whole + return values, links diff --git a/steward/timeline/catalog.py b/steward/timeline/catalog.py index 0202b84..c8a9a83 100644 --- a/steward/timeline/catalog.py +++ b/steward/timeline/catalog.py @@ -77,6 +77,13 @@ for _t in ( "What an alert rule watches reported again after a silence."), ("change.recorded", "Change recorded", "change", "service", True, "A change someone recorded by hand, on the Timeline or a host's page, or through MCP."), + # A ticket's entries are written with its own area. + ("ticket.opened", "Ticket opened", "ticket", "monitoring", True, + "Someone opened a ticket for a known problem."), + ("ticket.resolved", "Ticket resolved", "ticket", "monitoring", True, + "A ticket was resolved; its resolution is the entry's note."), + ("ticket.reopened", "Ticket reopened", "ticket", "monitoring", True, + "A resolved ticket was opened again."), ("ansible.changed", "Playbook changed something", "automation", "host_os", True, "A playbook run finished and changed at least one host."), ("ansible.unchanged", "Playbook changed nothing", "automation", "host_os", False, @@ -149,6 +156,9 @@ _AUDIT_TYPES = ( ("docker.prune.", None), # A change recorded by hand is on the timeline as itself (timeline.changes). ("timeline.change", None), + # A ticket's opening, resolution and reopening are on the timeline as + # themselves (tickets.service); its edits and comments are its own log. + ("ticket.", None), ("auth.login", "steward.signin"), ("auth.setup", "steward.config"), ("account.", "steward.access"), ("user.", "steward.access"), ("api_token.", "steward.access"), ("backup.", "steward.backup"), diff --git a/tests/core/test_tickets.py b/tests/core/test_tickets.py new file mode 100644 index 0000000..9f136d5 --- /dev/null +++ b/tests/core/test_tickets.py @@ -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 '#12 Backups time out at night' in page + assert "2 comments" in page and ">Storage<" in page and "rack fan" in page + assert 'nas-1' 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 'Alert rule Backup late' in mine + assert 'An anomaly no longer kept' 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 '' 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 " 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()) diff --git a/tests/mcp/test_anomaly_review.py b/tests/mcp/test_anomaly_review.py index c08fbf6..ea55d5b 100644 --- a/tests/mcp/test_anomaly_review.py +++ b/tests/mcp/test_anomaly_review.py @@ -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 diff --git a/tests/test_breadcrumbs.py b/tests/test_breadcrumbs.py index cda3ada..ea50b3b 100644 --- a/tests/test_breadcrumbs.py +++ b/tests/test_breadcrumbs.py @@ -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",