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,168 @@
"""Integration: the widget template library (#5692) against Postgres. An admin
copies a built-in template, saves it under its own name, edits, previews,
exports, uploads and deletes; the widget picker lists it; each change is
audited; a viewer can look but not change. Requires STEWARD_DATABASE_URL."""
from __future__ import annotations
import asyncio
import io
import json
import os
import time
import uuid
import pytest
pytestmark = pytest.mark.integration
CSRF = {"X-CSRF-Token": "test-token"}
@pytest.fixture
def app():
if not os.environ.get("STEWARD_DATABASE_URL"):
pytest.skip("needs Postgres")
from steward.app import create_app
return create_app(testing=False)
async def _client(app, role: str):
from steward.models.users import User, UserRole
uid = str(uuid.uuid4())
name = f"tl-{uid[:8]}"
async with app.db_sessionmaker() as s:
async with s.begin():
s.add(User(id=uid, username=name, email=f"{name}@test", password_hash="x",
role=UserRole(role), is_active=True))
client = app.test_client()
async with client.session_transaction() as sess:
sess.update(user_id=uid, user_role=role, username=name, _csrf="test-token",
login_at=time.time(), tz="UTC")
return client, uid
async def _audits(app, uid: str) -> list[tuple[str, dict]]:
from sqlalchemy import select
from steward.models.audit import AuditEvent
async with app.db_sessionmaker() as s:
rows = (await s.execute(select(AuditEvent).where(AuditEvent.user_id == uid)
.order_by(AuditEvent.timestamp))).scalars().all()
return [(r.action, json.loads(r.detail_json or "{}")) for r in rows]
def _prom(sub):
"""A submodule of the prometheus plugin as the app loaded it: importing
plugins.prometheus beside it would define its tables twice."""
import importlib
from steward.core.plugin_manager import get_loaded_plugin_modules
loaded = get_loaded_plugin_modules().get("prometheus")
return importlib.import_module(f"{loaded.__name__ if loaded else 'plugins.prometheus'}.{sub}")
def _text(page: str) -> str:
return " ".join(page.split())
def test_an_admin_copies_edits_previews_exports_and_deletes_a_template(app):
from steward.core.widgets import WIDGET_REGISTRY
from steward.dashboard.routes import with_options
tag = uuid.uuid4().hex[:8]
mine = f"Archives {tag}"
async def go():
admin, uid = await _client(app, "admin")
out = {}
listing = _text(await (await admin.get("/plugins/prometheus/templates/")).get_data(as_text=True))
out["listed_bundled"] = "Fabled Archives" in listing and "built in" in listing
out["tab_from_targets"] = 'href="/plugins/prometheus/templates/"' in _text(
await (await admin.get("/plugins/prometheus/")).get_data(as_text=True))
# Duplicate: an editor holding a copy under a name nobody has, nothing saved yet.
copy = await (await admin.get("/plugins/prometheus/templates/new?from=bundled:fabled_archives")
).get_data(as_text=True)
out["copy_named"] = "Fabled Archives (copy" in copy and "A copy of “Fabled Archives”" in copy
library = _prom("library")
text = library.renamed(library.bundled()[0].text, mine)
# A template with a problem isn't saved, and says where.
bad = await admin.post("/plugins/prometheus/templates/new", headers=CSRF,
form={"text": text.replace("version: 1", "version: one")})
out["bad"] = (bad.status_code, "Not saved." in _text(await bad.get_data(as_text=True)))
# The bundled template's name is taken.
clash = await admin.post("/plugins/prometheus/templates/new", headers=CSRF,
form={"text": library.bundled()[0].text})
out["clash"] = (clash.status_code, "is already named" in await clash.get_data(as_text=True))
saved = await admin.post("/plugins/prometheus/templates/new", headers=CSRF, form={"text": text})
out["saved"] = saved.status_code
where = saved.headers["Location"]
key = where.rsplit("/", 1)[1]
out["key"] = key
async with app.db_sessionmaker() as db:
picker = await with_options(db, WIDGET_REGISTRY["prometheus"])
out["picker"] = {"value": key, "label": mine} in picker["params"][1]["options"]
page = _text(await (await admin.get(where)).get_data(as_text=True))
out["editable"] = f'action="/plugins/prometheus/templates/{key}/edit"' in page and "Your own" in page
renamed = library.renamed(text, f"{mine} v2").replace("version: 1", "version: 2")
edit = await admin.post(f"/plugins/prometheus/templates/{key}/edit", headers=CSRF, form={"text": renamed})
out["edited"] = edit.status_code
exported = await admin.get(f"/plugins/prometheus/templates/{key}/export")
out["export"] = (exported.status_code, exported.headers.get("Content-Disposition"),
await exported.get_data(as_text=True) == renamed)
preview = await admin.post("/plugins/prometheus/templates/preview", headers=CSRF,
form={"text": "name: x\n"})
out["preview_bad"] = "to fix before it draws" in await preview.get_data(as_text=True)
preview = await admin.post("/plugins/prometheus/templates/preview", headers=CSRF,
form={"text": renamed, "target": "no-such-target"})
out["preview_ok"] = "passes the checker" in await preview.get_data(as_text=True)
from werkzeug.datastructures import FileStorage
upload = await admin.post("/plugins/prometheus/templates/upload", headers=CSRF, files={
"file": FileStorage(io.BytesIO(("" + renamed).encode()), filename="mine.yaml")})
up = await upload.get_data(as_text=True)
out["upload"] = (upload.status_code, "Read from mine.yaml" in up, f"{mine} v2" in up)
gone = await admin.post(f"/plugins/prometheus/templates/{key}/delete", headers=CSRF)
out["deleted"] = gone.status_code
out["after"] = (await admin.get(where)).status_code
out["audits"] = [(a, d.get("name"), d.get("old_name")) for a, d in await _audits(app, uid)]
out["mine"] = mine
return out
out = asyncio.run(go())
mine = out["mine"]
assert out["listed_bundled"] and out["tab_from_targets"] and out["copy_named"]
assert out["bad"] == (422, True) and out["clash"] == (422, True)
assert out["saved"] == 302 and out["key"].startswith("custom:")
assert out["picker"] and out["editable"] and out["edited"] == 302
assert out["export"] == (200, f'attachment; filename="archives-{mine.split()[1]}-v2.yaml"', True)
assert out["preview_bad"] and out["preview_ok"]
assert out["upload"] == (200, True, True)
assert out["deleted"] == 302 and out["after"] == 404
assert out["audits"] == [("prometheus.template_created", mine, None),
("prometheus.template_updated", f"{mine} v2", mine),
("prometheus.template_deleted", f"{mine} v2", None)]
def test_a_viewer_looks_and_exports_but_changes_nothing(app):
async def go():
viewer, _ = await _client(app, "viewer")
listing = await viewer.get("/plugins/prometheus/templates/")
page = await viewer.get("/plugins/prometheus/templates/bundled:fabled_archives")
exported = await viewer.get("/plugins/prometheus/templates/bundled:fabled_archives/export")
new = await viewer.get("/plugins/prometheus/templates/new")
post = await viewer.post("/plugins/prometheus/templates/new", headers=CSRF, form={"text": "x"})
return (listing.status_code, page.status_code, " readonly" in await page.get_data(as_text=True),
exported.status_code, new.status_code, post.status_code)
listing, page, readonly, exported, new, post = asyncio.run(go())
assert (listing, page, readonly, exported) == (200, 200, True, 200)
assert new == 403 and post == 403
+121
View File
@@ -0,0 +1,121 @@
"""The widget template library (#5692): copying a template under a new name,
the file a template exports as, and the list, editor and preview pages."""
from datetime import datetime, timedelta, timezone
from plugins.prometheus import library
from plugins.prometheus.template_format import check, problems
from tests._templates import render_plugin_template
from tests.plugins.prometheus.test_templates import NOW, SERIES, TEMPLATE, T, latest
from tests.test_style_tokens import hex_colours
ARCHIVES = library.bundled()[0]
def test_a_copy_gets_its_new_name_and_keeps_the_rest_as_written():
copy = library.renamed(ARCHIVES.text, "Fabled Archives (copy)")
assert check(copy).name == "Fabled Archives (copy)"
assert copy.splitlines()[0] == ARCHIVES.text.splitlines()[0] # its comments stay
assert copy.count("name: ") == ARCHIVES.text.count("name: ") # only the top-level one changed
# A name YAML would misread is quoted.
assert check(library.renamed(ARCHIVES.text, "Archives: mine #2")).name == "Archives: mine #2"
# No name line: one is added.
nameless = "\n".join(ln for ln in ARCHIVES.text.splitlines() if not ln.startswith("name:"))
assert check(library.renamed(nameless, "New")).name == "New"
def test_a_copy_is_named_after_what_is_taken():
assert library.copy_name("Archives", set()) == "Archives (copy)"
assert library.copy_name("Archives", {"Archives (copy)"}) == "Archives (copy 2)"
assert library.copy_name("Archives", {"Archives (copy)", "Archives (copy 2)"}) == "Archives (copy 3)"
assert len(library.copy_name("x" * 80, set())) == 80
def test_an_export_is_named_after_the_template():
assert library.file_name("Fabled Archives") == "fabled-archives.yaml"
assert library.file_name("Archives: mine #2") == "archives-mine-2.yaml"
assert library.file_name("…") == "template.yaml"
def _row(name="Mine", ok=True, minutes=5):
return {"key": f"custom:id-{name}", "id": f"id-{name}", "name": name, "bundled": False,
"version": 1 if ok else None, "description": "" if not ok else "My service",
"problem": "" if ok else "Line 4: `metric` should be a metric name.",
"updated_at": datetime.now(timezone.utc) - timedelta(minutes=minutes), "used": 2 if ok else 1}
async def _render(app, name, role="admin", **ctx):
from quart import session
async with app.test_request_context("/plugins/prometheus/templates/"):
session["user_role"] = role
session["user_id"] = "u1"
page = await render_plugin_template(app, "prometheus", name, **ctx)
return " ".join(page.split())
async def test_the_list_leads_with_a_template_that_no_longer_passes(app):
bundled = {"key": ARCHIVES.key, "id": None, "name": ARCHIVES.name, "bundled": True, "version": 1,
"description": "Backups", "problem": "", "updated_at": None, "used": 0}
page = await _render(app, "prometheus/templates.html",
templates=[_row("Broken", ok=False), bundled, _row("Mine")])
assert "1 template doesn't pass the checker" in page
assert page.index("Broken") < page.index("Fabled Archives") < page.index("Mine")
assert "Line 4: `metric` should be a metric name." in page
assert '<span class="tone-muted">built in</span>' in page and "2 widgets" in page
assert 'href="/plugins/prometheus/templates/new"' in page
assert 'href="/plugins/prometheus/templates/new?from=bundled%3Afabled_archives"' in page
assert 'href="/plugins/prometheus/templates/bundled:fabled_archives/export"' in page
assert '<nav class="section-tabs" aria-label="Prometheus">' in page
assert not hex_colours(page[page.index("<main"):])
async def test_a_viewer_can_look_and_export_but_not_change(app):
page = await _render(app, "prometheus/templates.html", role="viewer", templates=[_row("Mine")])
assert "templates/new" not in page and "/export" in page
assert "1 template: 0 built in, 1 of your own." in page
async def test_a_built_in_template_reads_only_and_offers_a_copy(app):
page = await _render(app, "prometheus/template.html", entry=ARCHIVES, title=ARCHIVES.name,
text=ARCHIVES.text, problems=[], note="", saving=False, editable=False, used=1,
targets=[{"value": "t1", "label": "Archives"}])
assert "Built in, read-only" in page and " readonly" in page
assert 'type="submit" class="btn">Save' not in page and "Delete template" not in page
assert "duplicate it and edit the copy" in page and "?from=bundled%3Afabled_archives" in page
assert '<option value="t1">Archives</option>' in page
assert 'hx-post="/plugins/prometheus/templates/preview"' in page
assert "1 dashboard widget draws with it." in page
async def test_your_own_template_edits_and_says_what_deleting_leaves(app):
mine = library._entry("custom:abc", "Mine", ARCHIVES.text.replace("Fabled Archives", "Mine"), False, id="abc")
page = await _render(app, "prometheus/template.html", entry=mine, title="Mine", text=mine.text,
problems=["Line 9: `metric` should be a metric name."], note="", saving=True,
editable=True, used=2, targets=[])
assert 'action="/plugins/prometheus/templates/custom:abc/edit"' in page and " readonly" not in page
assert "Not saved. 1 problem to fix:" in page and "Line 9" in page
assert "2 dashboard widgets draw with it; saving changes them too." in page
assert 'action="/plugins/prometheus/templates/custom:abc/delete"' in page
assert "2 widgets using it will say the template is gone." in page
async def test_a_new_template_offers_paste_and_upload(app):
page = await _render(app, "prometheus/template.html", entry=None, title="New template", text="",
problems=[], note="", saving=False, editable=True, used=0, targets=[])
assert 'action="/plugins/prometheus/templates/new"' in page and "Paste a template here" in page
assert 'enctype="multipart/form-data"' in page and 'action="/plugins/prometheus/templates/upload"' in page
assert "Delete template" not in page
async def test_the_preview_lists_problems_or_draws_the_widget(app):
bad = await _render(app, "prometheus/_preview.html", problems=[str(p) for p in problems("name: x\n")],
view=None, target=None)
assert "to fix before it draws" in bad and '<ul class="tpl-problems"><li>' in bad
from plugins.prometheus.template_view import render
view = render(check(TEMPLATE), T, latest(SERIES), NOW)
target = {"id": "t1", "name": T, "reach": "up", "says": "", "ok_at": NOW, "scraped_at": NOW}
drawn = await _render(app, "prometheus/_preview.html", problems=[], view=view, target=target,
template=check(TEMPLATE), spark_hours=24)
assert "3 need a look. Backup targets: Photos is late." in drawn
no_target = await _render(app, "prometheus/_preview.html", problems=[], view=None, target=None,
template=check(TEMPLATE))
assert "passes the checker" in no_target and "Add a Prometheus target" in no_target