"""The Prometheus pages. Every GET reads Steward's own records: the target rows the editor writes and the scraper annotates, and the series it stores. Only Test, an admin's explicit action, asks an endpoint anything (System 178's ruling).""" from __future__ import annotations import json from datetime import datetime, timedelta, timezone 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 from steward.core.audit import log_audit from steward.core.crypto import decrypt_secret from steward.core.metric_series import latest_values, points from steward.models.metrics import PluginMetric, PluginMetricLatest from steward.models.users import UserRole 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) prometheus_bp = Blueprint("prometheus", __name__, template_folder="templates") _REACH_ORDER = {"down": 0, "stale": 1, "pending": 2, "up": 3, "off": 4} def _actor() -> tuple[str | None, str]: return session.get("user_id"), session.get("username", "") async def _all_targets(db) -> list[PrometheusTarget]: return list((await db.execute(select(PrometheusTarget).order_by(PrometheusTarget.name))).scalars().all()) def view(target: PrometheusTarget, own: dict, now: datetime) -> dict: """What a page says about a target: its state in words and the numbers from its newest scrape.""" state = target_state(target, now) return {"id": target.id, "name": target.name, "url": target.url, "reach": state.reach, "says": state.says, "scraped_at": target.last_scraped_at, "ok_at": target.last_ok_at, "interval": target.interval_seconds, "kept": target.last_kept, "dropped": target.last_dropped, "capped": target.last_capped, "cap": target.series_cap, "keep": list(target.keep or []), "drop": list(target.drop or []), "scrape_ms": own.get("scrape_ms")} @prometheus_bp.get("/") @require_role(UserRole.viewer) async def index(): async with current_app.db_sessionmaker() as db: targets = await _all_targets(db) own = {} if targets: rows = (await db.execute(select(PluginMetricLatest).where( PluginMetricLatest.source_module == SOURCE, PluginMetricLatest.resource_name.in_([t.name for t in targets]), PluginMetricLatest.metric_name == "scrape_ms"))).scalars() own = {r.resource_name: {"scrape_ms": r.value} for r in rows} now = datetime.now(timezone.utc) shown = [view(t, own.get(t.name, {}), now) for t in targets] shown.sort(key=lambda v: (_REACH_ORDER[v["reach"]], v["name"].lower())) return await render_template("prometheus/index.html", targets=shown) # ── The editor ──────────────────────────────────────────────────────────────── async def _render_form(target: PrometheusTarget | None, values: dict, error: str | None = None, status: int = 200): return await render_template("prometheus/form.html", target=target, v=values, error=error), status def _typed(form, target: PrometheusTarget | None = None) -> dict: """What was typed, to show the form again with an error. The token is not echoed back.""" return {"name": form.get("name", ""), "url": form.get("url", ""), "interval_seconds": form.get("interval_seconds", ""), "series_cap": form.get("series_cap", ""), "keep": form.get("keep", ""), "drop": form.get("drop", ""), "verify_tls": bool(form.get("verify_tls")), "enabled": bool(form.get("enabled")), "has_token": bool(target and target.token)} async def _name_taken(db, name: str, other_than: str | None = None) -> bool: q = select(PrometheusTarget.id).where(PrometheusTarget.name == name) if other_than: q = q.where(PrometheusTarget.id != other_than) return (await db.execute(q)).first() is not None async def _target_or_404(db, target_id: str) -> PrometheusTarget: target = await db.get(PrometheusTarget, target_id) if target is None: abort(404) return target @prometheus_bp.get("/targets/new") @require_role(UserRole.admin) async def new_target(): return await _render_form(None, form_values(None)) @prometheus_bp.post("/targets/new") @require_role(UserRole.admin) async def create_target(): form = await request.form try: values = parse_form(form) except FormError as exc: return await _render_form(None, _typed(form), str(exc), 422) async with current_app.db_sessionmaker() as db: async with db.begin(): if await _name_taken(db, values["name"]): return await _render_form(None, _typed(form), f"A target is already named “{values['name']}”.", 422) target = PrometheusTarget(**values) db.add(target) await db.flush() target_id = target.id await log_audit(current_app, *_actor(), "prometheus.target_created", entity_type="prometheus_target", entity_id=target_id, detail={"name": values["name"], "url": values["url"]}) return redirect(f"/plugins/prometheus/targets/{target_id}") @prometheus_bp.get("/targets//edit") @require_role(UserRole.admin) async def edit_target(target_id: str): async with current_app.db_sessionmaker() as db: target = await _target_or_404(db, target_id) return await _render_form(target, form_values(target)) @prometheus_bp.post("/targets//edit") @require_role(UserRole.admin) async def save_target(target_id: str): form = await request.form async with current_app.db_sessionmaker() as db: async with db.begin(): target = await _target_or_404(db, target_id) try: values = parse_form(form, target) except FormError as exc: return await _render_form(target, _typed(form, target), str(exc), 422) if await _name_taken(db, values["name"], other_than=target.id): return await _render_form(target, _typed(form, target), f"A target is already named “{values['name']}”.", 422) old_name = target.name for column, value in values.items(): setattr(target, column, value) # Where it is or what is kept may have changed: the last outcome no # longer describes it, so the page says "not scraped yet" until the next scrape. target.last_scraped_at = target.last_ok_at = target.last_error = None target.last_kept = target.last_dropped = target.last_capped = 0 if old_name != target.name: # Its history is stored under its name, and its labelled series # under ":…": move them with the rename. for model in (PluginMetric, PluginMetricLatest): await db.execute(update(model).where( model.source_module == SOURCE, model.resource_name == old_name ).values(resource_name=target.name)) # ":labels" keeps its ":labels" (substr is 1-based). await db.execute(update(model).where( model.source_module == SOURCE, model.resource_name.startswith(old_name + ":", autoescape=True), ).values(resource_name=target.name + func.substr(model.resource_name, len(old_name) + 1))) from . import scheduler scheduler.RATES.forget(target_id) detail = {"name": values["name"]} if old_name != values["name"]: detail["old_name"] = old_name await log_audit(current_app, *_actor(), "prometheus.target_updated", entity_type="prometheus_target", entity_id=target_id, detail=detail) return redirect(f"/plugins/prometheus/targets/{target_id}") @prometheus_bp.post("/targets//delete") @require_role(UserRole.admin) async def delete_target(target_id: str): async with current_app.db_sessionmaker() as db: async with db.begin(): target = await _target_or_404(db, target_id) name = target.name await db.delete(target) # Its newest values go with it, so nothing reads it as a target # that stopped answering. Its history ages out with retention. await db.execute(delete(PluginMetricLatest).where( PluginMetricLatest.source_module == SOURCE, (PluginMetricLatest.resource_name == name) | PluginMetricLatest.resource_name.startswith(name + ":", autoescape=True))) from . import scheduler scheduler.RATES.forget(target_id) await log_audit(current_app, *_actor(), "prometheus.target_deleted", entity_type="prometheus_target", entity_id=target_id, detail={"name": name}) return redirect("/plugins/prometheus/") TEST_FAMILIES_SHOWN = 40 @prometheus_bp.post("/targets/test") @require_role(UserRole.admin) async def test_target(): """Scrape the (unsaved) form's address once and say what would be stored. An admin's explicit action, so it may contact the endpoint.""" form = await request.form existing = None if form.get("target_id"): async with current_app.db_sessionmaker() as db: existing = await db.get(PrometheusTarget, form["target_id"]) url = (form.get("url") or "").strip() token = (form.get("token") or "").strip() if not token and existing is not None and existing.token and not form.get("clear_token"): token = decrypt_secret(existing.token, context=f"prometheus target {existing.name} token") found: dict = {} if not url.startswith(("http://", "https://")): found["error"] = "Enter the address first: http://… or https://…" else: try: families = await scrape(url, token, bool(form.get("verify_tls"))) except ScrapeError as exc: found["error"] = str(exc) else: try: cap = max(1, int(form.get("series_cap") or 1000)) except ValueError: cap = 1000 keep, drop = patterns(form.get("keep") or ""), patterns(form.get("drop") or "") mapped = map_families("t", families, keep, drop, cap) kept_names = {} for s in mapped.series: kept_names[s.metric] = kept_names.get(s.metric, 0) + 1 found = {"families": len(families), "kept": len(mapped.series), "dropped": mapped.dropped, "capped": mapped.capped, "names": sorted(kept_names.items())[:TEST_FAMILIES_SHOWN], "more": max(0, len(kept_names) - TEST_FAMILIES_SHOWN)} return await render_template("prometheus/_test.html", found=found, url=url) # ── The target page and one series ─────────────────────────────────────────── def group_series(target_name: str, latest: list) -> list[dict]: """The target's stored series grouped by metric, each with its rows: the labels that tell them apart, and the newest value. A state set is folded to one row per thing it describes, naming the current state (`states` set on the group).""" groups: dict[str, list] = {} for row in latest: if row.resource_name == target_name and row.metric_name in OWN_METRICS: continue labels = row.resource_name[len(target_name) + 1:] if row.resource_name != target_name else "" groups.setdefault(row.metric_name, []).append({ "labels": labels, "resource": row.resource_name, "at": row.recorded_at, "value": row.value, **shown_value(row.metric_name, row.value)}) out = [] for metric, rows in sorted(groups.items()): folded = fold_states(metric, rows) if folded is not None: out.append({"metric": metric, "states": True, "rows": [ {"labels": f["labels"], "resource": f["resource"], "moment": None, "text": f["state"] or "none", "states": f["states"]} for f in folded]}) else: out.append({"metric": metric, "states": False, "rows": sorted(rows, key=lambda r: r["labels"])}) return out @prometheus_bp.get("/targets/") @require_role(UserRole.viewer) async def target_detail(target_id: str): q = (request.args.get("q") or "").strip() async with current_app.db_sessionmaker() as db: target = await _target_or_404(db, target_id) latest = await latest_values(db, SOURCE, target.name, include_children=True) own = {r.metric_name: r.value for r in latest if r.resource_name == target.name and r.metric_name in OWN_METRICS} groups = group_series(target.name, latest) total = sum(len(g["rows"]) for g in groups) if q: groups = [g for g in groups if q.lower() in g["metric"].lower()] now = datetime.now(timezone.utc) return await render_template("prometheus/target.html", target=view(target, own, now), groups=groups, total=total, q=q) RANGES = (1, 6, 24, 168) @prometheus_bp.get("/targets//series") @require_role(UserRole.viewer) async def series_detail(target_id: str): resource = request.args.get("resource") or "" metric = request.args.get("metric") or "" hours = request.args.get("hours", type=int, default=24) if hours not in RANGES: hours = 24 async with current_app.db_sessionmaker() as db: target = await _target_or_404(db, target_id) if resource != target.name and not resource.startswith(target.name + ":"): abort(404) now_row = await db.get(PluginMetricLatest, (SOURCE, resource, metric)) series = await points(db, SOURCE, resource, metric, datetime.now(timezone.utc) - timedelta(hours=hours)) labels = resource[len(target.name) + 1:] if resource != target.name else "" stamp_axis = metric.endswith("_timestamp_seconds") return await render_template( "prometheus/series.html", target=target, resource=resource, metric=metric, labels=labels, now=shown_value(metric, now_row.value if now_row else None), hours=hours, ranges=RANGES, points_json=json.dumps([(t.isoformat(), v) for t, v in series]), stamp_axis=stamp_axis) # ── The dashboard widget ───────────────────────────────────────────────────── SPARK_HOURS = 24 def spark_heights(values: list[float]) -> list[float]: """Bar heights in % of the series' own peak over the window (System 154's ruling on small history bars); a flat zero draws flat.""" peak = max((abs(v) for v in values), default=0.0) 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.""" target_id = request.args.get("target") or "" key = request.args.get("template") or "" now = datetime.now(timezone.utc) async with current_app.db_sessionmaker() as db: target = await db.get(PrometheusTarget, target_id) if target_id else None entry = await library.find(db, key) if key else None problem = ("This widget has no target; remove it and add it again with one." if not target_id else "The target this widget showed has been deleted." if target is None else "This widget has no template; remove it and add it again with one." if not key else "The template this widget used is gone." if entry is None else f"The template “{entry.name}” has a problem: {entry.problems[0]}" if entry.template is None else "") if problem: return await render_template("prometheus/widget.html", problem=problem, target=None, view=None) 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) 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)