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
|
||||
Reference in New Issue
Block a user