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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user