feat(prometheus): the widget template library: list, edit with live preview, paste in, upload, export, duplicate (#5692)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m16s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped

Prometheus gains a "Widget templates" tab beside Targets. It lists the
built-in templates and an admin's own, any that no longer pass the checker
first. A template opens as its YAML beside a live preview drawn over a
chosen target exactly as the dashboard widget draws it (routes.draw, now
shared by both). An admin writes or pastes a new one, reads a .yaml upload
into the editor to look over, duplicates any template under a free name,
edits and deletes their own; anyone can export the YAML. Built-in
templates are read-only.

Custom templates are rows in prometheus_widget_templates (migration
prometheus_002_templates), kept in backups, keyed "custom:<id>" so a
rename keeps its widgets. Saving needs a template that passes the checker
and a name no other template has. Create, update and delete are audited
and link to the template.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 09:22:31 -04:00
co-authored by Claude Opus 5.5
parent 5ca13f9278
commit bd762307e0
18 changed files with 849 additions and 28 deletions
@@ -0,0 +1,14 @@
{# prometheus/_preview.html — the template editor's live preview (#5692):
what's typed, checked; when it passes, drawn over the chosen target as
the dashboard widget would draw it (widget.html, the same fragment). #}
{% if problems %}
<div class="w-verdict tone-warn">{{ problems|length }} problem{{ "s" if problems|length != 1 }} to fix before it draws.</div>
<ul class="tpl-problems">{% for p in problems %}<li>{{ p }}</li>{% endfor %}</ul>
{% elif empty %}
<p class="field-hint">Paste or write a template, and it's drawn here as you type.</p>
{% elif view is none %}
<div class="w-verdict tone-ok">“{{ template.name }}” passes the checker.</div>
<p class="field-hint">Add a Prometheus target to see it drawn over real values.</p>
{% else %}
{% include "prometheus/widget.html" %}
{% endif %}
@@ -0,0 +1,7 @@
{# The Prometheus section's tabs: its targets, and the widget templates the
dashboard widget draws with (#5692). Call in the section_tabs block. #}
{% from "_macros.html" import section_tabs %}
{% macro prometheus_tabs(active) -%}
{{ section_tabs([("targets", "Targets", "/plugins/prometheus/"),
("templates", "Widget templates", "/plugins/prometheus/templates/")], active, "Prometheus") }}
{%- endmacro %}
@@ -5,6 +5,8 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}{{ ("Edit " ~ target.name) if target else "Add Prometheus target" }} — Steward{% endblock %}
{% from "prometheus/_tabs.html" import prometheus_tabs %}
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
{% block breadcrumb %}
{%- if target -%}
{{ crumbs([("Prometheus", "/plugins/prometheus/"), (target.name, "/plugins/prometheus/targets/" ~ target.id), ("Edit", "")]) }}
@@ -4,6 +4,8 @@
{% extends "base.html" %}
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
{% block title %}Prometheus — Steward{% endblock %}
{% from "prometheus/_tabs.html" import prometheus_tabs %}
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{% call page_header("Prometheus targets", (targets|length ~ " target" ~ ("s" if targets|length != 1 else "")) if targets else none) %}
@@ -4,6 +4,8 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% block title %}{{ metric }} — {{ target.name }} — Steward{% endblock %}
{% from "prometheus/_tabs.html" import prometheus_tabs %}
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), (target.name, "/plugins/prometheus/targets/" ~ target.id), (metric, "")]) }}{% endblock %}
{% block content %}
{% call page_header(metric, labels or none) %}
@@ -6,6 +6,8 @@
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header, status_badge, stat_tile %}
{% block title %}Prometheus — {{ target.name }} — Steward{% endblock %}
{% from "prometheus/_tabs.html" import prometheus_tabs %}
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), (target.name, "")]) }}{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
@@ -0,0 +1,92 @@
{# plugins/prometheus/templates/prometheus/template.html — one widget
template (#5692): its YAML beside a live preview over a chosen target,
drawn as the dashboard widget draws it. An admin edits their own here,
or starts one by pasting or uploading; a built-in one is read-only and
can be duplicated. The preview saves nothing. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "prometheus/_tabs.html" import prometheus_tabs %}
{% block title %}{{ title }} — Widget templates — Steward{% endblock %}
{% block section_tabs %}{{ prometheus_tabs("templates") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), ("Widget templates", "/plugins/prometheus/templates/"), (title, "")]) }}{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{% call page_header(title, ("Built in, read-only" if entry.bundled else "Your own") if entry else none) %}
{% if entry %}
<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/{{ entry.key }}/export">Export</a>
{% if admin %}<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/new?from={{ entry.key|urlencode }}">Duplicate</a>{% endif %}
{% endif %}
{% endcall %}
{% if problems %}
<div class="field-error" role="alert">
{{ "Not saved. " if saving }}{{ problems|length }} problem{{ "s" if problems|length != 1 }} to fix:
<ul class="tpl-problems">{% for p in problems %}<li>{{ p }}</li>{% endfor %}</ul>
</div>
{% endif %}
{% if note %}<p class="field-hint">{{ note }}</p>{% endif %}
{% if entry and entry.bundled %}
<p class="field-hint">Ships with Steward and changes with it. To change it, duplicate it and edit the copy.</p>
{% endif %}
{% if used %}<p class="field-hint">{{ used }} dashboard widget{{ "s draw" if used != 1 else " draws" }} with it{{ "; saving changes them too" if editable }}.</p>{% endif %}
<div class="tpl-editor">
<form method="post" id="tpl-form"
action="{{ '/plugins/prometheus/templates/custom:' ~ entry.id ~ '/edit' if entry else '/plugins/prometheus/templates/new' }}">{{ csrf_field() }}
<label class="field-label" for="tpl-text">YAML</label>
<textarea class="input mono tpl-text" id="tpl-text" name="text" spellcheck="false" wrap="off"
{%- if not editable %} readonly{% endif %}
placeholder="Paste a template here, or write one: name, version and blocks. The guide is docs/plugins/prometheus.md.">{{ text }}</textarea>
{% if editable %}
<div class="form-actions">
<button type="submit" class="btn">{{ "Save" if entry else "Save template" }}</button>
<a href="{{ '/plugins/prometheus/templates/' ~ entry.key if entry else '/plugins/prometheus/templates/' }}" class="btn btn-ghost">Cancel</a>
</div>
{% endif %}
</form>
<section class="card" aria-labelledby="tpl-preview-title">
<div class="tpl-preview-head">
<h2 id="tpl-preview-title" class="section-title">Preview</h2>
{% if targets %}
<label class="sr-only" for="tpl-target">Target</label>
<select class="input" id="tpl-target" name="target">
{% for t in targets %}<option value="{{ t.value }}">{{ t.label }}</option>{% endfor %}
</select>
{% endif %}
</div>
<div id="tpl-preview" class="dash-cell-body" aria-live="polite"
hx-post="/plugins/prometheus/templates/preview"
hx-trigger="load, input changed delay:600ms from:#tpl-text, change from:#tpl-target"
hx-include="#tpl-text, #tpl-target">
<p class="field-hint">Loading…</p>
</div>
</section>
</div>
{% if editable and not entry %}
<section class="card form-section" aria-labelledby="tpl-upload">
<h2 id="tpl-upload" class="section-title">Or upload a file</h2>
<form method="post" action="/plugins/prometheus/templates/upload" enctype="multipart/form-data"
style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">{{ csrf_field() }}
<label class="sr-only" for="tpl-file">Template file</label>
<input id="tpl-file" type="file" name="file" accept=".yaml,.yml,text/yaml,application/yaml">
<button type="submit" class="btn btn-sm btn-ghost">Read file</button>
<span class="field-hint">Opens it here to look over; nothing is saved until you press Save.</span>
</form>
</section>
{% endif %}
{% if editable and entry %}
<section class="card danger-zone" aria-labelledby="tpl-delete">
<div>
<h2 id="tpl-delete" class="section-title">Delete this template</h2>
<p class="field-hint">{% if used %}{{ used }} widget{{ "s" if used != 1 }} using it will say the template is gone.{% else %}No widget uses it.{% endif %} Export it first to keep a copy.</p>
</div>
<form method="post" action="/plugins/prometheus/templates/custom:{{ entry.id }}/delete"
data-confirm="Delete widget template {{ entry.name }}?{% if used %} {{ used }} widget{{ 's' if used != 1 }} use it.{% endif %}">{{ csrf_field() }}
<button class="btn btn-danger" type="submit">Delete template</button>
</form>
</section>
{% endif %}
{% endblock %}
@@ -0,0 +1,49 @@
{# plugins/prometheus/templates/prometheus/templates.html — the widget
templates the Prometheus dashboard widget can draw with (#5692): the ones
built in and the ones copied in here. One that no longer passes the
checker comes first, saying why: its widgets say so too. #}
{% extends "base.html" %}
{% from "_macros.html" import crumbs, page_header %}
{% from "prometheus/_tabs.html" import prometheus_tabs %}
{% block title %}Widget templates — Prometheus — Steward{% endblock %}
{% block section_tabs %}{{ prometheus_tabs("templates") }}{% endblock %}
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), ("Widget templates", "")]) }}{% endblock %}
{% block content %}
{% set admin = session.user_role == 'admin' %}
{% set broken = templates|selectattr("problem")|list %}
{% set own = templates|rejectattr("bundled")|list %}
{% call page_header("Widget templates") %}
{% if admin %}<a class="btn btn-sm" href="/plugins/prometheus/templates/new">New template</a>{% endif %}
{% endcall %}
<p class="page-verdict">
{%- if broken %}<span class="tone-warn">{{ broken|length }} template{{ "s don't" if broken|length != 1 else " doesn't" }} pass the checker; widgets drawn with {{ "them" if broken|length != 1 else "it" }} say so.</span>
{%- else %}{{ templates|length }} template{{ "s" if templates|length != 1 }}: {{ templates|length - own|length }} built in, {{ own|length }} of your own.{% endif %}
</p>
<p class="field-hint">A template says which of a target's series the Prometheus dashboard widget shows, and how. Built-in ones can be copied and changed; your own you write here, or paste or upload one someone shared.</p>
<section class="card">
<table class="table">
<thead><tr><th scope="col">Template</th><th scope="col">Version</th><th scope="col">Used by</th><th scope="col">Changed</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
<tbody>
{% for t in templates %}
<tr>
<td>
<a href="/plugins/prometheus/templates/{{ t.key }}">{{ t.name }}</a>
{% if t.bundled %} <span class="tone-muted">built in</span>{% endif %}
{%- if t.problem %}<div class="cell-sub tone-warn" title="{{ t.problem }}">{{ t.problem }}</div>
{%- elif t.description %}<div class="cell-sub" title="{{ t.description }}">{{ t.description }}</div>{% endif %}
</td>
<td>{{ t.version if t.version is not none else "—" }}</td>
<td>{% if t.used %}{{ t.used }} widget{{ "s" if t.used != 1 }}{% else %}<span class="tone-muted">none</span>{% endif %}</td>
<td>{% if t.updated_at %}{{ t.updated_at|ago }}{% else %}<span class="tone-muted">with Steward</span>{% endif %}</td>
<td style="text-align:right;white-space:nowrap;">
<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/{{ t.key }}/export">Export</a>
{% if admin %}<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/new?from={{ t.key|urlencode }}">Duplicate</a>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</section>
{% endblock %}