diff --git a/docs/plugins/prometheus.md b/docs/plugins/prometheus.md index 822e10f..cad6986 100644 --- a/docs/plugins/prometheus.md +++ b/docs/plugins/prometheus.md @@ -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: diff --git a/plugins/prometheus/__init__.py b/plugins/prometheus/__init__.py index 14605ee..a34993b 100644 --- a/plugins/prometheus/__init__.py +++ b/plugins/prometheus/__init__.py @@ -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:{}")} diff --git a/plugins/prometheus/library.py b/plugins/prometheus/library.py index d2be5a6..b28bb57 100644 --- a/plugins/prometheus/library.py +++ b/plugins/prometheus/library.py @@ -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:". 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:". Templates an admin writes or +copies in are rows (PrometheusWidgetTemplate, #5692); their key is +"custom:". 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 ──────────────────────────────────────────────────── diff --git a/plugins/prometheus/migrations/versions/prometheus_002_templates.py b/plugins/prometheus/migrations/versions/prometheus_002_templates.py new file mode 100644 index 0000000..790927e --- /dev/null +++ b/plugins/prometheus/migrations/versions/prometheus_002_templates.py @@ -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") diff --git a/plugins/prometheus/models.py b/plugins/prometheus/models.py index d67f53f..31d0caf 100644 --- a/plugins/prometheus/models.py +++ b/plugins/prometheus/models.py @@ -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:", 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)) diff --git a/plugins/prometheus/routes.py b/plugins/prometheus/routes.py index 782167c..dcef850 100644 --- a/plugins/prometheus/routes.py +++ b/plugins/prometheus/routes.py @@ -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=) 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/") +@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:/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:/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//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) diff --git a/plugins/prometheus/templates/prometheus/_preview.html b/plugins/prometheus/templates/prometheus/_preview.html new file mode 100644 index 0000000..24504ca --- /dev/null +++ b/plugins/prometheus/templates/prometheus/_preview.html @@ -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 %} +
{{ problems|length }} problem{{ "s" if problems|length != 1 }} to fix before it draws.
+
    {% for p in problems %}
  • {{ p }}
  • {% endfor %}
+{% elif empty %} +

Paste or write a template, and it's drawn here as you type.

+{% elif view is none %} +
“{{ template.name }}” passes the checker.
+

Add a Prometheus target to see it drawn over real values.

+{% else %} +{% include "prometheus/widget.html" %} +{% endif %} diff --git a/plugins/prometheus/templates/prometheus/_tabs.html b/plugins/prometheus/templates/prometheus/_tabs.html new file mode 100644 index 0000000..fd3f937 --- /dev/null +++ b/plugins/prometheus/templates/prometheus/_tabs.html @@ -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 %} diff --git a/plugins/prometheus/templates/prometheus/form.html b/plugins/prometheus/templates/prometheus/form.html index adb22ce..68b21e4 100644 --- a/plugins/prometheus/templates/prometheus/form.html +++ b/plugins/prometheus/templates/prometheus/form.html @@ -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", "")]) }} diff --git a/plugins/prometheus/templates/prometheus/index.html b/plugins/prometheus/templates/prometheus/index.html index 15a126e..90b6e0d 100644 --- a/plugins/prometheus/templates/prometheus/index.html +++ b/plugins/prometheus/templates/prometheus/index.html @@ -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) %} diff --git a/plugins/prometheus/templates/prometheus/series.html b/plugins/prometheus/templates/prometheus/series.html index cbd8eca..3d99b25 100644 --- a/plugins/prometheus/templates/prometheus/series.html +++ b/plugins/prometheus/templates/prometheus/series.html @@ -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) %} diff --git a/plugins/prometheus/templates/prometheus/target.html b/plugins/prometheus/templates/prometheus/target.html index 65346c8..b3a874b 100644 --- a/plugins/prometheus/templates/prometheus/target.html +++ b/plugins/prometheus/templates/prometheus/target.html @@ -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' %} diff --git a/plugins/prometheus/templates/prometheus/template.html b/plugins/prometheus/templates/prometheus/template.html new file mode 100644 index 0000000..a948a5d --- /dev/null +++ b/plugins/prometheus/templates/prometheus/template.html @@ -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 %} + Export + {% if admin %}Duplicate{% endif %} + {% endif %} +{% endcall %} + +{% if problems %} + +{% endif %} +{% if note %}

{{ note }}

{% endif %} +{% if entry and entry.bundled %} +

Ships with Steward and changes with it. To change it, duplicate it and edit the copy.

+{% endif %} +{% if used %}

{{ used }} dashboard widget{{ "s draw" if used != 1 else " draws" }} with it{{ "; saving changes them too" if editable }}.

{% endif %} + +
+
{{ csrf_field() }} + + + {% if editable %} +
+ + Cancel +
+ {% endif %} +
+ +
+
+

Preview

+ {% if targets %} + + + {% endif %} +
+
+

Loading…

+
+
+
+ +{% if editable and not entry %} +
+

Or upload a file

+
{{ csrf_field() }} + + + + Opens it here to look over; nothing is saved until you press Save. +
+
+{% endif %} + +{% if editable and entry %} +
+
+

Delete this template

+

{% 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.

+
+
{{ csrf_field() }} + +
+
+{% endif %} +{% endblock %} diff --git a/plugins/prometheus/templates/prometheus/templates.html b/plugins/prometheus/templates/prometheus/templates.html new file mode 100644 index 0000000..8bd2423 --- /dev/null +++ b/plugins/prometheus/templates/prometheus/templates.html @@ -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 %}New template{% endif %} +{% endcall %} + +

+ {%- if broken %}{{ 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. + {%- else %}{{ templates|length }} template{{ "s" if templates|length != 1 }}: {{ templates|length - own|length }} built in, {{ own|length }} of your own.{% endif %} +

+

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.

+ +
+ + + + {% for t in templates %} + + + + + + + + {% endfor %} + +
TemplateVersionUsed byChangedActions
+ {{ t.name }} + {% if t.bundled %} built in{% endif %} + {%- if t.problem %}
{{ t.problem }}
+ {%- elif t.description %}
{{ t.description }}
{% endif %} +
{{ t.version if t.version is not none else "—" }}{% if t.used %}{{ t.used }} widget{{ "s" if t.used != 1 }}{% else %}none{% endif %}{% if t.updated_at %}{{ t.updated_at|ago }}{% else %}with Steward{% endif %} + Export + {% if admin %}Duplicate{% endif %} +
+
+{% endblock %} diff --git a/steward/backup/contents.py b/steward/backup/contents.py index 610cf3d..69c4af2 100644 --- a/steward/backup/contents.py +++ b/steward/backup/contents.py @@ -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. diff --git a/steward/static/steward.css b/steward/static/steward.css index ea1841b..000b1b6 100644 --- a/steward/static/steward.css +++ b/steward/static/steward.css @@ -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; } diff --git a/tests/integration/test_prometheus_template_library.py b/tests/integration/test_prometheus_template_library.py new file mode 100644 index 0000000..931520e --- /dev/null +++ b/tests/integration/test_prometheus_template_library.py @@ -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 diff --git a/tests/plugins/prometheus/test_library.py b/tests/plugins/prometheus/test_library.py new file mode 100644 index 0000000..eba4d83 --- /dev/null +++ b/tests/plugins/prometheus/test_library.py @@ -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 'built in' 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 '