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
+232 -14
View File
@@ -7,7 +7,7 @@ from __future__ import annotations
import json
from datetime import datetime, timedelta, timezone
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
from quart import Blueprint, Response, abort, current_app, redirect, render_template, request, session
from sqlalchemy import delete, func, select, update
from steward.auth.middleware import require_role
@@ -17,7 +17,8 @@ from steward.core.metric_series import latest_values, points
from steward.models.metrics import PluginMetric, PluginMetricLatest
from steward.models.users import UserRole
from .models import PrometheusTarget
from . import library
from .models import PrometheusTarget, PrometheusWidgetTemplate
from .scraper import ScrapeError, scrape
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, map_families,
parse_form, patterns, shown_value, target_state)
@@ -322,13 +323,29 @@ def spark_heights(values: list[float]) -> list[float]:
return [round(100.0 * abs(v) / peak, 1) if peak else 0.0 for v in values]
async def draw(db, template, target: PrometheusTarget, now: datetime):
"""`template` drawn over `target`'s stored values, with the last day's bars
for the items that ask for them. The dashboard widget and the template
editor's preview both draw through here."""
from .template_view import render
latest = await latest_values(db, SOURCE, target.name, include_children=True)
shown = render(template, target.name, latest, now)
if shown.wants:
since = now - timedelta(hours=SPARK_HOURS)
history = {}
for resource, metric in shown.wants:
got = await points(db, SOURCE, resource, metric, since, target=SPARK_HOURS)
history[(resource, metric)] = spark_heights([v for _, v in got])
shown = render(template, target.name, latest, now, history=history)
return shown
@prometheus_bp.get("/widget")
@require_role(UserRole.viewer)
async def widget():
"""The Prometheus dashboard widget: one target drawn by one template
(System 181's ruling), in widget shape. Reads stored values only."""
from . import library
from .template_view import render as draw
target_id = request.args.get("target") or ""
key = request.args.get("template") or ""
@@ -345,16 +362,8 @@ async def widget():
if problem:
return await render_template("prometheus/widget.html", problem=problem, target=None,
view=None)
latest = await latest_values(db, SOURCE, target.name, include_children=True)
view = draw(entry.template, target.name, latest, now)
if view.wants:
since = now - timedelta(hours=SPARK_HOURS)
history = {}
for resource, metric in view.wants:
got = await points(db, SOURCE, resource, metric, since, target=SPARK_HOURS)
history[(resource, metric)] = spark_heights([v for _, v in got])
view = draw(entry.template, target.name, latest, now, history=history)
return await render_template("prometheus/widget.html", problem="", view=view,
shown = await draw(db, entry.template, target, now)
return await render_template("prometheus/widget.html", problem="", view=shown,
target=view_target(target, now), spark_hours=SPARK_HOURS)
@@ -362,3 +371,212 @@ def view_target(target: PrometheusTarget, now: datetime) -> dict:
state = target_state(target, now)
return {"id": target.id, "name": target.name, "reach": state.reach, "says": state.says,
"ok_at": target.last_ok_at, "scraped_at": target.last_scraped_at}
# ── The template library (#5692) ─────────────────────────────────────────────
# Bundled templates are read-only: they can be looked at, exported and
# duplicated. An admin writes, pastes or uploads their own, edits and deletes
# them. Nothing is fetched from anywhere (System 181's ruling).
def _shown_entry(entry, used: int) -> dict:
return {"key": entry.key, "id": entry.id, "name": entry.name, "bundled": entry.bundled,
"version": entry.template.version if entry.template else None,
"description": entry.template.description if entry.template else "",
"problem": str(entry.problems[0]) if entry.problems else "",
"updated_at": entry.updated_at, "used": used}
@prometheus_bp.get("/templates/")
@require_role(UserRole.viewer)
async def templates_index():
async with current_app.db_sessionmaker() as db:
entries = await library.entries(db)
used = await library.used_by(db)
shown = [_shown_entry(e, used.get(e.key, 0)) for e in entries]
# One that no longer passes the checker first: its widgets say so too.
shown.sort(key=lambda t: (not t["problem"], t["name"].lower()))
return await render_template("prometheus/templates.html", templates=shown)
async def _editor(entry=None, text: str = "", problems=(), note: str = "", status: int = 200,
saving: bool = False):
"""The template page: an admin's editor, or a read-only view of a
bundled template (or of any template, to a viewer). Both preview it."""
async with current_app.db_sessionmaker() as db:
targets = await library.target_options(db)
used = (await library.used_by(db)).get(entry.key, 0) if entry is not None else 0
editable = session.get("user_role") == "admin" and (entry is None or not entry.bundled)
return await render_template(
"prometheus/template.html", entry=entry, title=entry.name if entry else "New template",
text=text if entry is None or text else entry.text,
problems=[str(p) for p in problems], note=note, targets=targets, editable=editable,
used=used, saving=saving), status
async def _entry_or_404(db, key: str):
entry = await library.find(db, key)
if entry is None:
abort(404)
return entry
@prometheus_bp.get("/templates/new")
@require_role(UserRole.admin)
async def new_template():
"""A blank editor, or a copy of another template (?from=<key>) under a
name nobody has."""
source = request.args.get("from") or ""
if not source:
return await _editor()
async with current_app.db_sessionmaker() as db:
entry = await _entry_or_404(db, source)
taken = {e.name for e in await library.entries(db)}
return await _editor(text=library.renamed(entry.text, library.copy_name(entry.name, taken)),
note=f"A copy of “{entry.name}”. Nothing is saved until you press Save.")
async def _checked(db, text: str, other_than: str | None = None):
"""The template `text` describes and its problems: the checker's, and a
name another template already has."""
from .template_format import Problem, TemplateError, check
try:
template = check(text)
except TemplateError as exc:
return None, exc.problems
if (await library.name_taken(db, template.name, other_than)
or any(e.name == template.name for e in library.bundled())):
line = next((i for i, ln in enumerate(text.splitlines(), 1) if ln.startswith("name:")), 0)
return None, [Problem(line, f"A template is already named “{template.name}”; give this one its own name.")]
return template, []
def _typed_text(form) -> str:
return (form.get("text") or "").replace("\r\n", "\n")
@prometheus_bp.post("/templates/new")
@require_role(UserRole.admin)
async def create_template():
form = await request.form
text = _typed_text(form)
async with current_app.db_sessionmaker() as db:
async with db.begin():
template, problems = await _checked(db, text)
if problems:
return await _editor(text=text, problems=problems, status=422, saving=True)
row = PrometheusWidgetTemplate(name=template.name, text=text)
db.add(row)
await db.flush()
row_id = row.id
await log_audit(current_app, *_actor(), "prometheus.template_created", entity_type="prometheus_template",
entity_id=row_id, detail={"name": template.name, "version": template.version})
return redirect(f"/plugins/prometheus/templates/custom:{row_id}")
UPLOAD_LIMIT = 64 * 1024
@prometheus_bp.post("/templates/upload")
@require_role(UserRole.admin)
async def upload_template():
"""A .yaml file read into a new editor. Nothing is saved until Save."""
files = await request.files
upload = files.get("file")
if upload is None or not upload.filename:
return await _editor(problems=["Choose a .yaml file to upload."], status=422)
raw = upload.read(UPLOAD_LIMIT + 1)
if len(raw) > UPLOAD_LIMIT:
return await _editor(problems=[f"A template is at most {UPLOAD_LIMIT // 1024} KB."], status=422)
try:
text = raw.decode("utf-8-sig").replace("\r\n", "\n")
except UnicodeDecodeError:
return await _editor(problems=[f"{upload.filename} isn't a text file (UTF-8)."], status=422)
return await _editor(text=text, note=f"Read from {upload.filename}. Nothing is saved until you press Save.")
@prometheus_bp.get("/templates/<key>")
@require_role(UserRole.viewer)
async def template_detail(key: str):
async with current_app.db_sessionmaker() as db:
entry = await _entry_or_404(db, key)
return await _editor(entry, problems=entry.problems)
async def _custom_or_404(db, template_id: str) -> PrometheusWidgetTemplate:
row = await db.get(PrometheusWidgetTemplate, template_id)
if row is None:
abort(404)
return row
@prometheus_bp.post("/templates/custom:<template_id>/edit")
@require_role(UserRole.admin)
async def save_template(template_id: str):
form = await request.form
text = _typed_text(form)
async with current_app.db_sessionmaker() as db:
async with db.begin():
row = await _custom_or_404(db, template_id)
template, problems = await _checked(db, text, other_than=row.id)
if problems:
entry = library.custom_entry(row)
return await _editor(entry, text=text, problems=problems, status=422, saving=True)
old_name = row.name
row.name, row.text = template.name, text
row.updated_at = datetime.now(timezone.utc)
detail = {"name": template.name, "version": template.version}
if old_name != template.name:
detail["old_name"] = old_name
await log_audit(current_app, *_actor(), "prometheus.template_updated", entity_type="prometheus_template",
entity_id=template_id, detail=detail)
return redirect(f"/plugins/prometheus/templates/custom:{template_id}")
@prometheus_bp.post("/templates/custom:<template_id>/delete")
@require_role(UserRole.admin)
async def delete_template(template_id: str):
async with current_app.db_sessionmaker() as db:
async with db.begin():
row = await _custom_or_404(db, template_id)
name = row.name
await db.delete(row)
await log_audit(current_app, *_actor(), "prometheus.template_deleted", entity_type="prometheus_template",
entity_id=template_id, detail={"name": name})
return redirect("/plugins/prometheus/templates/")
@prometheus_bp.get("/templates/<key>/export")
@require_role(UserRole.viewer)
async def export_template(key: str):
"""The template's YAML as a file, to share or keep."""
async with current_app.db_sessionmaker() as db:
entry = await _entry_or_404(db, key)
return Response(entry.text, mimetype="application/yaml", headers={
"Content-Disposition": f'attachment; filename="{library.file_name(entry.name)}"'})
@prometheus_bp.post("/templates/preview")
@require_role(UserRole.viewer)
async def preview_template():
"""The editor's live preview: what's typed, checked, and drawn over a
target's stored values as the widget would draw it. Saves nothing."""
from .template_format import TemplateError, check
form = await request.form
text = _typed_text(form)
now = datetime.now(timezone.utc)
try:
template = check(text) if text.strip() else None
except TemplateError as exc:
return await render_template("prometheus/_preview.html", problems=[str(p) for p in exc.problems],
view=None, target=None)
if template is None:
return await render_template("prometheus/_preview.html", problems=[], view=None, target=None,
empty=True)
async with current_app.db_sessionmaker() as db:
target = await db.get(PrometheusTarget, form.get("target") or "") if form.get("target") else None
if target is None:
return await render_template("prometheus/_preview.html", problems=[], view=None, target=None,
template=template)
shown = await draw(db, template, target, now)
return await render_template("prometheus/_preview.html", problems=[], view=shown, template=template,
target=view_target(target, now), spark_hours=SPARK_HOURS)