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>
122 lines
7.0 KiB
Python
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
|