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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user