Files
bvandeusenandClaude Opus 5.5 bd762307e0
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m16s
CI / unit (push) Failing after 1m29s
CI / publish (push) Skipped
feat(prometheus): the widget template library: list, edit with live preview, paste in, upload, export, duplicate (#5692)
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>
2026-10-14 09:22:31 -04:00

122 lines
7.0 KiB
Python

"""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