Files
FabledSteward/plugins/prometheus/template_view.py
T
bvandeusenandClaude Opus 5.5 4d95c5d0c2
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m17s
CI / unit (push) Successful in 1m27s
CI / publish (push) Successful in 21s
feat(prometheus): widget template format, checker and renderer (#5690)
#5690 "Widget template format: YAML schema, validator and renderer", step 2
of milestone 508 "Prometheus widget templates".

- template_format.check(text) -> Template, or TemplateError with every
  problem, each with its line and what to change. Blocks: states, values
  (of -> share and bar, thresholds as numbers or shares, sparkline),
  times (warn_after / crit_after), table (two labels pivoted), info.
  Anchors/aliases refused, 64 KB cap, YAML's on/off/yes/no flagged.
- template_view.render(template, target, latest, now, history) -> View:
  a verdict naming the worst problem, then each block with its problems
  first (crit, warn, ok, then quiet states). Missing series show as "—"
  and say so; a template whose `expects` prefix the target lacks says so.
- shown_value moves to service.py so the renderer can use it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:34:15 -04:00

303 lines
12 KiB
Python

"""A widget template drawn from a target's newest values: what the
Prometheus dashboard widget shows. Rows in, a view out; nothing is fetched
here, so the widget route reads the database and a test passes rows.
The view follows the widget shape (System 154): a verdict naming the worst
thing, then each block with its problems first and the healthy rest after.
Every row carries a tone: crit, warn, ok, or dim for a state the template
calls quiet.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from steward.core.formatting import human_duration
from .service import fold_states, label_pairs, shown_value
from .template_format import UNITS, Block, Limit, Template
TONE_ORDER = {"crit": 0, "warn": 1, "ok": 2, "dim": 3}
@dataclass
class Sample:
resource: str
metric: str
labels: str # as stored: "target=S,state=late"
pairs: dict[str, str]
value: float
at: datetime | None = None
@dataclass
class Row:
name: str
tone: str = "ok"
why: str = "" # what is wrong, in words: "is late", "below 10%"
text: str = "" # the value as shown, "—" when there is none
moment: datetime | None = None
bar: float | None = None # a share of `of`, 0 to 1
cells: list[dict] = field(default_factory=list)
resource: str = ""
metric: str = ""
history: list[float] | None = None
@dataclass
class BlockView:
kind: str
title: str
tone: str = "ok"
rows: list[Row] = field(default_factory=list)
columns: list[str] = field(default_factory=list)
note: str = "" # the block couldn't be drawn: no series, or not a state set
@dataclass
class View:
tone: str
verdict: str
blocks: list[BlockView]
wants: list[tuple[str, str]] = field(default_factory=list) # (resource, metric) for sparklines
note: str = ""
def samples(target: str, latest) -> dict[str, list[Sample]]:
"""A target's newest values by metric. `latest` is PluginMetricLatest
rows (resource_name, metric_name, value, recorded_at) for the target and
its "<target>:<labels>" children."""
out: dict[str, list[Sample]] = {}
for row in latest:
if row.resource_name == target:
labels = ""
elif row.resource_name.startswith(target + ":"):
labels = row.resource_name[len(target) + 1:]
else:
continue
out.setdefault(row.metric_name, []).append(Sample(
row.resource_name, row.metric_name, labels, dict(label_pairs(labels)), row.value,
getattr(row, "recorded_at", None)))
return out
def pick(index: dict[str, list[Sample]], metric: str, where: dict[str, str]) -> list[Sample]:
return [s for s in index.get(metric, []) if all(s.pairs.get(k) == v for k, v in where.items())]
def shown(metric: str, unit: str, value: float | None) -> dict:
"""{text, moment} for a value, in `unit` or else as its name says. A
count reads with thousands separated: 93,310."""
if unit == "count" and value is not None:
return {"text": f"{value:,.0f}" if value == int(value) else f"{value:,.2f}", "moment": None}
return shown_value(f"x{UNITS[unit]}" if unit else metric, value)
def _worst(tones) -> str:
tones = [t for t in tones if t != "dim"]
return min(tones, key=TONE_ORDER.__getitem__) if tones else "ok"
def _ordered(rows: list[Row]) -> list[Row]:
return sorted(rows, key=lambda r: (TONE_ORDER[r.tone], r.name.lower()))
# ── Blocks ───────────────────────────────────────────────────────────────────
def _states(block: Block, index, now) -> BlockView:
spec = block.spec
view = BlockView("states", block.title, columns=[c["label"] for c in spec["columns"]])
found = pick(index, spec["metric"], spec["where"])
folded = fold_states(spec["metric"], [{"labels": s.labels, "value": s.value, "resource": s.resource}
for s in found])
if not found:
view.note = f"Nothing reported for {spec['metric']}."
elif folded is None:
view.note = (f"{spec['metric']} isn't a state set: it should have one series per state, "
"the current one 1 and the rest 0.")
for f in folded or []:
pairs = dict(label_pairs(f["labels"]))
name = pairs.get(spec["name"], "") if spec["name"] else ""
current = [s for s in f["state"].split(", ") if s]
tones = []
for state in current:
if state in spec["healthy"]:
tones.append("ok")
elif state in spec["quiet"]:
tones.append("dim")
elif state in spec["warning"]:
tones.append("warn")
else:
tones.append("crit")
tone = (min(tones, key=TONE_ORDER.__getitem__) if tones else "warn")
row = Row(name=name or f["labels"] or block.title, tone=tone, text=f["state"] or "—",
why="" if tone in ("ok", "dim") else (f"is {f['state']}" if current else "reports no state"),
resource=f["resource"], metric=spec["metric"])
for col in spec["columns"]:
match = [s for s in index.get(col["metric"], []) if s.labels == f["labels"]]
if not match and name:
match = [s for s in index.get(col["metric"], []) if s.pairs.get(spec["name"]) == name]
row.cells.append(shown(col["metric"], col["unit"], match[0].value if len(match) == 1 else None))
view.rows.append(row)
view.rows = _ordered(view.rows)
return view
def _limit_text(lim: Limit, metric: str, unit: str) -> str:
return f"{lim.value:g}%" if lim.share else (shown(metric, unit, lim.value)["text"] or f"{lim.value:g}")
def _judge(item: dict, value: float, of: float | None) -> tuple[str, str]:
for level in ("crit", "warn"):
for side in ("above", "below"):
lim = item[f"{level}_{side}"]
if lim is None:
continue
if lim.share:
if not of:
continue
x = value / of * 100
else:
x = value
if (x > lim.value) if side == "above" else (x < lim.value):
return level, f"{side} {_limit_text(lim, item['metric'], item['unit'])}"
return "ok", ""
def _rows_for(item: dict, index) -> list[tuple[str, Sample | None, str]]:
"""The series an item shows: one per value of its `name` label, or the one
series it names. (name, sample, problem)."""
found = pick(index, item["metric"], item["where"])
if item["name"]:
named = [(s.pairs.get(item["name"], "") or item["label"], s, "") for s in found]
return named or [(item["label"], None, "not reported")]
if len(found) == 1:
return [(item["label"], found[0], "")]
if not found:
return [(item["label"], None, "not reported")]
return [(item["label"], None, f"{len(found)} series match; narrow it with `where` or `name`")]
def _values(block: Block, index, now) -> tuple[BlockView, list]:
view, wants = BlockView("values", block.title), []
for item in block.spec["items"]:
for name, s, problem in _rows_for(item, index):
if s is None:
view.rows.append(Row(name=name, tone="warn", why=problem, text="—", metric=item["metric"]))
continue
row = Row(name=name, resource=s.resource, metric=s.metric, **shown(s.metric, item["unit"], s.value))
of = None
if item["of"]:
whole = [o for o in index.get(item["of"], []) if o.labels == s.labels]
if len(whole) == 1 and whole[0].value:
of = whole[0].value
row.bar = max(0.0, min(1.0, s.value / of))
row.text = f"{row.text} of {shown(item['of'], item['unit'], of)['text']}"
row.tone, row.why = _judge(item, s.value, of)
if item["sparkline"]:
wants.append((s.resource, s.metric))
view.rows.append(row)
view.rows = _ordered(view.rows)
return view, wants
def _times(block: Block, index, now) -> BlockView:
view = BlockView("times", block.title)
for item in block.spec["items"]:
for name, s, problem in _rows_for(item, index):
if s is None:
view.rows.append(Row(name=name, tone="warn", why=problem, text="—", metric=item["metric"]))
continue
got = shown_value("x_timestamp_seconds", s.value)
row = Row(name=name, resource=s.resource, metric=s.metric, **got)
if row.moment is None:
if item["warn_after"] or item["crit_after"]:
row.tone, row.why = "warn", "never"
else:
age = (now - row.moment).total_seconds()
for level in ("crit", "warn"):
after = item[f"{level}_after"]
if after and age > after:
row.tone, row.why = level, f"over {human_duration(after)} ago"
break
view.rows.append(row)
view.rows = _ordered(view.rows)
return view
def _table(block: Block, index, now) -> BlockView:
spec = block.spec
found = pick(index, spec["metric"], spec["where"])
rows: dict[str, dict[str, float]] = {}
columns: list[str] = []
for s in found:
r, c = s.pairs.get(spec["rows"]), s.pairs.get(spec["columns"])
if r is None or c is None:
continue
rows.setdefault(r, {})[c] = s.value
if c not in columns:
columns.append(c)
columns.sort()
view = BlockView("table", block.title, columns=columns)
if not rows:
view.note = (f"Nothing reported for {spec['metric']} with labels "
f"{spec['rows']} and {spec['columns']}.")
for name in sorted(rows, key=str.lower):
view.rows.append(Row(name=name, cells=[shown(spec["metric"], spec["unit"], rows[name].get(c))
for c in columns]))
return view
def _info(block: Block, index, now) -> BlockView:
spec = block.spec
view = BlockView("info", block.title)
found = sorted(pick(index, spec["metric"], spec["where"]), key=lambda s: s.labels)
if not found:
view.note = f"Nothing reported for {spec['metric']}."
for s in found:
keys = spec["labels"] or list(s.pairs)
view.rows.append(Row(name=" · ".join(f"{k} {s.pairs[k]}" for k in keys if s.pairs.get(k)),
resource=s.resource, metric=s.metric))
return view
_DRAW = {"states": _states, "times": _times, "table": _table, "info": _info}
def render(template: Template, target: str, latest, now: datetime,
history: dict[tuple[str, str], list[float]] | None = None) -> View:
"""The view of `template` over a target's newest values. `history` gives
the sparkline points the first pass asked for in `wants`."""
index = samples(target, latest)
blocks, wants = [], []
for block in template.blocks:
if block.kind == "values":
view, want = _values(block, index, now)
wants += want
else:
view = _DRAW[block.kind](block, index, now)
if history:
for row in view.rows:
row.history = history.get((row.resource, row.metric))
view.tone = "warn" if view.note else _worst(r.tone for r in view.rows)
blocks.append(view)
note = ""
if template.expects and not any(m.startswith(template.expects) for m in index):
note = (f"{target} reports no {template.expects}… metrics: is “{template.name}” "
"the template for it, or is the target filtering them out?")
problems = [(b, r) for b in blocks for r in b.rows if r.tone in ("crit", "warn")]
problems.sort(key=lambda p: TONE_ORDER[p[1].tone])
empty = [b for b in blocks if b.note]
tone = _worst([b.tone for b in blocks] + (["warn"] if note else []))
if note:
verdict = note
elif len(problems) + len(empty) == 0:
verdict = "All well."
else:
first = (f"{problems[0][0].title}: {problems[0][1].name} {problems[0][1].why}." if problems
else f"{empty[0].title}: {empty[0].note}")
count = len(problems) + len(empty)
verdict = first if count == 1 else f"{count} need a look. {first}"
return View(tone=tone, verdict=verdict, blocks=blocks, wants=wants, note=note)