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

A ticket records a known problem: title, area, hosts, subject, description,
links to alert rules and anomalies, and a log of comments, edits, resolving
and reopening. Opening, resolving (with the resolution as the note) and
reopening go on the timeline; every step is audited, and the audit rows stay
off the timeline.

An alert or anomaly an open ticket covers (by link, by subject, or a
host-wide ticket on the host itself) shows a Known issue badge, and offers
"Open a ticket" prefilled from its rule or anomaly when none does. Hosts,
monitors and containers get a Known issues card.

MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket,
comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and
list_anomalies carry known_issue; get_overview, get_host, get_monitor and
get_container carry open_tickets. Also update_change and delete_change for
changes recorded by hand (author or admin).

Tickets are left out of backups, like the timeline (2026-10-15 ruling).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-24 22:06:43 -04:00
co-authored by Claude Opus 5.5
parent 1ea86c0330
commit 2a561210c3
40 changed files with 1786 additions and 35 deletions
+1
View File
@@ -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 |
+3 -1
View File
@@ -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 "<you> 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 "<you> 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
+55
View File
@@ -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`).
+2 -1
View File
@@ -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 "<user> 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 "<user> 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
+6 -1
View File
@@ -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}")',
}
@@ -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 ──────────────────────────────────────────────────────── #}
<div hx-get="/tickets/card?subject={{ anomaly_resource|urlencode }}&amp;back={{ anomaly_back|urlencode }}" hx-trigger="load" hx-swap="outerHTML"></div>
{# ── What it is set up with, and what it moved since it started ─────────── #}
<div class="card">
+10 -1
View File
@@ -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
+2
View File
@@ -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.
+14 -1
View File
@@ -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:
+3
View File
@@ -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",
+7 -1
View File
@@ -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
+5
View File
@@ -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"),
}
+3 -3
View File
@@ -13,10 +13,10 @@ each argument's empty value means, and what comes back.
from __future__ import annotations
from . import (alerts, anomalies, audit, automation, backups, hosts, metrics, monitors, overview, session,
timeline)
tickets, timeline)
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, automation,
backups)
_MODULES = (session, overview, hosts, monitors, alerts, anomalies, metrics, audit, timeline, tickets,
automation, backups)
def register_all(register_tool) -> None:
+9 -3
View File
@@ -10,6 +10,7 @@ from steward.core import outbox
from steward.mcp._context import current_caller, mcp_app
from steward.models.alerts import AlertOperator, AlertRule, AlertState, MaintenanceWindow
from steward.models.users import UserRole
from steward.tickets.service import known_issues
from ._audit import audit_mcp, mcp_username
from ._shape import (
@@ -44,13 +45,17 @@ async def list_alerts() -> dict:
(no sample for three of its usual gaps, at least 5 minutes); a firing
alert that goes silent stays "firing" with `no_data_since` set. Each
carries the rule's condition, the resource and metric it watches, `since`
(its latest state change) and `last_sample_at`. Firing, unacknowledged
alerts come first. An alert that cleared is not here; get_alert_history
shows the transitions.
(its latest state change), `last_sample_at`, and `known_issue`: the open
tickets covering it (get_ticket says what is known and tried). Firing,
unacknowledged alerts come first. An alert that cleared is not here;
get_alert_history shows the transitions.
"""
async with db_session() as db:
active = await alert_svc.active_alerts(db)
names = await alert_svc.usernames(db, (s.acknowledged_by for s, _ in active))
known = await known_issues(db, {
rule.id: ([("alert_rule", rule.id), ("alert_rule", rule.parent_id or "")], rule.resource_name)
for _, rule in active})
order = {"firing": 0, "no_data": 1, "pending": 2, "acknowledged": 3}
rows = [
{
@@ -67,6 +72,7 @@ async def list_alerts() -> dict:
"age_s": age_s(state.last_transition_at),
"acknowledged_by": names.get(state.acknowledged_by) if state.acknowledged_by else None,
"acknowledged_at": iso(state.acknowledged_at),
"known_issue": [{"ticket": t.id, "title": t.title} for t in known.get(rule.id, [])],
}
for state, rule in active
]
+3 -1
View File
@@ -45,6 +45,7 @@ def _row(v: anom.AnomalyView) -> dict:
"reviewed_at": iso(a.reviewed_at),
"note": a.note,
"page": v.href,
"known_issue": [{"ticket": t.id, "title": t.title} for t in v.tickets],
}
@@ -64,7 +65,8 @@ async def list_anomalies(review: str = "to_review", limit: int = 50) -> dict:
`usual_hold` (the level that hour usually holds) and `usual_peak` (how high
it usually goes), all in the metric's own unit. `ongoing` is true while an
occurrence is still going. To-review rows come first, newest occurrence
first. review_anomaly marks one.
first. `known_issue` lists the open tickets covering it. review_anomaly
marks one; open_ticket keeps a real one's notes until it is fixed.
"""
if review and review not in anom.REVIEWS:
raise ValueError(f"review must be one of {', '.join(anom.REVIEWS)}, or empty for all")
+6 -2
View File
@@ -15,6 +15,7 @@ from steward.models.users import UserRole
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, lookback, pick, rnd, summarize,
)
from .tickets import open_tickets_for
AGENT_SOURCE = "host_agent"
@@ -141,8 +142,9 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
Returns the host's monitors (latest result, error, uptime), the agent's
latest values, alerts currently pending, firing or acknowledged on this
host's resources, and the Ansible inventory target linked to it with its
groups (`ansible_target`: null when unlinked).
host's resources, its open tickets (`open_tickets`: known problems someone
recorded, list_tickets / get_ticket), and the Ansible inventory target
linked to it with its groups (`ansible_target`: null when unlinked).
The agent's values are `agent.metrics` (host-level) and its sub-resources
grouped by kind: `mounts` (fullest first), `interfaces` and `disk_io`
@@ -166,6 +168,7 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
select(AnsibleTarget).where(AnsibleTarget.host_id == h.id)
.options(selectinload(AnsibleTarget.groups))
)).scalar_one_or_none()
tickets = await open_tickets_for(db, host=h.name)
agent = None
if samples:
@@ -218,6 +221,7 @@ async def get_host(host: str, parts_limit: int = 20) -> dict:
],
"agent": agent,
"active_alerts": alerts,
"open_tickets": tickets,
"ansible_target": {
"target_id": target.id,
"target_name": target.name,
+4
View File
@@ -16,6 +16,7 @@ from ._audit import audit_mcp
from ._shape import (
age_s, bounded, clamp_limit, db_session, iso, now_utc, pick, redact_url, rnd,
)
from .tickets import open_tickets_for
_STATES = ("up", "down", "pending", "disabled")
@@ -126,6 +127,7 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
often it runs; `interval_from` says whether that is its own setting
("monitor") or the global monitors interval it follows ("global").
Header values in an HTTP monitor's configuration are redacted.
`open_tickets` are the open tickets about it (list_tickets).
"""
failures = clamp_limit(failures, 10, 50)
async with db_session() as db:
@@ -133,11 +135,13 @@ async def get_monitor(monitor: str, failures: int = 10) -> dict:
m = pick(monitor, monitors, kind="monitor", list_tool="list_monitors")
st = (await monitor_svc.monitor_states(db, [m]))[m.id]
failed = await monitor_svc.recent_failures(db, m.id, failures)
tickets = await open_tickets_for(db, subject=m.name)
return {
**_row(m, st, host_names),
**_interval(m),
"config": _config(m),
"heartbeat": "".join("+" if r.is_up else "-" for r in st["recent"]),
"open_tickets": tickets,
"recent_failures": [
{
"checked_at": iso(r.checked_at),
+13
View File
@@ -18,12 +18,16 @@ from steward.models.hosts import Host
from steward.models.monitors import Monitor
from steward.models.users import UserRole
from steward.monitors import service as monitor_svc
from steward.tickets import service as tk
from ._shape import age_s, db_session, iso, now_utc, rnd
from .tickets import brief
logger = logging.getLogger(__name__)
FINDINGS_CAP = 50
# Open tickets get_overview lists; list_tickets has the rest.
TICKETS_CAP = 10
# A scheduled backup this long past its slot with none made is overdue: one
# 15-minute tick and the hour a failed try waits, with room to spare.
BACKUP_OVERDUE_AFTER = timedelta(hours=2)
@@ -291,6 +295,10 @@ async def get_overview() -> dict:
`counts` sizes the fleet; `counts.anomalies_to_review` is how many metric
anomalies wait for someone to mark them normal or real (list_anomalies).
`open_tickets` are known problems someone recorded and is working, most
recently worked first (up to 10; `counts.tickets_open` counts them all,
list_tickets lists them). A finding an open ticket covers is already
known: read the ticket (get_ticket) before treating it as new.
`active_maintenance` names windows currently silencing alerts.
`all_clear` is true when nothing is wrong.
"""
@@ -322,6 +330,8 @@ async def get_overview() -> dict:
open_anomalies = await anom.open_anomalies(db)
to_review = await anom.to_review_count(db)
settings = await get_all_settings(db)
open_tickets = list((await db.execute(tk.query(status=tk.OPEN).limit(TICKETS_CAP + 1))).scalars())
tickets_open = await tk.open_count(db)
thresholds = app.config.get("THRESHOLDS") or {}
listed, silent, speaking = fold_agent_silence(active, agent)
@@ -347,7 +357,10 @@ async def get_overview() -> dict:
"critical": sum(1 for f in findings if f["severity"] == "critical"),
"warning": sum(1 for f in findings if f["severity"] == "warning"),
"anomalies_to_review": to_review,
"tickets_open": tickets_open,
},
# Known problems someone is working: what is wrong that is already known.
"open_tickets": [brief(t) for t in open_tickets[:TICKETS_CAP]],
"active_maintenance": [
{"window_id": w.id, "name": w.name, "end_at": iso(w.end_at),
"scope": {"source_module": w.scope_source, "resource": w.scope_resource}}
+224
View File
@@ -0,0 +1,224 @@
"""Tickets: known problems, open until someone resolves them, worked from MCP
the same as from the Tickets pages (steward.tickets.service)."""
from __future__ import annotations
from steward.mcp._context import current_caller
from steward.models.users import UserRole
from steward.tickets import service as tk
from steward.timeline import config as tl_config
from ._audit import audit_mcp, mcp_username
from ._shape import age_s, bounded, clamp_limit, db_session, iso
from .timeline import area_key, host_hint
LIST_MAX = 200
def brief(t) -> dict:
"""A ticket in a list, or carried by another tool's reply (get_overview,
get_host, get_monitor, get_container, list_alerts, list_anomalies)."""
return {"ticket": t.id, "title": t.title, "status": t.status, "area": t.area, "hosts": t.hosts,
"subject": t.subject, "opened_at": iso(t.opened_at), "opened_by": t.opened_by,
"updated_at": iso(t.updated_at), "age_s": age_s(t.opened_at),
"resolved_at": iso(t.resolved_at), "resolved_by": t.resolved_by}
async def open_tickets_for(db, *, host: str = "", subject: str = "") -> list[dict]:
return [brief(t) for t in await tk.for_page(db, host=host, subject=subject)]
async def _full(db, t) -> dict:
log = await tk.notes(db, t.id)
return {
**brief(t),
"description": t.body,
"resolution": t.resolution,
"links": await tk.link_views(db, await tk.links_of(db, t.id)),
"log": [{"at": iso(n.at), "kind": n.kind, "by": n.actor, "text": n.body} for n in log],
"url": f"/tickets/{t.id}",
}
async def _ticket(db, ticket):
t = await tk.get(db, ticket)
if t is None:
raise ValueError(f"No ticket #{str(ticket).lstrip('#')}; list_tickets lists them.")
return t
def _hosts(hosts) -> list[str]:
if isinstance(hosts, str):
hosts = hosts.split(",")
return [h.strip() for h in (hosts or []) if h and h.strip()]
def _links(alert_rules=None, anomalies=None) -> list[tuple[str, str]]:
def ids(v):
return [i.strip() for i in (v.split(",") if isinstance(v, str) else (v or [])) if i and str(i).strip()]
return [("alert_rule", i) for i in ids(alert_rules)] + [("anomaly", i) for i in ids(anomalies)]
async def list_tickets(status: str = "open", host: str = "", subject: str = "", area: str = "",
limit: int = 50) -> dict:
"""Tickets: known problems someone recorded, open until resolved. Open
ones come first, most recently worked on first.
Read the open tickets before reporting what is wrong: an alert or anomaly
an open ticket covers is already known (list_alerts and list_anomalies
mark it `known_issue`), and the ticket says what has been tried.
status: "open" (default), "resolved" or "all".
host: a host's name, exactly; empty for every host.
subject: what a ticket is about, exactly (a resource, "<host>/<container>",
a monitor's name); empty for any.
area: an area key (get_timeline's `areas`); empty for all.
limit: default 50, max 200.
"""
if status not in (*tk.STATUSES, "all"):
raise ValueError('status is "open", "resolved" or "all".')
limit = clamp_limit(limit, 50, LIST_MAX)
async with db_session() as db:
rows = list((await db.execute(tk.query(status=status, host=host.strip(), subject=subject.strip(),
area=area.strip()).limit(limit + 1))).scalars())
open_n = await tk.open_count(db)
out = {**bounded([brief(t) for t in rows], limit, key="tickets"), "open_total": open_n}
if out["truncated"]:
out["total"] = None
out["more"] = f"More than {limit} tickets. Pass a larger `limit` (max {LIST_MAX}) or filter."
return out
async def get_ticket(ticket: int | str) -> dict:
"""One ticket in full: its description, what it links to (alert rules,
anomalies), its resolution, and its log of comments, edits, resolving and
reopening, oldest first.
ticket: its number (12 or "#12").
"""
async with db_session() as db:
return await _full(db, await _ticket(db, ticket))
async def open_ticket(title: str, area: str, description: str = "", hosts: list[str] | str | None = None,
subject: str = "", alert_rules: list[str] | str | None = None,
anomalies: list[str] | str | None = None) -> dict:
"""Open a ticket for a known problem that will take more than a moment to
fix: a disk failing slowly, a service that keeps restarting, a backup that
times out at night. Acknowledging an alert only quiets it; a ticket keeps
what is known, what was tried and, once resolved, what fixed it, and it
marks the alerts and anomalies it covers as a known issue. Check
list_tickets first for one already open on it.
title: what is wrong, one line (up to 200 characters).
area: what part of the infrastructure it is about, by key or label:
service, host_os, hardware, storage, network, security, monitoring
(get_timeline's `areas`).
description: what is seen, since when, what has been tried (optional).
hosts: the host(s) it is on, by exact name (list_hosts).
subject: what it is about when that is not a host as a whole: an alert
rule's resource, "<host>/<container>", a monitor's name. An alert or
anomaly on this resource reads as a known issue while it is open.
alert_rules: alert rule ids to link (list_alerts' `rule_id`).
anomalies: anomaly ids to link (list_anomalies' `anomaly_id`).
Opening it goes on the timeline. Recorded in the audit log as "<you> via
MCP".
"""
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await tk.fields(db, cfg, title=title, area=area_key(cfg, area), body=description,
hosts=_hosts(hosts), subject=subject)
except ValueError as exc:
raise host_hint(exc) from None
links = await tk.check_links(db, _links(alert_rules, anomalies))
t = await tk.open_ticket(db, f, actor=mcp_username(caller.username), author_id=caller.user_id,
links=links)
out = await _full(db, t)
await audit_mcp("ticket.opened", "ticket", str(t.id),
{"name": t.title, "hosts": t.hosts, "links": [f"{r}:{i}" for r, i in links]})
return out
async def update_ticket(ticket: int | str, title: str | None = None, area: str | None = None,
description: str | None = None, hosts: list[str] | str | None = None,
subject: str | None = None, link_alert_rules: list[str] | str | None = None,
link_anomalies: list[str] | str | None = None,
unlink_alert_rules: list[str] | str | None = None,
unlink_anomalies: list[str] | str | None = None) -> dict:
"""Change a ticket: its title, area, description, hosts or subject, and
what it links to. Only what you pass changes; an empty string clears the
description or subject, an empty list clears the hosts. To add to the
log, use comment_ticket; to close it, resolve_ticket.
ticket: its number. The rest as open_ticket; link_*/unlink_* take ids.
Recorded in the ticket's log and the audit log.
"""
caller = current_caller()
async with db_session() as db:
async with db.begin():
t = await _ticket(db, ticket)
cfg = await tl_config.load(db)
try:
f = await tk.fields(db, cfg, title=t.title if title is None else title,
area=t.area if area is None else area_key(cfg, area),
body=t.body if description is None else description,
hosts=t.hosts if hosts is None else _hosts(hosts),
subject=t.subject if subject is None else subject)
except ValueError as exc:
raise host_hint(exc) from None
add = await tk.check_links(db, _links(link_alert_rules, link_anomalies))
changed = await tk.edit(db, t, f, actor=mcp_username(caller.username), add_links=add,
remove_links=_links(unlink_alert_rules, unlink_anomalies))
out = await _full(db, t)
if changed:
await audit_mcp("ticket.edited", "ticket", str(t.id), {"name": t.title, "changed": changed})
return {**out, "changed": changed}
async def comment_ticket(ticket: int | str, comment: str) -> dict:
"""Add a comment to a ticket's log: what you found, what you tried, what
happened. Works on a resolved ticket too.
ticket: its number. comment: up to 8000 characters.
Recorded in the audit log.
"""
return await _act(ticket, "ticket.commented", tk.comment, comment)
async def resolve_ticket(ticket: int | str, resolution: str) -> dict:
"""Resolve an open ticket. `resolution` says what fixed it, or why it no
longer matters; it goes on the timeline as the record of the fix, so
write it for someone reading the host's history later.
ticket: its number. Recorded in the audit log.
"""
return await _act(ticket, "ticket.resolved", tk.resolve, resolution)
async def reopen_ticket(ticket: int | str, reason: str = "") -> dict:
"""Reopen a resolved ticket: the problem is back, or the fix did not
hold. `reason` says why (optional). Goes on the timeline; recorded in the
audit log.
"""
return await _act(ticket, "ticket.reopened", tk.reopen, reason)
async def _act(ticket, action: str, fn, text: str) -> dict:
caller = current_caller()
async with db_session() as db:
async with db.begin():
t = await _ticket(db, ticket)
await fn(db, t, text, actor=mcp_username(caller.username))
out = await _full(db, t)
await audit_mcp(action, "ticket", str(t.id), {"name": t.title})
return out
def register(register_tool) -> None:
register_tool(list_tickets, role=UserRole.viewer, read_only=True)
register_tool(get_ticket, role=UserRole.viewer, read_only=True)
for fn in (open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket):
register_tool(fn, role=UserRole.operator, read_only=False)
+87 -16
View File
@@ -84,8 +84,9 @@ async def get_timeline(host: str = "", area: str = "", kind: str = "", type: str
return out
def _area(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it."""
def area_key(cfg, area: str) -> str:
"""An area by its key or its label, as Settings → Timeline names it
(log_change, and the ticket tools)."""
want = (area or "").strip().lower()
for key, label in cfg.areas:
if want in (key, label.lower()):
@@ -93,6 +94,22 @@ def _area(cfg, area: str) -> str:
raise ValueError(f"area {area!r} is not one of {', '.join(cfg.labels)} (get_timeline lists them).")
def _when(at: str):
"""An ISO-8601 time (no zone is UTC), or None for an empty one."""
if not (at or "").strip():
return None
try:
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
return when if when.tzinfo else when.replace(tzinfo=timezone.utc)
def host_hint(exc: ValueError) -> ValueError:
"""A refusal naming a host that isn't one, pointed at the tool that lists them."""
return ValueError(f"{exc} list_hosts gives the names.") if "No host is named" in str(exc) else exc
async def log_change(summary: str, area: str, hosts: list[str] | str | None = None, subject: str = "",
detail: str = "", at: str = "") -> dict:
"""Record on the timeline a change Steward can't see happen: a config
@@ -115,29 +132,22 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
4000 characters).
at: when it was done, ISO-8601 (a time with no zone is UTC); default now.
Shown as by "<you> via MCP"; the person whose token this is can edit or
delete it from the Timeline page. Recorded in the audit log.
Shown as by "<you> via MCP". It can be corrected with update_change or
taken back with delete_change, by the person whose token this is or an
admin. Recorded in the audit log.
"""
if isinstance(hosts, str):
hosts = hosts.split(",")
when = None
if (at or "").strip():
try:
when = datetime.fromisoformat(at.strip().replace("Z", "+00:00"))
except ValueError:
raise ValueError(f"at {at!r} is not an ISO-8601 time, e.g. 2026-10-06T22:00:00Z.") from None
when = when if when.tzinfo else when.replace(tzinfo=timezone.utc)
when = _when(at)
caller = current_caller()
async with db_session() as db:
async with db.begin():
cfg = await tl_config.load(db)
try:
f = await changes.fields(db, cfg, summary=summary, area=_area(cfg, area), hosts=hosts or [],
f = await changes.fields(db, cfg, summary=summary, area=area_key(cfg, area), hosts=hosts or [],
subject=subject, note=detail, at=when)
except ValueError as exc:
if "No host is named" in str(exc):
raise ValueError(f"{exc} list_hosts gives the names.") from None
raise
raise host_hint(exc) from None
entry = changes.log(db, f, author_id=caller.user_id, actor=mcp_username(caller.username))
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_recorded", "timeline_entry", entry.id,
@@ -145,6 +155,67 @@ async def log_change(summary: str, area: str, hosts: list[str] | str | None = No
return _entry(v)
async def _own(db, entry_id: str):
"""A recorded change the caller may edit: one their token's user wrote,
or any for an admin."""
caller = current_caller()
entry = await changes.get(db, entry_id)
if entry is None:
raise ValueError(f"No recorded change has the id {entry_id!r} (get_timeline's `entry_id`, "
"on an entry of type change.recorded).")
if not changes.can_edit(entry, caller.user_id, getattr(caller.role, "value", caller.role)):
raise ValueError(f"Change {entry_id} was recorded by {entry.actor}; only they or an admin can change it.")
return entry
async def update_change(entry_id: str, summary: str | None = None, area: str | None = None,
hosts: list[str] | str | None = None, subject: str | None = None,
detail: str | None = None, at: str | None = None) -> dict:
"""Correct a change recorded with log_change (or on the Timeline page):
only what you pass changes; an empty string clears the subject or
detail, an empty list the hosts. Only its author or an admin can.
entry_id: the change's `entry_id` (log_change's reply, or get_timeline).
The rest as log_change. Recorded in the audit log.
"""
async with db_session() as db:
async with db.begin():
entry = await _own(db, entry_id)
cfg = await tl_config.load(db)
if isinstance(hosts, str):
hosts = hosts.split(",")
try:
f = await changes.fields(
db, cfg, summary=entry.summary if summary is None else summary,
area=entry.area if area is None else area_key(cfg, area),
hosts=entry.hosts if hosts is None else hosts,
subject=(entry.subject or "") if subject is None else subject,
note=((entry.detail or {}).get("note") or "") if detail is None else detail,
at=entry.at if at is None else _when(at))
except ValueError as exc:
raise host_hint(exc) from None
changes.edit(entry, f)
[v] = await tl.views(db, cfg, [entry])
await audit_mcp("timeline.change_edited", "timeline_entry", entry.id, {"name": entry.summary})
return _entry(v)
async def delete_change(entry_id: str) -> dict:
"""Take a recorded change off the timeline: one logged by mistake, or
twice. Only its author or an admin can; the audit log keeps that it was.
entry_id: the change's `entry_id`.
"""
async with db_session() as db:
async with db.begin():
entry = await _own(db, entry_id)
summary = entry.summary
await db.delete(entry)
await audit_mcp("timeline.change_deleted", "timeline_entry", entry_id, {"name": summary})
return {"deleted": entry_id, "summary": summary}
def register(register_tool) -> None:
register_tool(get_timeline, role=UserRole.viewer, read_only=True)
register_tool(log_change, role=UserRole.operator, read_only=False)
for fn in (log_change, update_change, delete_change):
register_tool(fn, role=UserRole.operator, read_only=False)
@@ -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")
+2
View File
@@ -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",
]
+67
View File
@@ -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 ("<host>/<name>"), 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 "<user> 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"),)
+8 -1
View File
@@ -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 -%}
<div class="anomaly-row" style="padding:0.55rem 0;border-bottom:1px solid var(--border);">
@@ -68,6 +69,12 @@ Held {{ metric_value(a.metric_name, a.peak_level) }}; usual for that hour
{{ anomaly_when(v) }} · {{ anomaly_level(a) }}
</div>
<div style="font-size:0.8rem;color:var(--text-muted);margin-top:0.2rem;">{{ anomaly_review(a, back) }}</div>
{%- if v.tickets %}
<div style="font-size:0.8rem;margin-top:0.2rem;">{{ status_badge("known_issue") }}
{% for t in v.tickets %}<a href="/tickets/{{ t.id }}">#{{ t.id }} {{ t.title }}</a>{{ ", " if not loop.last }}{% endfor %}</div>
{%- elif session.user_role in ('operator', 'admin') and a.review in ('to_review', 'real') %}
<div style="font-size:0.8rem;margin-top:0.2rem;"><a href="/tickets/new?anomaly={{ a.id|urlencode }}{% if back %}&amp;back={{ back|urlencode }}{% endif %}">Open a ticket</a></div>
{%- endif %}
{%- if "syslog" in enabled_plugins and v.last_span %}
{#- The device's own log around its latest occurrence (#6002), read only when opened. #}
<details style="font-size:0.8rem;margin-top:0.3rem;"
+10 -1
View File
@@ -3,7 +3,8 @@
there are and where their notifications go; then the alerts themselves,
firing first, then no data, then acknowledged, then pending
(alert_svc.active_rows). An acknowledged alert says how long it has been
breaching, and apart from that who acknowledged it and when. #}
breaching, and apart from that who acknowledged it and when. One an open
ticket covers is a known issue, linked to it; the rest offer a ticket. #}
{% from "_macros.html" import status_badge %}
{% set can_edit = session.user_role in ('operator', 'admin') %}
{% set words = {"firing": "firing", "no_data": "silent", "acknowledged": "acknowledged", "pending": "pending"} %}
@@ -46,6 +47,10 @@
{{- row.says.lead -}}
{%- if row.url %}<a href="{{ row.url }}">{{ row.says.resource }}</a>{% else %}<strong>{{ row.says.resource }}</strong>{% endif -%}
{{- row.says.tail -}}
{%- if row.tickets %}
<div class="field-hint" style="margin-top:0.15rem;">{{ status_badge("known_issue") }}
{% for t in row.tickets %}<a href="/tickets/{{ t.id }}">#{{ t.id }} {{ t.title }}</a>{{ ", " if not loop.last }}{% endfor %}</div>
{%- endif %}
{%- if row.label == 'acknowledged' and row.state.acknowledged_at %}
<div class="field-hint" style="margin-top:0.15rem;">Acknowledged{% if row.acked_by %} by {{ row.acked_by }}{% endif %} {{ row.state.acknowledged_at|ago }}</div>
{%- endif %}
@@ -57,6 +62,10 @@
<button type="submit" class="btn btn-sm btn-warn">Acknowledge</button>
</form>
{% endif %}
{% if can_edit and not row.tickets %}
<a class="btn btn-sm btn-ghost" href="/tickets/new?rule={{ row.rule.id|urlencode }}&amp;back=/alerts/"
title="A known problem that will take a while: keep its notes and its fix in one place">Open a ticket</a>
{% endif %}
</td>
</tr>
{% endfor %}
+1
View File
@@ -43,6 +43,7 @@
<div class="nav-group-label">Overview</div>
<a href="/" {% if _p == '/' %}class="active" aria-current="page"{% endif %}>Dashboard</a>
<a href="/timeline/" {% if _p.startswith('/timeline') %}class="active" aria-current="page"{% endif %}>Timeline</a>
<a href="/tickets/" {% if _p.startswith('/tickets') %}class="active" aria-current="page"{% endif %}>Tickets</a>
</div>
<div class="nav-group">
<div class="nav-group-label">Infrastructure</div>
+3
View File
@@ -202,6 +202,9 @@
{% endif %}
</div>{# Ansible card #}
{# ── Known issues: this host's open tickets ──────────────────────────────── #}
<div hx-get="/tickets/card?host={{ host.name|urlencode }}&amp;back={{ ('/hosts/' ~ host.id)|urlencode }}" hx-trigger="load" hx-swap="outerHTML"></div>
{# ── History: what happened to this host lately (timeline) ─────────────────── #}
<div hx-get="/timeline/host/{{ host.id }}" hx-trigger="load" hx-swap="innerHTML">
<div class="card"><span style="color:var(--text-muted);font-size:0.85rem;">Loading history…</span></div>
+1
View File
@@ -36,6 +36,7 @@
</div>
{% with back="/monitors/" ~ monitor.id, base_resource=monitor.name %}{% include "_anomaly_card.html" %}{% endwith %}
<div hx-get="/tickets/card?subject={{ monitor.name|urlencode }}&amp;back={{ ('/monitors/' ~ monitor.id)|urlencode }}" hx-trigger="load" hx-swap="outerHTML"></div>
{% set timed = history | selectattr("ms", "ne", none) | list %}
{% if timed|length > 1 %}
+22
View File
@@ -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 %}
<section class="card" aria-labelledby="known-issues">
<div style="display:flex;align-items:baseline;justify-content:space-between;gap:1rem;">
<h3 id="known-issues" class="section-title">{% if tickets %}Known issues ({{ tickets|length }} open){% else %}Known issues{% endif %}</h3>
{% if can_edit %}<a class="field-hint" href="/tickets/new?{{ dict(query, back=back)|urlencode }}">Open a ticket</a>{% endif %}
</div>
{% if tickets %}
<ul class="check-list">
{% for t in tickets %}
<li><a href="/tickets/{{ t.id }}"><span class="tone-dim">#{{ t.id }}</span> {{ t.title }}</a>
<span class="field-hint">· {{ t.opened_by }}, {{ t.opened_at|when }}</span></li>
{% endfor %}
</ul>
{% else %}
<p class="field-hint" style="margin:0;">No open tickets.</p>
{% endif %}
</section>
{% endif %}
+81
View File
@@ -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 %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
<form method="post" action="{{ '/tickets/' ~ ticket.id if ticket else '/tickets/' }}" class="form-page">{{ csrf_field() }}
<input type="hidden" name="back" value="{{ back }}">
<section class="card form-section" aria-labelledby="tk-what">
<h2 id="tk-what" class="section-title">The problem</h2>
<div class="field">
<label class="field-label" for="tk-title">Title</label>
<input class="input" id="tk-title" type="text" name="title" value="{{ values.title }}" maxlength="200"
required autofocus placeholder="e.g. Backups to the NAS time out at night">
</div>
<div class="field">
<label class="field-label" for="tk-area">Area</label>
<select class="input" id="tk-area" name="area" required>
<option value="">— pick one —</option>
{% for key, label in areas %}<option value="{{ key }}"{% if values.area == key %} selected{% endif %}>{{ label }}</option>{% endfor %}
</select>
<span class="field-hint">What part of the infrastructure it is about.</span>
</div>
<div class="field">
<label class="field-label" for="tk-subject">What it is about <span class="tone-dim">(optional)</span></label>
<input class="input" id="tk-subject" type="text" name="subject" value="{{ values.subject }}" maxlength="255"
placeholder="e.g. web-1/nginx, a monitor's name; empty for the hosts as a whole">
<span class="field-hint">An alert or anomaly on this resource reads as a known issue while the ticket is open.</span>
</div>
<div class="field">
<label class="field-label" for="tk-body">Description <span class="tone-dim">(optional)</span></label>
<textarea class="input" id="tk-body" name="body" rows="5" maxlength="8000"
placeholder="What is seen, since when, and what has been tried.">{{ values.body }}</textarea>
</div>
</section>
<section class="card form-section" aria-labelledby="tk-hosts">
<h2 id="tk-hosts" class="section-title">Hosts</h2>
<p class="field-hint">The ticket shows on each ticked host's page.</p>
{% if hosts %}
<div class="check-list">
{% for h in hosts %}
<label class="field-check">
<input type="checkbox" name="hosts" value="{{ h }}"{% if h in values.hosts %} checked{% endif %}>
<span>{{ h }}</span>
</label>
{% endfor %}
</div>
{% else %}
<p class="tone-muted">No hosts yet.</p>
{% endif %}
</section>
{% if links %}
<section class="card form-section" aria-labelledby="tk-links">
<h2 id="tk-links" class="section-title">Linked</h2>
<div class="check-list">
{% for l in links %}
<label class="field-check">
<input type="checkbox" name="link" value="{{ l.type }}:{{ l.id }}" checked>
<span>{{ l.label }}</span>
</label>
{% endfor %}
</div>
{% if ticket %}<p class="field-hint">Untick one to unlink it.</p>{% endif %}
</section>
{% endif %}
<div class="form-actions">
<button type="submit" class="btn">{{ "Save" if ticket else "Open the ticket" }}</button>
<a href="{{ back }}" class="btn btn-ghost">Cancel</a>
</div>
</form>
{% endblock %}
+62
View File
@@ -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') %}<a class="btn btn-sm" href="/tickets/new{% if host %}?host={{ host|urlencode }}{% endif %}">Open a ticket</a>{% endif %}
{% endcall %}
<p class="page-verdict">
{%- if open_count %}<strong class="tone-warn">{{ open_count }} open</strong>{% else %}<strong class="tone-ok">No open tickets</strong>{% endif %}
{%- if host %} · on <a href="/timeline/?host={{ host|urlencode }}">{{ host }}</a> · <a href="/tickets/?status={{ status }}">every host</a>{% endif %}
</p>
<nav class="filter-bar" aria-label="Which tickets">
{% for key, label in (("open", "Open"), ("resolved", "Resolved"), ("all", "All")) %}
<a class="btn btn-sm {{ 'btn-ghost' if status != key }}"{% if status == key %} aria-current="page"{% endif %}
href="/tickets/?status={{ key }}{% if host %}&amp;host={{ host|urlencode }}{% endif %}">{{ label }}</a>
{% endfor %}
</nav>
{% if tickets %}
<div class="card-flush">
<table class="table">
<thead>
<tr>
<th>Ticket</th>
<th>Where</th>
<th>Area</th>
<th>Opened ({{ tz_label() }})</th>
<th>State</th>
</tr>
</thead>
<tbody>
{% for t in tickets %}
<tr>
<td>
<a href="/tickets/{{ t.id }}"><span class="tone-dim">#{{ t.id }}</span> {{ t.title }}</a>
{%- if counts.get(t.id) %}<div class="field-hint">{{ counts[t.id] }} comment{{ "s" if counts[t.id] != 1 }}</div>{% endif %}
</td>
<td>
{%- for h in t.hosts %}<a href="/tickets/?host={{ h|urlencode }}&amp;status={{ status }}">{{ h }}</a>{{ ", " if not loop.last }}{% endfor -%}
{%- if t.subject %}{% if t.hosts %}<br>{% endif %}<span class="field-hint">{{ t.subject }}</span>{% endif -%}
{%- if not t.hosts and not t.subject %}<span class="tone-dim">—</span>{% endif -%}
</td>
<td class="nowrap"><span class="chip">{{ labels.get(t.area, t.area) }}</span></td>
<td class="nowrap">{{ t.opened_at|when }}<div class="field-hint">{{ t.opened_by }}</div></td>
<td>
{{ status_badge(t.status) }}
{%- if t.status == 'resolved' %}<div class="field-hint">{{ t.resolved_by }}, {{ t.resolved_at|when }}</div>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="card">{{ empty_state({"open": "No open tickets.", "resolved": "No ticket has been resolved yet.", "all": "No tickets yet."}[status]) }}</div>
{% endif %}
{% endblock %}
+78
View File
@@ -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 %}<a class="btn btn-ghost btn-sm" href="/tickets/{{ t.id }}/edit">Edit</a>{% endif %}
{% endcall %}
<p class="page-verdict">
{{ status_badge(t.status) }}
<span class="chip">{{ area_label }}</span>
{%- for h in t.hosts %}{% if loop.first %} · {% endif %}<a href="/timeline/?host={{ h|urlencode }}">{{ h }}</a>{{ ", " 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 %}
</p>
{% if error %}<div class="alert alert-error" role="alert">{{ error }}</div>{% endif %}
{% if t.status == 'resolved' %}
<section class="card" aria-labelledby="tk-resolution">
<h2 id="tk-resolution" class="section-title">Resolution</h2>
<p style="white-space:pre-line;margin:0;">{{ t.resolution }}</p>
</section>
{% endif %}
<section class="card" aria-labelledby="tk-about">
<h2 id="tk-about" class="section-title">What is wrong</h2>
{% if t.body %}<p style="white-space:pre-line;margin:0;">{{ t.body }}</p>
{% else %}<p class="tone-muted" style="margin:0;">No description.</p>{% endif %}
{% if links %}
<h3 class="section-title" style="margin-top:1rem;">Linked</h3>
<ul class="check-list">
{% for l in links %}<li>{% if l.url %}<a href="{{ l.url }}">{{ l.label }}</a>{% else %}<span class="tone-dim">{{ l.label }}</span>{% endif %}</li>{% endfor %}
</ul>
{% endif %}
</section>
<section class="card" aria-labelledby="tk-log">
<h2 id="tk-log" class="section-title">Log ({{ tz_label() }})</h2>
{% set said = {"opened": "opened it", "comment": "commented", "edited": "edited", "resolved": "resolved it", "reopened": "reopened it"} %}
<table class="table">
<tbody>
{% for n in log %}
<tr>
<td class="nowrap" style="width:1%;">{{ n.at|when }}</td>
<td>
<strong>{{ n.actor }}</strong> {{ said.get(n.kind, n.kind) }}{% if n.kind == "edited" and n.body %}: {{ n.body }}{% endif %}
{%- if n.body and n.kind != "edited" %}<div style="white-space:pre-line;">{{ n.body }}</div>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if can_edit %}
<form method="post" action="/tickets/{{ t.id }}/comment" style="margin-top:1rem;">{{ csrf_field() }}
<div class="field">
<label class="field-label" for="tk-text">{{ "Comment, or what resolved it" if t.status == 'open' else "Comment, or why it is back" }}</label>
<textarea class="input" id="tk-text" name="text" rows="3" maxlength="8000">{{ text or "" }}</textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-ghost">Comment</button>
{% if t.status == 'open' %}
<button type="submit" class="btn" formaction="/tickets/{{ t.id }}/resolve">Resolve</button>
{% else %}
<button type="submit" class="btn" formaction="/tickets/{{ t.id }}/reopen">Reopen</button>
{% endif %}
</div>
</form>
{% endif %}
</section>
{% endblock %}
+1
View File
@@ -0,0 +1 @@
"""Tickets: known problems, open until someone resolves them."""
+213
View File
@@ -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/<int:number>")
@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/<int:number>/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/<int:number>")
@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/<int:number>/comment")
@require_role(UserRole.operator)
async def comment_ticket(number: int):
return await _act(number, "ticket.commented", tk.comment)
@tickets_bp.post("/tickets/<int:number>/resolve")
@require_role(UserRole.operator)
async def resolve_ticket(number: int):
return await _act(number, "ticket.resolved", tk.resolve)
@tickets_bp.post("/tickets/<int:number>/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/")
+316
View File
@@ -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 "<host>/<name>", 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
+10
View File
@@ -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"),
+121
View File
@@ -0,0 +1,121 @@
"""Tickets (#6351, #6352): what a ticket must say before it is opened, how
its steps reach the timeline and the audit log, and the pages that show it.
Opening, linking, resolving and "known issue" run against Postgres in
tests/integration/test_tickets.py."""
from datetime import datetime, timezone
from types import SimpleNamespace as NS
import pytest
from quart import render_template
from steward.core.formatting import status_info
from steward.mcp.tools.tickets import _hosts, _links
from steward.tickets import service as tk
from steward.timeline import catalog
from steward.timeline.config import Config
NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
AREAS = [list(a) for a in catalog.DEFAULT_AREAS]
def _ticket(**kw):
base = dict(id=12, title="Backups time out at night", body="Since the 20th.", area="storage",
hosts=["nas-1"], subject=None, status="open", opened_at=NOW, opened_by="bryan",
resolved_at=None, resolved_by=None, resolution=None, updated_at=NOW)
return NS(**{**base, **kw})
async def test_a_ticket_is_checked_before_it_is_opened():
cfg = Config(areas=AREAS)
f = await tk.fields(None, cfg, title=" Backups time out ", area="storage", body=" ", subject=" nas-1/restic ")
assert f == {"title": "Backups time out", "area": "storage", "hosts": [], "body": None,
"subject": "nas-1/restic"}
for kw, said in ((dict(title=" ", area="storage"), "Give the ticket a title"),
(dict(title="x" * 201, area="storage"), "200 characters"),
(dict(title="x", area="plumbing"), "Pick an area: Service, Host OS"),
(dict(title="x", area="storage", body="b" * 8001), "8000 characters")):
with pytest.raises(ValueError, match=said):
await tk.fields(None, cfg, **kw)
async def test_a_ticket_links_only_to_an_alert_rule_or_an_anomaly():
with pytest.raises(ValueError, match="not 'monitor'"):
await tk.check_links(None, [("monitor", "m1")])
def test_opening_resolving_and_reopening_go_on_the_timeline_and_the_audit_rows_stay_off():
for type_ in ("ticket.opened", "ticket.resolved", "ticket.reopened"):
t = catalog.TYPES[type_]
assert (t.kind, t.shown) == ("ticket", True), type_
for action in ("ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"):
assert catalog.audit_type(action) is None, action
def test_a_ticket_s_states_read_as_words():
assert status_info("open") == ("warn", "Open")
assert status_info("resolved") == ("ok", "Resolved")
assert status_info("known_issue") == ("info", "Known issue")
def test_the_mcp_tools_take_hosts_and_ids_as_a_list_or_a_comma_list():
assert _hosts("web-1, ,db-1 ") == ["web-1", "db-1"]
assert _hosts(None) == [] and _hosts(["web-1", ""]) == ["web-1"]
assert _links("r1, r2", ["a1"]) == [("alert_rule", "r1"), ("alert_rule", "r2"), ("anomaly", "a1")]
assert _links(None, None) == []
# ── Pages ───────────────────────────────────────────────────────────────────
async def test_the_list_shows_where_each_ticket_is_and_who_opened_it(app):
tickets = [_ticket(), _ticket(id=9, title="Fan noisy", area="hardware", hosts=[], subject="rack fan",
status="resolved", resolved_by="sam", resolved_at=NOW)]
async with app.test_request_context("/tickets/"):
from quart import session
session["user_role"] = "viewer"
page = await render_template("tickets/list.html", tickets=tickets, counts={12: 2}, status="all",
host="", open_count=1, labels=dict(AREAS))
assert '<a href="/tickets/12"><span class="tone-dim">#12</span> Backups time out at night</a>' in page
assert "2 comments" in page and ">Storage<" in page and "rack fan" in page
assert '<a href="/tickets/?host=nas-1&amp;status=all">nas-1</a>' in page
assert "1 open" in page and "sam, " in page
assert "Open a ticket" not in page # a viewer
async def test_an_operator_comments_and_resolves_and_a_viewer_only_reads(app):
log = [NS(at=NOW, kind="opened", actor="bryan", body=None),
NS(at=NOW, kind="edited", actor="bryan", body="title, linked alert rule"),
NS(at=NOW, kind="comment", actor="sam", body="restic prune runs long")]
links = [{"type": "alert_rule", "id": "r1", "label": "Alert rule Backup late", "url": "/alerts/history?rule=r1",
"gone": False},
{"type": "anomaly", "id": "a1", "label": "An anomaly no longer kept", "url": None, "gone": True}]
async with app.test_request_context("/tickets/12"):
from quart import session
session["user_role"] = "operator"
mine = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
resolved = await render_template("tickets/ticket.html", t=_ticket(status="resolved", resolved_by="sam",
resolved_at=NOW, resolution="Moved prune to Sunday"),
log=log, links=[], area_label="Storage")
session["user_role"] = "viewer"
viewer = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
assert '<a href="/alerts/history?rule=r1">Alert rule Backup late</a>' in mine
assert '<span class="tone-dim">An anomaly no longer kept</span>' in mine
assert "edited: title, linked alert rule" in mine and "restic prune runs long" in mine
assert 'action="/tickets/12/comment"' in mine and 'formaction="/tickets/12/resolve"' in mine
assert 'formaction="/tickets/12/reopen"' in resolved and "Moved prune to Sunday" in resolved
assert "/tickets/12/comment" not in viewer and "/tickets/12/edit" not in viewer
async def test_a_page_s_card_lists_its_open_tickets_and_offers_to_open_one(app):
async with app.test_request_context("/tickets/card"):
from quart import session
session["user_role"] = "operator"
card = await render_template("tickets/_card.html", tickets=[_ticket()], host="nas-1", subject="",
back="/hosts/h1")
empty = await render_template("tickets/_card.html", tickets=[], host="", subject="nas-1/restic",
back="/hosts/h1")
session["user_role"] = "viewer"
nothing = await render_template("tickets/_card.html", tickets=[], host="nas-1", subject="", back="/")
assert "Known issues (1 open)" in card and '<a href="/tickets/12">' in card
assert 'href="/tickets/new?host=nas-1&amp;back=%2Fhosts%2Fh1"' in card
assert "No open tickets." in empty and "subject=nas-1%2Frestic" in empty
assert "<section" not in nothing # a viewer, nothing open
+257
View File
@@ -0,0 +1,257 @@
"""Integration: tickets (#6351, #6352), from the pages and through MCP. A
ticket opened from a firing alert links its rule and makes the alert a known
issue until it is resolved; opening, resolving and reopening go on the
timeline, the rest in the ticket's own log; every step is audited. Also the
recorded-change tools update_change and delete_change: only the author or an
admin. Requires STEWARD_DATABASE_URL.
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from datetime import datetime, timedelta, timezone
import pytest
pytestmark = pytest.mark.integration
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _user(app, name: str, role: str):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time())
return client, uid
async def _firing(app, tag: str, created_by: str):
"""A host with a disk rule firing on its root mount, and an anomaly on
its CPU: what a ticket is opened on."""
from steward.models.alerts import AlertOperator, AlertRule, AlertState, AlertStateEnum
from steward.models.hosts import Host
from steward.models.norms import MetricAnomaly
name = f"tk-{tag}"
now = datetime.now(timezone.utc)
async with app.db_sessionmaker() as s:
async with s.begin():
host = Host(name=name, address="10.7.7.7")
rule = AlertRule(id=str(uuid.uuid4()), name=f"Disk full {tag}", source_module="host_agent",
resource_name=f"{name}:/", metric_name="disk_used_pct",
operator=AlertOperator.gt, threshold=90.0, consecutive_failures_required=1,
enabled=True, created_by=created_by)
anomaly = MetricAnomaly(source_module="host_agent", resource_name=name, metric_name="cpu_pct",
started_at=now - timedelta(hours=2), last_started_at=now - timedelta(hours=2),
ended_at=now - timedelta(hours=1), occurrences=1, peak_level=95.0,
peak_at=now - timedelta(hours=2), usual_hold=20.0, usual_peak=35.0)
s.add_all([host, rule, anomaly])
async with s.begin():
s.add(AlertState(rule_id=rule.id, state=AlertStateEnum.firing,
last_transition_at=now - timedelta(minutes=5)))
return host, rule, anomaly
async def _ticket_on(app, host: str):
from sqlalchemy import select
from steward.models.tickets import Ticket
async with app.db_sessionmaker() as s:
return (await s.execute(select(Ticket).where(Ticket.hosts.contains([host])))).scalar_one()
async def _timeline(app, host: str) -> list[tuple[str, str | None]]:
from sqlalchemy import select
from steward.models.timeline import TimelineEntry
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host]),
TimelineEntry.type.like("ticket.%"))
.order_by(TimelineEntry.at))).scalars().all()
return [(e.type, (e.detail or {}).get("note")) for e in rows]
async def _audited(app, entity_id: str) -> list[str]:
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
return list((await s.execute(select(AuditEvent.action).where(AuditEvent.entity_type == "ticket",
AuditEvent.entity_id == entity_id)
.order_by(AuditEvent.timestamp))).scalars())
async def _known(app, rule_id: str) -> list[int]:
from steward.alerts import service as alert_svc
async with app.db_sessionmaker() as s:
rows = await alert_svc.active_rows(s, datetime.now(timezone.utc))
return [t.id for r in rows if r["rule"].id == rule_id for t in r["tickets"]]
def test_a_ticket_opened_from_an_alert_is_worked_and_resolved_on_its_page(app):
async def _go():
tag = uuid.uuid4().hex[:8]
op, op_id = await _user(app, f"tk-op-{tag}", "operator")
viewer, _ = await _user(app, f"tk-v-{tag}", "viewer")
host, rule, _ = await _firing(app, tag, op_id)
# The form opens from the alert, filled in from its rule and linked to it.
page = await (await op.get(f"/tickets/new?rule={rule.id}&back=/alerts/")).get_data(as_text=True)
assert f'value="Disk full {tag} on {host.name}:/"' in page
assert f'value="alert_rule:{rule.id}" checked' in page and f'value="{host.name}" checked' in page
assert (await viewer.get("/tickets/new")).status_code == 403
form = {"csrf_token": "test-token", "title": "Root disk filling with old logs", "area": "storage",
"body": "journald keeps 4 GB.", "hosts": host.name, "subject": f"{host.name}:/",
"link": f"alert_rule:{rule.id}", "back": "/alerts/"}
bad = await op.post("/tickets/", form={**form, "link": "alert_rule:no-such-rule"})
assert bad.status_code == 400 and "No alert rule has the id no-such-rule" in await bad.get_data(as_text=True)
res = await op.post("/tickets/", form=form)
t = await _ticket_on(app, host.name)
assert res.status_code == 302 and res.headers["Location"] == f"/tickets/{t.id}"
assert (t.status, t.area, t.subject, t.opened_by) == ("open", "storage", f"{host.name}:/", f"tk-op-{tag}")
assert await _known(app, rule.id) == [t.id] # the alert is a known issue
alerts = await (await op.get("/alerts/")).get_data(as_text=True)
assert f'href="/tickets/{t.id}">#{t.id} Root disk filling' in alerts
card = await (await viewer.get(f"/tickets/card?host={host.name}&back=/hosts/{host.id}"))\
.get_data(as_text=True)
assert "Known issues (1 open)" in card and "Root disk filling with old logs" in card
assert (await viewer.post(f"/tickets/{t.id}/comment",
form={"csrf_token": "test-token", "text": "x"})).status_code == 403
await op.post(f"/tickets/{t.id}/comment", form={"csrf_token": "test-token", "text": "Vacuumed to 500 MB."})
refused = await op.post(f"/tickets/{t.id}/resolve", form={"csrf_token": "test-token", "text": " "})
assert refused.status_code == 400 and "Say what resolved it" in await refused.get_data(as_text=True)
await op.post(f"/tickets/{t.id}/resolve",
form={"csrf_token": "test-token", "text": "Capped journald at 500 MB."})
t = await _ticket_on(app, host.name)
assert (t.status, t.resolution, t.resolved_by) == ("resolved", "Capped journald at 500 MB.", f"tk-op-{tag}")
assert await _known(app, rule.id) == [] # resolved: no longer known
await op.post(f"/tickets/{t.id}/reopen", form={"csrf_token": "test-token", "text": "Full again."})
# An edit with the link's box unticked unlinks it.
await op.post(f"/tickets/{t.id}", form={k: v for k, v in form.items() if k != "link"})
assert await _known(app, rule.id) == [t.id] # still: it names the mount
shown = await (await viewer.get(f"/tickets/{t.id}")).get_data(as_text=True)
assert "Vacuumed to 500 MB." in shown and "unlinked alert rule" in shown and "Full again." in shown
assert await _timeline(app, host.name) == [
("ticket.opened", None), ("ticket.resolved", "Capped journald at 500 MB."),
("ticket.reopened", "Full again.")]
assert await _audited(app, str(t.id)) == [
"ticket.opened", "ticket.commented", "ticket.resolved", "ticket.reopened", "ticket.edited"]
asyncio.run(_go())
def test_tickets_are_worked_through_mcp_and_show_up_where_an_agent_looks(app):
from steward.core import anomalies as anom
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools import alerts, hosts, overview, tickets
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
_, uid = await _user(app, f"tm-{tag}", "operator")
host, rule, anomaly = await _firing(app, tag, uid)
_context._app_ctx.set(app)
_context._caller_ctx.set(Caller(user_id=uid, username=f"tm-{tag}", role=UserRole.operator, token_id=7))
with pytest.raises(ValueError, match="list_hosts"):
await tickets.open_ticket("x", "storage", hosts=["nope"])
out = await tickets.open_ticket("Root disk filling", "Storage", description="journald",
hosts=host.name, subject=f"{host.name}:/", alert_rules=rule.id)
n = out["ticket"]
assert (out["status"], out["opened_by"], out["url"]) == ("open", f"tm-{tag} via MCP", f"/tickets/{n}")
assert [(lk["type"], lk["id"]) for lk in out["links"]] == [("alert_rule", rule.id)]
assert [e["kind"] for e in out["log"]] == ["opened"]
listed = await alerts.list_alerts()
[mine] = [a for a in listed["alerts"] if a["rule_id"] == rule.id]
assert mine["known_issue"] == [{"ticket": n, "title": "Root disk filling"}]
assert n in [t["ticket"] for t in (await tickets.list_tickets(host=host.name))["tickets"]]
assert [t["ticket"] for t in (await hosts.get_host(host.name))["open_tickets"]] == [n]
ov = await overview.get_overview()
assert ov["counts"]["tickets_open"] >= 1 and "open_tickets" in ov
# A link to the anomaly too; the host as a whole is not its subject, so
# the CPU anomaly is known only through the link.
async with app.db_sessionmaker() as s:
assert (await anom.views(s, [anomaly]))[0].tickets == []
changed = await tickets.update_ticket(f"#{n}", link_anomalies=[anomaly.id], title="Root disk filling up")
assert changed["changed"] == ["title", "linked anomaly"]
async with app.db_sessionmaker() as s:
assert [t.id for t in (await anom.views(s, [anomaly]))[0].tickets] == [n]
await tickets.comment_ticket(n, "Vacuumed the journal.")
done = await tickets.resolve_ticket(n, "Capped journald.")
assert (done["status"], done["resolution"]) == ("resolved", "Capped journald.")
with pytest.raises(ValueError, match="already resolved"):
await tickets.resolve_ticket(n, "again")
[mine] = [a for a in (await alerts.list_alerts())["alerts"] if a["rule_id"] == rule.id]
assert mine["known_issue"] == []
back = await tickets.reopen_ticket(n, "Full again.")
assert back["status"] == "open" and [e["kind"] for e in back["log"]] == [
"opened", "edited", "comment", "resolved", "reopened"]
with pytest.raises(ValueError, match="No ticket #999999"):
await tickets.get_ticket(999999)
assert await _audited(app, str(n)) == [
"ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"]
asyncio.run(_go())
def test_a_recorded_change_is_corrected_or_taken_back_by_its_author_or_an_admin(app):
from steward.core.api_tokens import Caller
from steward.mcp import _context
from steward.mcp.tools.timeline import delete_change, log_change, update_change
from steward.models.users import UserRole
async def _go():
tag = uuid.uuid4().hex[:8]
_, author = await _user(app, f"uc-a-{tag}", "operator")
_, other = await _user(app, f"uc-o-{tag}", "operator")
_, admin = await _user(app, f"uc-ad-{tag}", "admin")
_context._app_ctx.set(app)
def as_(uid, name, role):
_context._caller_ctx.set(Caller(user_id=uid, username=name, role=role, token_id=7))
as_(author, f"uc-a-{tag}", UserRole.operator)
entry = await log_change(f"Swapped the PSU {tag}", "hardware", subject="psu", detail="old one whined")
as_(other, f"uc-o-{tag}", UserRole.operator)
with pytest.raises(ValueError, match="only they or an admin"):
await update_change(entry["entry_id"], summary="mine now")
with pytest.raises(ValueError, match="only they or an admin"):
await delete_change(entry["entry_id"])
as_(author, f"uc-a-{tag}", UserRole.operator)
fixed = await update_change(entry["entry_id"], summary=f"Swapped the PSU for a 650 W {tag}", subject="")
assert (fixed["summary"], fixed["subject"], fixed["note"], fixed["area"]) == (
f"Swapped the PSU for a 650 W {tag}", None, "old one whined", "hardware")
with pytest.raises(ValueError, match="No recorded change"):
await update_change("no-such-entry", summary="x")
as_(admin, f"uc-ad-{tag}", UserRole.admin)
assert (await delete_change(entry["entry_id"]))["deleted"] == entry["entry_id"]
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
actions = list((await s.execute(select(AuditEvent.action).where(
AuditEvent.entity_id == entry["entry_id"]).order_by(AuditEvent.timestamp))).scalars())
assert actions == ["timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"]
asyncio.run(_go())
+11 -2
View File
@@ -154,9 +154,18 @@ def test_a_quick_restart_is_shown_though_it_never_broke_the_minute():
assert _events.gaps_in(ticks, NOW + timedelta(minutes=2), NOW + timedelta(hours=1), [(restart, 35.0)]) == []
def test_the_timeline_tools_are_registered_with_their_roles(app):
def test_the_timeline_and_ticket_tools_are_registered_with_their_roles(app):
mcp = app.mcp_server
for name, role, read_only in (("get_timeline", UserRole.viewer, True),
("log_change", UserRole.operator, False)):
("log_change", UserRole.operator, False),
("update_change", UserRole.operator, False),
("delete_change", UserRole.operator, False),
("list_tickets", UserRole.viewer, True),
("get_ticket", UserRole.viewer, True),
("open_ticket", UserRole.operator, False),
("update_ticket", UserRole.operator, False),
("comment_ticket", UserRole.operator, False),
("resolve_ticket", UserRole.operator, False),
("reopen_ticket", UserRole.operator, False)):
assert mcp.tool_roles[name] is role, name
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
+1
View File
@@ -18,6 +18,7 @@ TOP_LEVEL = {
"steward/templates/ansible/index.html",
"steward/templates/audit/list.html",
"steward/templates/timeline/list.html",
"steward/templates/tickets/list.html",
"steward/templates/ai_access/index.html",
"steward/templates/account/index.html",
"plugins/docker/templates/docker/index.html",