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:
@@ -62,6 +62,19 @@ A template says which of the target's series to show and how: backup states, fig
|
||||
|
||||
Steward ships with one template, **Fabled Archives**. A target whose series don't match its template says so in the widget rather than showing blanks.
|
||||
|
||||
## Widget templates
|
||||
|
||||
**Prometheus → Widget templates** lists every template the widget can draw with: the ones built in, and your own. A template of yours that no longer passes the checker (a newer Steward may read the format differently) is listed first with what to change, and the widgets drawn with it say so too.
|
||||
|
||||
Open one to see its YAML beside a **preview**: the template drawn over a target you choose, exactly as the widget draws it, from the values Steward has stored. Nothing is fetched from the target to draw it.
|
||||
|
||||
- **New template**: write one, or paste in one someone shared. The preview redraws as you type, and lists each problem with its line until there are none.
|
||||
- **Upload a file**: on the same page, read a `.yaml` file into the editor to look over before saving.
|
||||
- **Duplicate**: copy any template, built-in ones included, under a new name, and change the copy. Built-in templates are read-only: they ship with Steward and change with it.
|
||||
- **Export**: download a template's YAML, to keep or to share.
|
||||
|
||||
Saving needs a template that passes the checker and a name no other template has. A widget remembers which template it draws with, so renaming a template keeps its widgets; deleting one leaves its widgets saying the template is gone. Templates are kept in Steward's backups, and adding, changing or deleting one is in the audit log.
|
||||
|
||||
## Alerting on a target
|
||||
|
||||
In **Alerts → New rule**, choose **Prometheus targets**, then the series (the resource) and the metric. Two rules worth having for any target:
|
||||
|
||||
@@ -11,7 +11,7 @@ _app: "Quart | None" = None
|
||||
def setup(app: "Quart") -> None:
|
||||
global _app
|
||||
_app = app
|
||||
from .models import PrometheusTarget # noqa: F401 — registers the table
|
||||
from .models import PrometheusTarget, PrometheusWidgetTemplate # noqa: F401 — registers the tables
|
||||
register_widgets()
|
||||
|
||||
|
||||
@@ -64,6 +64,7 @@ def get_alert_metrics() -> dict[str, list[str]]:
|
||||
|
||||
|
||||
def get_audit_entities() -> dict:
|
||||
"""Audit rows about a target name it and link to its page."""
|
||||
from .models import PrometheusTarget
|
||||
return {"prometheus_target": (PrometheusTarget, "/plugins/prometheus/targets/{}")}
|
||||
"""Audit rows about a target or a widget template name it and link to its page."""
|
||||
from .models import PrometheusTarget, PrometheusWidgetTemplate
|
||||
return {"prometheus_target": (PrometheusTarget, "/plugins/prometheus/targets/{}"),
|
||||
"prometheus_template": (PrometheusWidgetTemplate, "/plugins/prometheus/templates/custom:{}")}
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
"""The widget templates a Prometheus widget can draw with.
|
||||
|
||||
Bundled templates ship in plugins/prometheus/widgets/*.yaml, read once and
|
||||
read-only; their key is "bundled:<file name>". Templates an admin copies in
|
||||
join them with the template library (#5692). Steward fetches none from
|
||||
anywhere (System 181's ruling).
|
||||
read-only; their key is "bundled:<file name>". Templates an admin writes or
|
||||
copies in are rows (PrometheusWidgetTemplate, #5692); their key is
|
||||
"custom:<id>". Steward fetches none from anywhere (System 181's ruling).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from .models import PrometheusTarget
|
||||
from .models import PrometheusTarget, PrometheusWidgetTemplate
|
||||
from .template_format import Problem, Template, TemplateError, check
|
||||
|
||||
BUNDLED = Path(__file__).parent / "widgets"
|
||||
CUSTOM = "custom:"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -27,14 +31,16 @@ class Entry:
|
||||
text: str
|
||||
template: Template | None = None
|
||||
problems: list[Problem] = field(default_factory=list)
|
||||
id: str | None = None # a custom template's row
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
def _entry(key: str, fallback_name: str, text: str, bundled: bool) -> Entry:
|
||||
def _entry(key: str, fallback_name: str, text: str, bundled: bool, **row) -> Entry:
|
||||
try:
|
||||
template = check(text)
|
||||
except TemplateError as exc:
|
||||
return Entry(key, fallback_name, bundled, text, None, exc.problems)
|
||||
return Entry(key, template.name, bundled, text, template)
|
||||
return Entry(key, fallback_name, bundled, text, None, exc.problems, **row)
|
||||
return Entry(key, template.name, bundled, text, template, **row)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
@@ -43,13 +49,76 @@ def bundled() -> tuple[Entry, ...]:
|
||||
for p in sorted(BUNDLED.glob("*.yaml")))
|
||||
|
||||
|
||||
def custom_entry(row: PrometheusWidgetTemplate) -> Entry:
|
||||
# Saved only once it passed, but a later Steward may read the format
|
||||
# differently: it is checked again, and says so if it no longer passes.
|
||||
return _entry(CUSTOM + row.id, row.name, row.text, False, id=row.id, updated_at=row.updated_at)
|
||||
|
||||
|
||||
async def entries(db) -> list[Entry]:
|
||||
"""Every template, by name."""
|
||||
return sorted(bundled(), key=lambda e: e.name.lower())
|
||||
rows = (await db.execute(select(PrometheusWidgetTemplate))).scalars().all()
|
||||
return sorted([*bundled(), *(custom_entry(r) for r in rows)], key=lambda e: e.name.lower())
|
||||
|
||||
|
||||
async def find(db, key: str) -> Entry | None:
|
||||
return next((e for e in await entries(db) if e.key == key), None)
|
||||
if key.startswith(CUSTOM):
|
||||
row = await db.get(PrometheusWidgetTemplate, key[len(CUSTOM):])
|
||||
return custom_entry(row) if row else None
|
||||
return next((e for e in bundled() if e.key == key), None)
|
||||
|
||||
|
||||
async def name_taken(db, name: str, other_than: str | None = None) -> bool:
|
||||
"""Another saved template has this name; the widget picker lists by name."""
|
||||
q = select(PrometheusWidgetTemplate.id).where(PrometheusWidgetTemplate.name == name)
|
||||
if other_than:
|
||||
q = q.where(PrometheusWidgetTemplate.id != other_than)
|
||||
return (await db.execute(q)).first() is not None
|
||||
|
||||
|
||||
async def used_by(db) -> dict[str, int]:
|
||||
"""How many dashboard widgets draw with each template key."""
|
||||
from steward.models.dashboard import DashboardWidget
|
||||
counts: dict[str, int] = {}
|
||||
rows = (await db.execute(select(DashboardWidget.config_json)
|
||||
.where(DashboardWidget.widget_key == "prometheus"))).scalars()
|
||||
for raw in rows:
|
||||
try:
|
||||
key = (json.loads(raw or "{}") or {}).get("template")
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if key:
|
||||
counts[key] = counts.get(key, 0) + 1
|
||||
return counts
|
||||
|
||||
|
||||
# ── Copying ──────────────────────────────────────────────────────────────────
|
||||
|
||||
_NAME_LINE = re.compile(r"^name:[^\n]*$", re.MULTILINE)
|
||||
|
||||
|
||||
def renamed(text: str, name: str) -> str:
|
||||
"""`text` with its top-level `name:` set to `name`, the rest as written
|
||||
(its comments and layout kept)."""
|
||||
line = "name: " + json.dumps(name, ensure_ascii=False)
|
||||
if _NAME_LINE.search(text):
|
||||
return _NAME_LINE.sub(lambda _: line, text, count=1)
|
||||
return f"{line}\n{text}"
|
||||
|
||||
|
||||
def copy_name(name: str, taken: set[str]) -> str:
|
||||
""""Fabled Archives (copy)", then "(copy 2)", … a name nobody has."""
|
||||
for n in range(1, 1000):
|
||||
candidate = f"{name} (copy)" if n == 1 else f"{name} (copy {n})"
|
||||
if candidate not in taken:
|
||||
return candidate[:80]
|
||||
return name[:80]
|
||||
|
||||
|
||||
def file_name(name: str) -> str:
|
||||
"""A download's file name: "Fabled Archives" → fabled-archives.yaml."""
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
|
||||
return f"{slug or 'template'}.yaml"
|
||||
|
||||
|
||||
# ── The widget's settings ────────────────────────────────────────────────────
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Widget templates an admin writes or copies in are rows
|
||||
|
||||
Each row is one template's YAML and its name, beside the bundled templates
|
||||
that ship as files (#5692).
|
||||
|
||||
Revision ID: prometheus_002_templates
|
||||
Revises: prometheus_001_targets
|
||||
Create Date: 2026-10-14
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "prometheus_002_templates"
|
||||
down_revision: Union[str, None] = "prometheus_001_targets"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"prometheus_widget_templates",
|
||||
sa.Column("id", sa.String(36), primary_key=True),
|
||||
sa.Column("name", sa.String(80), nullable=False, unique=True),
|
||||
sa.Column("text", sa.Text, nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("prometheus_widget_templates")
|
||||
@@ -39,3 +39,21 @@ class PrometheusTarget(Base):
|
||||
last_kept: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_dropped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
last_capped: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||
|
||||
|
||||
class PrometheusWidgetTemplate(Base):
|
||||
"""A widget template an admin wrote or copied in (#5692), beside the
|
||||
bundled ones in widgets/*.yaml. `text` is the YAML as written; `name` is
|
||||
its `name:`, kept here so the library lists and orders without reading
|
||||
every template. Only a template that passes the checker is saved. Its key
|
||||
in a widget's settings is "custom:<id>", so renaming it keeps its widgets.
|
||||
"""
|
||||
__tablename__ = "prometheus_widget_templates"
|
||||
|
||||
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=time_ordered_id)
|
||||
name: Mapped[str] = mapped_column(String(80), nullable=False, unique=True)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc))
|
||||
|
||||
+232
-14
@@ -7,7 +7,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from quart import Blueprint, abort, current_app, redirect, render_template, request, session
|
||||
from quart import Blueprint, Response, abort, current_app, redirect, render_template, request, session
|
||||
from sqlalchemy import delete, func, select, update
|
||||
|
||||
from steward.auth.middleware import require_role
|
||||
@@ -17,7 +17,8 @@ from steward.core.metric_series import latest_values, points
|
||||
from steward.models.metrics import PluginMetric, PluginMetricLatest
|
||||
from steward.models.users import UserRole
|
||||
|
||||
from .models import PrometheusTarget
|
||||
from . import library
|
||||
from .models import PrometheusTarget, PrometheusWidgetTemplate
|
||||
from .scraper import ScrapeError, scrape
|
||||
from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, map_families,
|
||||
parse_form, patterns, shown_value, target_state)
|
||||
@@ -322,13 +323,29 @@ def spark_heights(values: list[float]) -> list[float]:
|
||||
return [round(100.0 * abs(v) / peak, 1) if peak else 0.0 for v in values]
|
||||
|
||||
|
||||
async def draw(db, template, target: PrometheusTarget, now: datetime):
|
||||
"""`template` drawn over `target`'s stored values, with the last day's bars
|
||||
for the items that ask for them. The dashboard widget and the template
|
||||
editor's preview both draw through here."""
|
||||
from .template_view import render
|
||||
|
||||
latest = await latest_values(db, SOURCE, target.name, include_children=True)
|
||||
shown = render(template, target.name, latest, now)
|
||||
if shown.wants:
|
||||
since = now - timedelta(hours=SPARK_HOURS)
|
||||
history = {}
|
||||
for resource, metric in shown.wants:
|
||||
got = await points(db, SOURCE, resource, metric, since, target=SPARK_HOURS)
|
||||
history[(resource, metric)] = spark_heights([v for _, v in got])
|
||||
shown = render(template, target.name, latest, now, history=history)
|
||||
return shown
|
||||
|
||||
|
||||
@prometheus_bp.get("/widget")
|
||||
@require_role(UserRole.viewer)
|
||||
async def widget():
|
||||
"""The Prometheus dashboard widget: one target drawn by one template
|
||||
(System 181's ruling), in widget shape. Reads stored values only."""
|
||||
from . import library
|
||||
from .template_view import render as draw
|
||||
|
||||
target_id = request.args.get("target") or ""
|
||||
key = request.args.get("template") or ""
|
||||
@@ -345,16 +362,8 @@ async def widget():
|
||||
if problem:
|
||||
return await render_template("prometheus/widget.html", problem=problem, target=None,
|
||||
view=None)
|
||||
latest = await latest_values(db, SOURCE, target.name, include_children=True)
|
||||
view = draw(entry.template, target.name, latest, now)
|
||||
if view.wants:
|
||||
since = now - timedelta(hours=SPARK_HOURS)
|
||||
history = {}
|
||||
for resource, metric in view.wants:
|
||||
got = await points(db, SOURCE, resource, metric, since, target=SPARK_HOURS)
|
||||
history[(resource, metric)] = spark_heights([v for _, v in got])
|
||||
view = draw(entry.template, target.name, latest, now, history=history)
|
||||
return await render_template("prometheus/widget.html", problem="", view=view,
|
||||
shown = await draw(db, entry.template, target, now)
|
||||
return await render_template("prometheus/widget.html", problem="", view=shown,
|
||||
target=view_target(target, now), spark_hours=SPARK_HOURS)
|
||||
|
||||
|
||||
@@ -362,3 +371,212 @@ def view_target(target: PrometheusTarget, now: datetime) -> dict:
|
||||
state = target_state(target, now)
|
||||
return {"id": target.id, "name": target.name, "reach": state.reach, "says": state.says,
|
||||
"ok_at": target.last_ok_at, "scraped_at": target.last_scraped_at}
|
||||
|
||||
|
||||
# ── The template library (#5692) ─────────────────────────────────────────────
|
||||
# Bundled templates are read-only: they can be looked at, exported and
|
||||
# duplicated. An admin writes, pastes or uploads their own, edits and deletes
|
||||
# them. Nothing is fetched from anywhere (System 181's ruling).
|
||||
|
||||
def _shown_entry(entry, used: int) -> dict:
|
||||
return {"key": entry.key, "id": entry.id, "name": entry.name, "bundled": entry.bundled,
|
||||
"version": entry.template.version if entry.template else None,
|
||||
"description": entry.template.description if entry.template else "",
|
||||
"problem": str(entry.problems[0]) if entry.problems else "",
|
||||
"updated_at": entry.updated_at, "used": used}
|
||||
|
||||
|
||||
@prometheus_bp.get("/templates/")
|
||||
@require_role(UserRole.viewer)
|
||||
async def templates_index():
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entries = await library.entries(db)
|
||||
used = await library.used_by(db)
|
||||
shown = [_shown_entry(e, used.get(e.key, 0)) for e in entries]
|
||||
# One that no longer passes the checker first: its widgets say so too.
|
||||
shown.sort(key=lambda t: (not t["problem"], t["name"].lower()))
|
||||
return await render_template("prometheus/templates.html", templates=shown)
|
||||
|
||||
|
||||
async def _editor(entry=None, text: str = "", problems=(), note: str = "", status: int = 200,
|
||||
saving: bool = False):
|
||||
"""The template page: an admin's editor, or a read-only view of a
|
||||
bundled template (or of any template, to a viewer). Both preview it."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
targets = await library.target_options(db)
|
||||
used = (await library.used_by(db)).get(entry.key, 0) if entry is not None else 0
|
||||
editable = session.get("user_role") == "admin" and (entry is None or not entry.bundled)
|
||||
return await render_template(
|
||||
"prometheus/template.html", entry=entry, title=entry.name if entry else "New template",
|
||||
text=text if entry is None or text else entry.text,
|
||||
problems=[str(p) for p in problems], note=note, targets=targets, editable=editable,
|
||||
used=used, saving=saving), status
|
||||
|
||||
|
||||
async def _entry_or_404(db, key: str):
|
||||
entry = await library.find(db, key)
|
||||
if entry is None:
|
||||
abort(404)
|
||||
return entry
|
||||
|
||||
|
||||
@prometheus_bp.get("/templates/new")
|
||||
@require_role(UserRole.admin)
|
||||
async def new_template():
|
||||
"""A blank editor, or a copy of another template (?from=<key>) under a
|
||||
name nobody has."""
|
||||
source = request.args.get("from") or ""
|
||||
if not source:
|
||||
return await _editor()
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entry = await _entry_or_404(db, source)
|
||||
taken = {e.name for e in await library.entries(db)}
|
||||
return await _editor(text=library.renamed(entry.text, library.copy_name(entry.name, taken)),
|
||||
note=f"A copy of “{entry.name}”. Nothing is saved until you press Save.")
|
||||
|
||||
|
||||
async def _checked(db, text: str, other_than: str | None = None):
|
||||
"""The template `text` describes and its problems: the checker's, and a
|
||||
name another template already has."""
|
||||
from .template_format import Problem, TemplateError, check
|
||||
try:
|
||||
template = check(text)
|
||||
except TemplateError as exc:
|
||||
return None, exc.problems
|
||||
if (await library.name_taken(db, template.name, other_than)
|
||||
or any(e.name == template.name for e in library.bundled())):
|
||||
line = next((i for i, ln in enumerate(text.splitlines(), 1) if ln.startswith("name:")), 0)
|
||||
return None, [Problem(line, f"A template is already named “{template.name}”; give this one its own name.")]
|
||||
return template, []
|
||||
|
||||
|
||||
def _typed_text(form) -> str:
|
||||
return (form.get("text") or "").replace("\r\n", "\n")
|
||||
|
||||
|
||||
@prometheus_bp.post("/templates/new")
|
||||
@require_role(UserRole.admin)
|
||||
async def create_template():
|
||||
form = await request.form
|
||||
text = _typed_text(form)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
template, problems = await _checked(db, text)
|
||||
if problems:
|
||||
return await _editor(text=text, problems=problems, status=422, saving=True)
|
||||
row = PrometheusWidgetTemplate(name=template.name, text=text)
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
row_id = row.id
|
||||
await log_audit(current_app, *_actor(), "prometheus.template_created", entity_type="prometheus_template",
|
||||
entity_id=row_id, detail={"name": template.name, "version": template.version})
|
||||
return redirect(f"/plugins/prometheus/templates/custom:{row_id}")
|
||||
|
||||
|
||||
UPLOAD_LIMIT = 64 * 1024
|
||||
|
||||
|
||||
@prometheus_bp.post("/templates/upload")
|
||||
@require_role(UserRole.admin)
|
||||
async def upload_template():
|
||||
"""A .yaml file read into a new editor. Nothing is saved until Save."""
|
||||
files = await request.files
|
||||
upload = files.get("file")
|
||||
if upload is None or not upload.filename:
|
||||
return await _editor(problems=["Choose a .yaml file to upload."], status=422)
|
||||
raw = upload.read(UPLOAD_LIMIT + 1)
|
||||
if len(raw) > UPLOAD_LIMIT:
|
||||
return await _editor(problems=[f"A template is at most {UPLOAD_LIMIT // 1024} KB."], status=422)
|
||||
try:
|
||||
text = raw.decode("utf-8-sig").replace("\r\n", "\n")
|
||||
except UnicodeDecodeError:
|
||||
return await _editor(problems=[f"{upload.filename} isn't a text file (UTF-8)."], status=422)
|
||||
return await _editor(text=text, note=f"Read from {upload.filename}. Nothing is saved until you press Save.")
|
||||
|
||||
|
||||
@prometheus_bp.get("/templates/<key>")
|
||||
@require_role(UserRole.viewer)
|
||||
async def template_detail(key: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entry = await _entry_or_404(db, key)
|
||||
return await _editor(entry, problems=entry.problems)
|
||||
|
||||
|
||||
async def _custom_or_404(db, template_id: str) -> PrometheusWidgetTemplate:
|
||||
row = await db.get(PrometheusWidgetTemplate, template_id)
|
||||
if row is None:
|
||||
abort(404)
|
||||
return row
|
||||
|
||||
|
||||
@prometheus_bp.post("/templates/custom:<template_id>/edit")
|
||||
@require_role(UserRole.admin)
|
||||
async def save_template(template_id: str):
|
||||
form = await request.form
|
||||
text = _typed_text(form)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
row = await _custom_or_404(db, template_id)
|
||||
template, problems = await _checked(db, text, other_than=row.id)
|
||||
if problems:
|
||||
entry = library.custom_entry(row)
|
||||
return await _editor(entry, text=text, problems=problems, status=422, saving=True)
|
||||
old_name = row.name
|
||||
row.name, row.text = template.name, text
|
||||
row.updated_at = datetime.now(timezone.utc)
|
||||
detail = {"name": template.name, "version": template.version}
|
||||
if old_name != template.name:
|
||||
detail["old_name"] = old_name
|
||||
await log_audit(current_app, *_actor(), "prometheus.template_updated", entity_type="prometheus_template",
|
||||
entity_id=template_id, detail=detail)
|
||||
return redirect(f"/plugins/prometheus/templates/custom:{template_id}")
|
||||
|
||||
|
||||
@prometheus_bp.post("/templates/custom:<template_id>/delete")
|
||||
@require_role(UserRole.admin)
|
||||
async def delete_template(template_id: str):
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
async with db.begin():
|
||||
row = await _custom_or_404(db, template_id)
|
||||
name = row.name
|
||||
await db.delete(row)
|
||||
await log_audit(current_app, *_actor(), "prometheus.template_deleted", entity_type="prometheus_template",
|
||||
entity_id=template_id, detail={"name": name})
|
||||
return redirect("/plugins/prometheus/templates/")
|
||||
|
||||
|
||||
@prometheus_bp.get("/templates/<key>/export")
|
||||
@require_role(UserRole.viewer)
|
||||
async def export_template(key: str):
|
||||
"""The template's YAML as a file, to share or keep."""
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
entry = await _entry_or_404(db, key)
|
||||
return Response(entry.text, mimetype="application/yaml", headers={
|
||||
"Content-Disposition": f'attachment; filename="{library.file_name(entry.name)}"'})
|
||||
|
||||
|
||||
@prometheus_bp.post("/templates/preview")
|
||||
@require_role(UserRole.viewer)
|
||||
async def preview_template():
|
||||
"""The editor's live preview: what's typed, checked, and drawn over a
|
||||
target's stored values as the widget would draw it. Saves nothing."""
|
||||
from .template_format import TemplateError, check
|
||||
form = await request.form
|
||||
text = _typed_text(form)
|
||||
now = datetime.now(timezone.utc)
|
||||
try:
|
||||
template = check(text) if text.strip() else None
|
||||
except TemplateError as exc:
|
||||
return await render_template("prometheus/_preview.html", problems=[str(p) for p in exc.problems],
|
||||
view=None, target=None)
|
||||
if template is None:
|
||||
return await render_template("prometheus/_preview.html", problems=[], view=None, target=None,
|
||||
empty=True)
|
||||
async with current_app.db_sessionmaker() as db:
|
||||
target = await db.get(PrometheusTarget, form.get("target") or "") if form.get("target") else None
|
||||
if target is None:
|
||||
return await render_template("prometheus/_preview.html", problems=[], view=None, target=None,
|
||||
template=template)
|
||||
shown = await draw(db, template, target, now)
|
||||
return await render_template("prometheus/_preview.html", problems=[], view=shown, template=template,
|
||||
target=view_target(target, now), spark_hours=SPARK_HOURS)
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{# prometheus/_preview.html — the template editor's live preview (#5692):
|
||||
what's typed, checked; when it passes, drawn over the chosen target as
|
||||
the dashboard widget would draw it (widget.html, the same fragment). #}
|
||||
{% if problems %}
|
||||
<div class="w-verdict tone-warn">{{ problems|length }} problem{{ "s" if problems|length != 1 }} to fix before it draws.</div>
|
||||
<ul class="tpl-problems">{% for p in problems %}<li>{{ p }}</li>{% endfor %}</ul>
|
||||
{% elif empty %}
|
||||
<p class="field-hint">Paste or write a template, and it's drawn here as you type.</p>
|
||||
{% elif view is none %}
|
||||
<div class="w-verdict tone-ok">“{{ template.name }}” passes the checker.</div>
|
||||
<p class="field-hint">Add a Prometheus target to see it drawn over real values.</p>
|
||||
{% else %}
|
||||
{% include "prometheus/widget.html" %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,7 @@
|
||||
{# The Prometheus section's tabs: its targets, and the widget templates the
|
||||
dashboard widget draws with (#5692). Call in the section_tabs block. #}
|
||||
{% from "_macros.html" import section_tabs %}
|
||||
{% macro prometheus_tabs(active) -%}
|
||||
{{ section_tabs([("targets", "Targets", "/plugins/prometheus/"),
|
||||
("templates", "Widget templates", "/plugins/prometheus/templates/")], active, "Prometheus") }}
|
||||
{%- endmacro %}
|
||||
@@ -5,6 +5,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% block title %}{{ ("Edit " ~ target.name) if target else "Add Prometheus target" }} — Steward{% endblock %}
|
||||
{% from "prometheus/_tabs.html" import prometheus_tabs %}
|
||||
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
|
||||
{% block breadcrumb %}
|
||||
{%- if target -%}
|
||||
{{ crumbs([("Prometheus", "/plugins/prometheus/"), (target.name, "/plugins/prometheus/targets/" ~ target.id), ("Edit", "")]) }}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import page_header, status_badge, stat_tile, empty_state %}
|
||||
{% block title %}Prometheus — Steward{% endblock %}
|
||||
{% from "prometheus/_tabs.html" import prometheus_tabs %}
|
||||
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set admin = session.user_role == 'admin' %}
|
||||
{% call page_header("Prometheus targets", (targets|length ~ " target" ~ ("s" if targets|length != 1 else "")) if targets else none) %}
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% block title %}{{ metric }} — {{ target.name }} — Steward{% endblock %}
|
||||
{% from "prometheus/_tabs.html" import prometheus_tabs %}
|
||||
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), (target.name, "/plugins/prometheus/targets/" ~ target.id), (metric, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% call page_header(metric, labels or none) %}
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header, status_badge, stat_tile %}
|
||||
{% block title %}Prometheus — {{ target.name }} — Steward{% endblock %}
|
||||
{% from "prometheus/_tabs.html" import prometheus_tabs %}
|
||||
{% block section_tabs %}{{ prometheus_tabs("targets") }}{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), (target.name, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set admin = session.user_role == 'admin' %}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
{# plugins/prometheus/templates/prometheus/template.html — one widget
|
||||
template (#5692): its YAML beside a live preview over a chosen target,
|
||||
drawn as the dashboard widget draws it. An admin edits their own here,
|
||||
or starts one by pasting or uploading; a built-in one is read-only and
|
||||
can be duplicated. The preview saves nothing. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% from "prometheus/_tabs.html" import prometheus_tabs %}
|
||||
{% block title %}{{ title }} — Widget templates — Steward{% endblock %}
|
||||
{% block section_tabs %}{{ prometheus_tabs("templates") }}{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), ("Widget templates", "/plugins/prometheus/templates/"), (title, "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set admin = session.user_role == 'admin' %}
|
||||
{% call page_header(title, ("Built in, read-only" if entry.bundled else "Your own") if entry else none) %}
|
||||
{% if entry %}
|
||||
<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/{{ entry.key }}/export">Export</a>
|
||||
{% if admin %}<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/new?from={{ entry.key|urlencode }}">Duplicate</a>{% endif %}
|
||||
{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
{% if problems %}
|
||||
<div class="field-error" role="alert">
|
||||
{{ "Not saved. " if saving }}{{ problems|length }} problem{{ "s" if problems|length != 1 }} to fix:
|
||||
<ul class="tpl-problems">{% for p in problems %}<li>{{ p }}</li>{% endfor %}</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if note %}<p class="field-hint">{{ note }}</p>{% endif %}
|
||||
{% if entry and entry.bundled %}
|
||||
<p class="field-hint">Ships with Steward and changes with it. To change it, duplicate it and edit the copy.</p>
|
||||
{% endif %}
|
||||
{% if used %}<p class="field-hint">{{ used }} dashboard widget{{ "s draw" if used != 1 else " draws" }} with it{{ "; saving changes them too" if editable }}.</p>{% endif %}
|
||||
|
||||
<div class="tpl-editor">
|
||||
<form method="post" id="tpl-form"
|
||||
action="{{ '/plugins/prometheus/templates/custom:' ~ entry.id ~ '/edit' if entry else '/plugins/prometheus/templates/new' }}">{{ csrf_field() }}
|
||||
<label class="field-label" for="tpl-text">YAML</label>
|
||||
<textarea class="input mono tpl-text" id="tpl-text" name="text" spellcheck="false" wrap="off"
|
||||
{%- if not editable %} readonly{% endif %}
|
||||
placeholder="Paste a template here, or write one: name, version and blocks. The guide is docs/plugins/prometheus.md.">{{ text }}</textarea>
|
||||
{% if editable %}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn">{{ "Save" if entry else "Save template" }}</button>
|
||||
<a href="{{ '/plugins/prometheus/templates/' ~ entry.key if entry else '/plugins/prometheus/templates/' }}" class="btn btn-ghost">Cancel</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</form>
|
||||
|
||||
<section class="card" aria-labelledby="tpl-preview-title">
|
||||
<div class="tpl-preview-head">
|
||||
<h2 id="tpl-preview-title" class="section-title">Preview</h2>
|
||||
{% if targets %}
|
||||
<label class="sr-only" for="tpl-target">Target</label>
|
||||
<select class="input" id="tpl-target" name="target">
|
||||
{% for t in targets %}<option value="{{ t.value }}">{{ t.label }}</option>{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div id="tpl-preview" class="dash-cell-body" aria-live="polite"
|
||||
hx-post="/plugins/prometheus/templates/preview"
|
||||
hx-trigger="load, input changed delay:600ms from:#tpl-text, change from:#tpl-target"
|
||||
hx-include="#tpl-text, #tpl-target">
|
||||
<p class="field-hint">Loading…</p>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{% if editable and not entry %}
|
||||
<section class="card form-section" aria-labelledby="tpl-upload">
|
||||
<h2 id="tpl-upload" class="section-title">Or upload a file</h2>
|
||||
<form method="post" action="/plugins/prometheus/templates/upload" enctype="multipart/form-data"
|
||||
style="display:flex;gap:0.75rem;align-items:center;flex-wrap:wrap;">{{ csrf_field() }}
|
||||
<label class="sr-only" for="tpl-file">Template file</label>
|
||||
<input id="tpl-file" type="file" name="file" accept=".yaml,.yml,text/yaml,application/yaml">
|
||||
<button type="submit" class="btn btn-sm btn-ghost">Read file</button>
|
||||
<span class="field-hint">Opens it here to look over; nothing is saved until you press Save.</span>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
{% if editable and entry %}
|
||||
<section class="card danger-zone" aria-labelledby="tpl-delete">
|
||||
<div>
|
||||
<h2 id="tpl-delete" class="section-title">Delete this template</h2>
|
||||
<p class="field-hint">{% if used %}{{ used }} widget{{ "s" if used != 1 }} using it will say the template is gone.{% else %}No widget uses it.{% endif %} Export it first to keep a copy.</p>
|
||||
</div>
|
||||
<form method="post" action="/plugins/prometheus/templates/custom:{{ entry.id }}/delete"
|
||||
data-confirm="Delete widget template {{ entry.name }}?{% if used %} {{ used }} widget{{ 's' if used != 1 }} use it.{% endif %}">{{ csrf_field() }}
|
||||
<button class="btn btn-danger" type="submit">Delete template</button>
|
||||
</form>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,49 @@
|
||||
{# plugins/prometheus/templates/prometheus/templates.html — the widget
|
||||
templates the Prometheus dashboard widget can draw with (#5692): the ones
|
||||
built in and the ones copied in here. One that no longer passes the
|
||||
checker comes first, saying why: its widgets say so too. #}
|
||||
{% extends "base.html" %}
|
||||
{% from "_macros.html" import crumbs, page_header %}
|
||||
{% from "prometheus/_tabs.html" import prometheus_tabs %}
|
||||
{% block title %}Widget templates — Prometheus — Steward{% endblock %}
|
||||
{% block section_tabs %}{{ prometheus_tabs("templates") }}{% endblock %}
|
||||
{% block breadcrumb %}{{ crumbs([("Prometheus", "/plugins/prometheus/"), ("Widget templates", "")]) }}{% endblock %}
|
||||
{% block content %}
|
||||
{% set admin = session.user_role == 'admin' %}
|
||||
{% set broken = templates|selectattr("problem")|list %}
|
||||
{% set own = templates|rejectattr("bundled")|list %}
|
||||
{% call page_header("Widget templates") %}
|
||||
{% if admin %}<a class="btn btn-sm" href="/plugins/prometheus/templates/new">New template</a>{% endif %}
|
||||
{% endcall %}
|
||||
|
||||
<p class="page-verdict">
|
||||
{%- if broken %}<span class="tone-warn">{{ broken|length }} template{{ "s don't" if broken|length != 1 else " doesn't" }} pass the checker; widgets drawn with {{ "them" if broken|length != 1 else "it" }} say so.</span>
|
||||
{%- else %}{{ templates|length }} template{{ "s" if templates|length != 1 }}: {{ templates|length - own|length }} built in, {{ own|length }} of your own.{% endif %}
|
||||
</p>
|
||||
<p class="field-hint">A template says which of a target's series the Prometheus dashboard widget shows, and how. Built-in ones can be copied and changed; your own you write here, or paste or upload one someone shared.</p>
|
||||
|
||||
<section class="card">
|
||||
<table class="table">
|
||||
<thead><tr><th scope="col">Template</th><th scope="col">Version</th><th scope="col">Used by</th><th scope="col">Changed</th><th scope="col"><span class="sr-only">Actions</span></th></tr></thead>
|
||||
<tbody>
|
||||
{% for t in templates %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="/plugins/prometheus/templates/{{ t.key }}">{{ t.name }}</a>
|
||||
{% if t.bundled %} <span class="tone-muted">built in</span>{% endif %}
|
||||
{%- if t.problem %}<div class="cell-sub tone-warn" title="{{ t.problem }}">{{ t.problem }}</div>
|
||||
{%- elif t.description %}<div class="cell-sub" title="{{ t.description }}">{{ t.description }}</div>{% endif %}
|
||||
</td>
|
||||
<td>{{ t.version if t.version is not none else "—" }}</td>
|
||||
<td>{% if t.used %}{{ t.used }} widget{{ "s" if t.used != 1 }}{% else %}<span class="tone-muted">none</span>{% endif %}</td>
|
||||
<td>{% if t.updated_at %}{{ t.updated_at|ago }}{% else %}<span class="tone-muted">with Steward</span>{% endif %}</td>
|
||||
<td style="text-align:right;white-space:nowrap;">
|
||||
<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/{{ t.key }}/export">Export</a>
|
||||
{% if admin %}<a class="btn btn-sm btn-ghost" href="/plugins/prometheus/templates/new?from={{ t.key|urlencode }}">Duplicate</a>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -20,7 +20,7 @@ KEPT: frozenset[str] = frozenset({
|
||||
"alert_rules", "alert_states", "maintenance_windows",
|
||||
"ansible_targets", "ansible_groups", "ansible_target_groups", "ansible_schedules",
|
||||
"dashboards", "dashboard_widgets", "dashboard_share_tokens",
|
||||
"snmp_devices", "prometheus_targets",
|
||||
"snmp_devices", "prometheus_targets", "prometheus_widget_templates",
|
||||
# Names given to network clients by hand (UniFi's alias column).
|
||||
"unifi_known_clients",
|
||||
# History kept by the ruling.
|
||||
|
||||
@@ -624,3 +624,13 @@ body.dash-editing .widget-drawer { transform:translateY(0); }
|
||||
.range-btn { background:none; border:1px solid var(--border-control); border-radius:4px; color:var(--text-muted); padding:0.18rem 0.5rem; cursor:pointer; font-size:0.75rem; font-family:inherit; transition:background .1s,color .1s,border-color .1s; }
|
||||
.range-btn:hover { background:var(--bg-elevated); color:var(--text); border-color:var(--accent); }
|
||||
.range-btn.active { background:var(--accent); border-color:var(--accent); color:var(--fs-surface-page); }
|
||||
/* The Prometheus widget template editor (#5692): the YAML beside its live
|
||||
preview, which is drawn at about a dashboard cell's width; stacked on a
|
||||
narrow screen. */
|
||||
.tpl-editor { display:grid; grid-template-columns:minmax(0,1fr) 24rem; gap:1rem; align-items:start; margin-bottom:1rem; }
|
||||
@media (max-width: 900px) { .tpl-editor { grid-template-columns:1fr; } }
|
||||
.tpl-text { width:100%; min-height:28rem; font-size:0.8rem; line-height:1.45; tab-size:2; white-space:pre; resize:vertical; }
|
||||
.tpl-problems { margin:0.3rem 0 0; padding-left:1.1rem; font-size:0.82rem; }
|
||||
.tpl-preview-head { display:flex; align-items:center; gap:0.75rem; margin-bottom:0.5rem; }
|
||||
.tpl-preview-head .section-title { margin:0; }
|
||||
.tpl-preview-head select { margin-left:auto; max-width:12rem; }
|
||||
|
||||
@@ -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