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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user