feat(tickets): open, comment and resolve tickets from the UI and MCP; known issues on alerts and anomalies (#6351, #6352)
A ticket records a known problem: title, area, hosts, subject, description, links to alert rules and anomalies, and a log of comments, edits, resolving and reopening. Opening, resolving (with the resolution as the note) and reopening go on the timeline; every step is audited, and the audit rows stay off the timeline. An alert or anomaly an open ticket covers (by link, by subject, or a host-wide ticket on the host itself) shows a Known issue badge, and offers "Open a ticket" prefilled from its rule or anomaly when none does. Hosts, monitors and containers get a Known issues card. MCP: list_tickets, get_ticket (viewer); open_ticket, update_ticket, comment_ticket, resolve_ticket, reopen_ticket (operator). list_alerts and list_anomalies carry known_issue; get_overview, get_host, get_monitor and get_container carry open_tickets. Also update_change and delete_change for changes recorded by hand (author or admin). Tickets are left out of backups, like the timeline (2026-10-15 ruling). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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`).
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}&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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"),)
|
||||
@@ -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 %}&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;"
|
||||
|
||||
@@ -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 }}&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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -202,6 +202,9 @@
|
||||
{% endif %}
|
||||
</div>{# Ansible card #}
|
||||
|
||||
{# ── Known issues: this host's open tickets ──────────────────────────────── #}
|
||||
<div hx-get="/tickets/card?host={{ host.name|urlencode }}&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>
|
||||
|
||||
@@ -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 }}&back={{ ('/monitors/' ~ monitor.id)|urlencode }}" hx-trigger="load" hx-swap="outerHTML"></div>
|
||||
|
||||
{% set timed = history | selectattr("ms", "ne", none) | list %}
|
||||
{% if timed|length > 1 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}&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 }}&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 %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1 @@
|
||||
"""Tickets: known problems, open until someone resolves them."""
|
||||
@@ -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/")
|
||||
@@ -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
|
||||
@@ -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"),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Tickets (#6351, #6352): what a ticket must say before it is opened, how
|
||||
its steps reach the timeline and the audit log, and the pages that show it.
|
||||
Opening, linking, resolving and "known issue" run against Postgres in
|
||||
tests/integration/test_tickets.py."""
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace as NS
|
||||
|
||||
import pytest
|
||||
from quart import render_template
|
||||
|
||||
from steward.core.formatting import status_info
|
||||
from steward.mcp.tools.tickets import _hosts, _links
|
||||
from steward.tickets import service as tk
|
||||
from steward.timeline import catalog
|
||||
from steward.timeline.config import Config
|
||||
|
||||
NOW = datetime(2026, 10, 24, 12, 0, tzinfo=timezone.utc)
|
||||
AREAS = [list(a) for a in catalog.DEFAULT_AREAS]
|
||||
|
||||
|
||||
def _ticket(**kw):
|
||||
base = dict(id=12, title="Backups time out at night", body="Since the 20th.", area="storage",
|
||||
hosts=["nas-1"], subject=None, status="open", opened_at=NOW, opened_by="bryan",
|
||||
resolved_at=None, resolved_by=None, resolution=None, updated_at=NOW)
|
||||
return NS(**{**base, **kw})
|
||||
|
||||
|
||||
async def test_a_ticket_is_checked_before_it_is_opened():
|
||||
cfg = Config(areas=AREAS)
|
||||
f = await tk.fields(None, cfg, title=" Backups time out ", area="storage", body=" ", subject=" nas-1/restic ")
|
||||
assert f == {"title": "Backups time out", "area": "storage", "hosts": [], "body": None,
|
||||
"subject": "nas-1/restic"}
|
||||
for kw, said in ((dict(title=" ", area="storage"), "Give the ticket a title"),
|
||||
(dict(title="x" * 201, area="storage"), "200 characters"),
|
||||
(dict(title="x", area="plumbing"), "Pick an area: Service, Host OS"),
|
||||
(dict(title="x", area="storage", body="b" * 8001), "8000 characters")):
|
||||
with pytest.raises(ValueError, match=said):
|
||||
await tk.fields(None, cfg, **kw)
|
||||
|
||||
|
||||
async def test_a_ticket_links_only_to_an_alert_rule_or_an_anomaly():
|
||||
with pytest.raises(ValueError, match="not 'monitor'"):
|
||||
await tk.check_links(None, [("monitor", "m1")])
|
||||
|
||||
|
||||
def test_opening_resolving_and_reopening_go_on_the_timeline_and_the_audit_rows_stay_off():
|
||||
for type_ in ("ticket.opened", "ticket.resolved", "ticket.reopened"):
|
||||
t = catalog.TYPES[type_]
|
||||
assert (t.kind, t.shown) == ("ticket", True), type_
|
||||
for action in ("ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"):
|
||||
assert catalog.audit_type(action) is None, action
|
||||
|
||||
|
||||
def test_a_ticket_s_states_read_as_words():
|
||||
assert status_info("open") == ("warn", "Open")
|
||||
assert status_info("resolved") == ("ok", "Resolved")
|
||||
assert status_info("known_issue") == ("info", "Known issue")
|
||||
|
||||
|
||||
def test_the_mcp_tools_take_hosts_and_ids_as_a_list_or_a_comma_list():
|
||||
assert _hosts("web-1, ,db-1 ") == ["web-1", "db-1"]
|
||||
assert _hosts(None) == [] and _hosts(["web-1", ""]) == ["web-1"]
|
||||
assert _links("r1, r2", ["a1"]) == [("alert_rule", "r1"), ("alert_rule", "r2"), ("anomaly", "a1")]
|
||||
assert _links(None, None) == []
|
||||
|
||||
|
||||
# ── Pages ───────────────────────────────────────────────────────────────────
|
||||
|
||||
async def test_the_list_shows_where_each_ticket_is_and_who_opened_it(app):
|
||||
tickets = [_ticket(), _ticket(id=9, title="Fan noisy", area="hardware", hosts=[], subject="rack fan",
|
||||
status="resolved", resolved_by="sam", resolved_at=NOW)]
|
||||
async with app.test_request_context("/tickets/"):
|
||||
from quart import session
|
||||
session["user_role"] = "viewer"
|
||||
page = await render_template("tickets/list.html", tickets=tickets, counts={12: 2}, status="all",
|
||||
host="", open_count=1, labels=dict(AREAS))
|
||||
assert '<a href="/tickets/12"><span class="tone-dim">#12</span> Backups time out at night</a>' in page
|
||||
assert "2 comments" in page and ">Storage<" in page and "rack fan" in page
|
||||
assert '<a href="/tickets/?host=nas-1&status=all">nas-1</a>' in page
|
||||
assert "1 open" in page and "sam, " in page
|
||||
assert "Open a ticket" not in page # a viewer
|
||||
|
||||
|
||||
async def test_an_operator_comments_and_resolves_and_a_viewer_only_reads(app):
|
||||
log = [NS(at=NOW, kind="opened", actor="bryan", body=None),
|
||||
NS(at=NOW, kind="edited", actor="bryan", body="title, linked alert rule"),
|
||||
NS(at=NOW, kind="comment", actor="sam", body="restic prune runs long")]
|
||||
links = [{"type": "alert_rule", "id": "r1", "label": "Alert rule Backup late", "url": "/alerts/history?rule=r1",
|
||||
"gone": False},
|
||||
{"type": "anomaly", "id": "a1", "label": "An anomaly no longer kept", "url": None, "gone": True}]
|
||||
async with app.test_request_context("/tickets/12"):
|
||||
from quart import session
|
||||
session["user_role"] = "operator"
|
||||
mine = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
|
||||
resolved = await render_template("tickets/ticket.html", t=_ticket(status="resolved", resolved_by="sam",
|
||||
resolved_at=NOW, resolution="Moved prune to Sunday"),
|
||||
log=log, links=[], area_label="Storage")
|
||||
session["user_role"] = "viewer"
|
||||
viewer = await render_template("tickets/ticket.html", t=_ticket(), log=log, links=links, area_label="Storage")
|
||||
assert '<a href="/alerts/history?rule=r1">Alert rule Backup late</a>' in mine
|
||||
assert '<span class="tone-dim">An anomaly no longer kept</span>' in mine
|
||||
assert "edited: title, linked alert rule" in mine and "restic prune runs long" in mine
|
||||
assert 'action="/tickets/12/comment"' in mine and 'formaction="/tickets/12/resolve"' in mine
|
||||
assert 'formaction="/tickets/12/reopen"' in resolved and "Moved prune to Sunday" in resolved
|
||||
assert "/tickets/12/comment" not in viewer and "/tickets/12/edit" not in viewer
|
||||
|
||||
|
||||
async def test_a_page_s_card_lists_its_open_tickets_and_offers_to_open_one(app):
|
||||
async with app.test_request_context("/tickets/card"):
|
||||
from quart import session
|
||||
session["user_role"] = "operator"
|
||||
card = await render_template("tickets/_card.html", tickets=[_ticket()], host="nas-1", subject="",
|
||||
back="/hosts/h1")
|
||||
empty = await render_template("tickets/_card.html", tickets=[], host="", subject="nas-1/restic",
|
||||
back="/hosts/h1")
|
||||
session["user_role"] = "viewer"
|
||||
nothing = await render_template("tickets/_card.html", tickets=[], host="nas-1", subject="", back="/")
|
||||
assert "Known issues (1 open)" in card and '<a href="/tickets/12">' in card
|
||||
assert 'href="/tickets/new?host=nas-1&back=%2Fhosts%2Fh1"' in card
|
||||
assert "No open tickets." in empty and "subject=nas-1%2Frestic" in empty
|
||||
assert "<section" not in nothing # a viewer, nothing open
|
||||
@@ -0,0 +1,257 @@
|
||||
"""Integration: tickets (#6351, #6352), from the pages and through MCP. A
|
||||
ticket opened from a firing alert links its rule and makes the alert a known
|
||||
issue until it is resolved; opening, resolving and reopening go on the
|
||||
timeline, the rest in the ticket's own log; every step is audited. Also the
|
||||
recorded-change tools update_change and delete_change: only the author or an
|
||||
admin. Requires STEWARD_DATABASE_URL.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
if not os.environ.get("STEWARD_DATABASE_URL"):
|
||||
pytest.skip("needs Postgres")
|
||||
from steward.app import create_app
|
||||
return create_app(testing=False)
|
||||
|
||||
|
||||
async def _user(app, name: str, role: str):
|
||||
from steward.models.users import User, UserRole
|
||||
uid = str(uuid.uuid4())
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
|
||||
role=UserRole(role), is_active=True))
|
||||
client = app.test_client()
|
||||
async with client.session_transaction() as sess:
|
||||
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token", login_at=time.time())
|
||||
return client, uid
|
||||
|
||||
|
||||
async def _firing(app, tag: str, created_by: str):
|
||||
"""A host with a disk rule firing on its root mount, and an anomaly on
|
||||
its CPU: what a ticket is opened on."""
|
||||
from steward.models.alerts import AlertOperator, AlertRule, AlertState, AlertStateEnum
|
||||
from steward.models.hosts import Host
|
||||
from steward.models.norms import MetricAnomaly
|
||||
name = f"tk-{tag}"
|
||||
now = datetime.now(timezone.utc)
|
||||
async with app.db_sessionmaker() as s:
|
||||
async with s.begin():
|
||||
host = Host(name=name, address="10.7.7.7")
|
||||
rule = AlertRule(id=str(uuid.uuid4()), name=f"Disk full {tag}", source_module="host_agent",
|
||||
resource_name=f"{name}:/", metric_name="disk_used_pct",
|
||||
operator=AlertOperator.gt, threshold=90.0, consecutive_failures_required=1,
|
||||
enabled=True, created_by=created_by)
|
||||
anomaly = MetricAnomaly(source_module="host_agent", resource_name=name, metric_name="cpu_pct",
|
||||
started_at=now - timedelta(hours=2), last_started_at=now - timedelta(hours=2),
|
||||
ended_at=now - timedelta(hours=1), occurrences=1, peak_level=95.0,
|
||||
peak_at=now - timedelta(hours=2), usual_hold=20.0, usual_peak=35.0)
|
||||
s.add_all([host, rule, anomaly])
|
||||
async with s.begin():
|
||||
s.add(AlertState(rule_id=rule.id, state=AlertStateEnum.firing,
|
||||
last_transition_at=now - timedelta(minutes=5)))
|
||||
return host, rule, anomaly
|
||||
|
||||
|
||||
async def _ticket_on(app, host: str):
|
||||
from sqlalchemy import select
|
||||
from steward.models.tickets import Ticket
|
||||
async with app.db_sessionmaker() as s:
|
||||
return (await s.execute(select(Ticket).where(Ticket.hosts.contains([host])))).scalar_one()
|
||||
|
||||
|
||||
async def _timeline(app, host: str) -> list[tuple[str, str | None]]:
|
||||
from sqlalchemy import select
|
||||
from steward.models.timeline import TimelineEntry
|
||||
async with app.db_sessionmaker() as s:
|
||||
rows = (await s.execute(select(TimelineEntry).where(TimelineEntry.hosts.contains([host]),
|
||||
TimelineEntry.type.like("ticket.%"))
|
||||
.order_by(TimelineEntry.at))).scalars().all()
|
||||
return [(e.type, (e.detail or {}).get("note")) for e in rows]
|
||||
|
||||
|
||||
async def _audited(app, entity_id: str) -> list[str]:
|
||||
from sqlalchemy import select
|
||||
from steward.models.audit import AuditEvent
|
||||
async with app.db_sessionmaker() as s:
|
||||
return list((await s.execute(select(AuditEvent.action).where(AuditEvent.entity_type == "ticket",
|
||||
AuditEvent.entity_id == entity_id)
|
||||
.order_by(AuditEvent.timestamp))).scalars())
|
||||
|
||||
|
||||
async def _known(app, rule_id: str) -> list[int]:
|
||||
from steward.alerts import service as alert_svc
|
||||
async with app.db_sessionmaker() as s:
|
||||
rows = await alert_svc.active_rows(s, datetime.now(timezone.utc))
|
||||
return [t.id for r in rows if r["rule"].id == rule_id for t in r["tickets"]]
|
||||
|
||||
|
||||
def test_a_ticket_opened_from_an_alert_is_worked_and_resolved_on_its_page(app):
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
op, op_id = await _user(app, f"tk-op-{tag}", "operator")
|
||||
viewer, _ = await _user(app, f"tk-v-{tag}", "viewer")
|
||||
host, rule, _ = await _firing(app, tag, op_id)
|
||||
|
||||
# The form opens from the alert, filled in from its rule and linked to it.
|
||||
page = await (await op.get(f"/tickets/new?rule={rule.id}&back=/alerts/")).get_data(as_text=True)
|
||||
assert f'value="Disk full {tag} on {host.name}:/"' in page
|
||||
assert f'value="alert_rule:{rule.id}" checked' in page and f'value="{host.name}" checked' in page
|
||||
assert (await viewer.get("/tickets/new")).status_code == 403
|
||||
|
||||
form = {"csrf_token": "test-token", "title": "Root disk filling with old logs", "area": "storage",
|
||||
"body": "journald keeps 4 GB.", "hosts": host.name, "subject": f"{host.name}:/",
|
||||
"link": f"alert_rule:{rule.id}", "back": "/alerts/"}
|
||||
bad = await op.post("/tickets/", form={**form, "link": "alert_rule:no-such-rule"})
|
||||
assert bad.status_code == 400 and "No alert rule has the id no-such-rule" in await bad.get_data(as_text=True)
|
||||
|
||||
res = await op.post("/tickets/", form=form)
|
||||
t = await _ticket_on(app, host.name)
|
||||
assert res.status_code == 302 and res.headers["Location"] == f"/tickets/{t.id}"
|
||||
assert (t.status, t.area, t.subject, t.opened_by) == ("open", "storage", f"{host.name}:/", f"tk-op-{tag}")
|
||||
assert await _known(app, rule.id) == [t.id] # the alert is a known issue
|
||||
alerts = await (await op.get("/alerts/")).get_data(as_text=True)
|
||||
assert f'href="/tickets/{t.id}">#{t.id} Root disk filling' in alerts
|
||||
|
||||
card = await (await viewer.get(f"/tickets/card?host={host.name}&back=/hosts/{host.id}"))\
|
||||
.get_data(as_text=True)
|
||||
assert "Known issues (1 open)" in card and "Root disk filling with old logs" in card
|
||||
|
||||
assert (await viewer.post(f"/tickets/{t.id}/comment",
|
||||
form={"csrf_token": "test-token", "text": "x"})).status_code == 403
|
||||
await op.post(f"/tickets/{t.id}/comment", form={"csrf_token": "test-token", "text": "Vacuumed to 500 MB."})
|
||||
refused = await op.post(f"/tickets/{t.id}/resolve", form={"csrf_token": "test-token", "text": " "})
|
||||
assert refused.status_code == 400 and "Say what resolved it" in await refused.get_data(as_text=True)
|
||||
await op.post(f"/tickets/{t.id}/resolve",
|
||||
form={"csrf_token": "test-token", "text": "Capped journald at 500 MB."})
|
||||
t = await _ticket_on(app, host.name)
|
||||
assert (t.status, t.resolution, t.resolved_by) == ("resolved", "Capped journald at 500 MB.", f"tk-op-{tag}")
|
||||
assert await _known(app, rule.id) == [] # resolved: no longer known
|
||||
|
||||
await op.post(f"/tickets/{t.id}/reopen", form={"csrf_token": "test-token", "text": "Full again."})
|
||||
# An edit with the link's box unticked unlinks it.
|
||||
await op.post(f"/tickets/{t.id}", form={k: v for k, v in form.items() if k != "link"})
|
||||
assert await _known(app, rule.id) == [t.id] # still: it names the mount
|
||||
|
||||
shown = await (await viewer.get(f"/tickets/{t.id}")).get_data(as_text=True)
|
||||
assert "Vacuumed to 500 MB." in shown and "unlinked alert rule" in shown and "Full again." in shown
|
||||
assert await _timeline(app, host.name) == [
|
||||
("ticket.opened", None), ("ticket.resolved", "Capped journald at 500 MB."),
|
||||
("ticket.reopened", "Full again.")]
|
||||
assert await _audited(app, str(t.id)) == [
|
||||
"ticket.opened", "ticket.commented", "ticket.resolved", "ticket.reopened", "ticket.edited"]
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_tickets_are_worked_through_mcp_and_show_up_where_an_agent_looks(app):
|
||||
from steward.core import anomalies as anom
|
||||
from steward.core.api_tokens import Caller
|
||||
from steward.mcp import _context
|
||||
from steward.mcp.tools import alerts, hosts, overview, tickets
|
||||
from steward.models.users import UserRole
|
||||
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
_, uid = await _user(app, f"tm-{tag}", "operator")
|
||||
host, rule, anomaly = await _firing(app, tag, uid)
|
||||
_context._app_ctx.set(app)
|
||||
_context._caller_ctx.set(Caller(user_id=uid, username=f"tm-{tag}", role=UserRole.operator, token_id=7))
|
||||
|
||||
with pytest.raises(ValueError, match="list_hosts"):
|
||||
await tickets.open_ticket("x", "storage", hosts=["nope"])
|
||||
out = await tickets.open_ticket("Root disk filling", "Storage", description="journald",
|
||||
hosts=host.name, subject=f"{host.name}:/", alert_rules=rule.id)
|
||||
n = out["ticket"]
|
||||
assert (out["status"], out["opened_by"], out["url"]) == ("open", f"tm-{tag} via MCP", f"/tickets/{n}")
|
||||
assert [(lk["type"], lk["id"]) for lk in out["links"]] == [("alert_rule", rule.id)]
|
||||
assert [e["kind"] for e in out["log"]] == ["opened"]
|
||||
|
||||
listed = await alerts.list_alerts()
|
||||
[mine] = [a for a in listed["alerts"] if a["rule_id"] == rule.id]
|
||||
assert mine["known_issue"] == [{"ticket": n, "title": "Root disk filling"}]
|
||||
assert n in [t["ticket"] for t in (await tickets.list_tickets(host=host.name))["tickets"]]
|
||||
assert [t["ticket"] for t in (await hosts.get_host(host.name))["open_tickets"]] == [n]
|
||||
ov = await overview.get_overview()
|
||||
assert ov["counts"]["tickets_open"] >= 1 and "open_tickets" in ov
|
||||
|
||||
# A link to the anomaly too; the host as a whole is not its subject, so
|
||||
# the CPU anomaly is known only through the link.
|
||||
async with app.db_sessionmaker() as s:
|
||||
assert (await anom.views(s, [anomaly]))[0].tickets == []
|
||||
changed = await tickets.update_ticket(f"#{n}", link_anomalies=[anomaly.id], title="Root disk filling up")
|
||||
assert changed["changed"] == ["title", "linked anomaly"]
|
||||
async with app.db_sessionmaker() as s:
|
||||
assert [t.id for t in (await anom.views(s, [anomaly]))[0].tickets] == [n]
|
||||
|
||||
await tickets.comment_ticket(n, "Vacuumed the journal.")
|
||||
done = await tickets.resolve_ticket(n, "Capped journald.")
|
||||
assert (done["status"], done["resolution"]) == ("resolved", "Capped journald.")
|
||||
with pytest.raises(ValueError, match="already resolved"):
|
||||
await tickets.resolve_ticket(n, "again")
|
||||
[mine] = [a for a in (await alerts.list_alerts())["alerts"] if a["rule_id"] == rule.id]
|
||||
assert mine["known_issue"] == []
|
||||
back = await tickets.reopen_ticket(n, "Full again.")
|
||||
assert back["status"] == "open" and [e["kind"] for e in back["log"]] == [
|
||||
"opened", "edited", "comment", "resolved", "reopened"]
|
||||
with pytest.raises(ValueError, match="No ticket #999999"):
|
||||
await tickets.get_ticket(999999)
|
||||
assert await _audited(app, str(n)) == [
|
||||
"ticket.opened", "ticket.edited", "ticket.commented", "ticket.resolved", "ticket.reopened"]
|
||||
|
||||
asyncio.run(_go())
|
||||
|
||||
|
||||
def test_a_recorded_change_is_corrected_or_taken_back_by_its_author_or_an_admin(app):
|
||||
from steward.core.api_tokens import Caller
|
||||
from steward.mcp import _context
|
||||
from steward.mcp.tools.timeline import delete_change, log_change, update_change
|
||||
from steward.models.users import UserRole
|
||||
|
||||
async def _go():
|
||||
tag = uuid.uuid4().hex[:8]
|
||||
_, author = await _user(app, f"uc-a-{tag}", "operator")
|
||||
_, other = await _user(app, f"uc-o-{tag}", "operator")
|
||||
_, admin = await _user(app, f"uc-ad-{tag}", "admin")
|
||||
_context._app_ctx.set(app)
|
||||
|
||||
def as_(uid, name, role):
|
||||
_context._caller_ctx.set(Caller(user_id=uid, username=name, role=role, token_id=7))
|
||||
|
||||
as_(author, f"uc-a-{tag}", UserRole.operator)
|
||||
entry = await log_change(f"Swapped the PSU {tag}", "hardware", subject="psu", detail="old one whined")
|
||||
as_(other, f"uc-o-{tag}", UserRole.operator)
|
||||
with pytest.raises(ValueError, match="only they or an admin"):
|
||||
await update_change(entry["entry_id"], summary="mine now")
|
||||
with pytest.raises(ValueError, match="only they or an admin"):
|
||||
await delete_change(entry["entry_id"])
|
||||
|
||||
as_(author, f"uc-a-{tag}", UserRole.operator)
|
||||
fixed = await update_change(entry["entry_id"], summary=f"Swapped the PSU for a 650 W {tag}", subject="")
|
||||
assert (fixed["summary"], fixed["subject"], fixed["note"], fixed["area"]) == (
|
||||
f"Swapped the PSU for a 650 W {tag}", None, "old one whined", "hardware")
|
||||
with pytest.raises(ValueError, match="No recorded change"):
|
||||
await update_change("no-such-entry", summary="x")
|
||||
|
||||
as_(admin, f"uc-ad-{tag}", UserRole.admin)
|
||||
assert (await delete_change(entry["entry_id"]))["deleted"] == entry["entry_id"]
|
||||
from sqlalchemy import select
|
||||
from steward.models.audit import AuditEvent
|
||||
async with app.db_sessionmaker() as s:
|
||||
actions = list((await s.execute(select(AuditEvent.action).where(
|
||||
AuditEvent.entity_id == entry["entry_id"]).order_by(AuditEvent.timestamp))).scalars())
|
||||
assert actions == ["timeline.change_recorded", "timeline.change_edited", "timeline.change_deleted"]
|
||||
|
||||
asyncio.run(_go())
|
||||
@@ -154,9 +154,18 @@ def test_a_quick_restart_is_shown_though_it_never_broke_the_minute():
|
||||
assert _events.gaps_in(ticks, NOW + timedelta(minutes=2), NOW + timedelta(hours=1), [(restart, 35.0)]) == []
|
||||
|
||||
|
||||
def test_the_timeline_tools_are_registered_with_their_roles(app):
|
||||
def test_the_timeline_and_ticket_tools_are_registered_with_their_roles(app):
|
||||
mcp = app.mcp_server
|
||||
for name, role, read_only in (("get_timeline", UserRole.viewer, True),
|
||||
("log_change", UserRole.operator, False)):
|
||||
("log_change", UserRole.operator, False),
|
||||
("update_change", UserRole.operator, False),
|
||||
("delete_change", UserRole.operator, False),
|
||||
("list_tickets", UserRole.viewer, True),
|
||||
("get_ticket", UserRole.viewer, True),
|
||||
("open_ticket", UserRole.operator, False),
|
||||
("update_ticket", UserRole.operator, False),
|
||||
("comment_ticket", UserRole.operator, False),
|
||||
("resolve_ticket", UserRole.operator, False),
|
||||
("reopen_ticket", UserRole.operator, False)):
|
||||
assert mcp.tool_roles[name] is role, name
|
||||
assert mcp._tool_manager.get_tool(name).annotations.read_only_hint is read_only, name
|
||||
|
||||
@@ -18,6 +18,7 @@ TOP_LEVEL = {
|
||||
"steward/templates/ansible/index.html",
|
||||
"steward/templates/audit/list.html",
|
||||
"steward/templates/timeline/list.html",
|
||||
"steward/templates/tickets/list.html",
|
||||
"steward/templates/ai_access/index.html",
|
||||
"steward/templates/account/index.html",
|
||||
"plugins/docker/templates/docker/index.html",
|
||||
|
||||
Reference in New Issue
Block a user