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>
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user