feat(prometheus): the Prometheus dashboard widget, target + template (#5691)
#5691 "Generic Prometheus dashboard widget: pick a target and a template", step 3 of milestone 508 "Prometheus widget templates". - /plugins/prometheus/widget draws one target with one template in widget shape: the verdict (led by "isn't answering" when the target is down), then each block with problems first, figures beside a share bar or the last 24h of bars, and the healthy rest folding to "+N more healthy" past six. A missing target or template, or one that no longer checks, says so. - library.py: bundled templates from plugins/prometheus/widgets/*.yaml, the picker's target and template lists, and the default title. - widgets/fabled_archives.yaml: the first bundled template, against the series Archives reports (refined in #5693). - Dashboard: a widget setting may take `options_from` (read when the picker opens) with an `empty` sentence; `default_title`; a Details link may name settings ("/plugins/prometheus/targets/{target}"). Prometheus is grouped with the integrations. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
"""The Prometheus dashboard widget (#5691): one target drawn by one template,
|
||||
in widget shape, and the dashboard pieces it needs (settings read when the
|
||||
picker opens, a Details link naming the target)."""
|
||||
import asyncio
|
||||
from datetime import timedelta
|
||||
|
||||
from plugins.prometheus import library
|
||||
from plugins.prometheus.routes import spark_heights
|
||||
from plugins.prometheus.template_format import check
|
||||
from plugins.prometheus.template_view import render
|
||||
from steward.dashboard.routes import detail_url, with_options
|
||||
from tests._templates import render_plugin_template
|
||||
from tests.plugins.prometheus.test_templates import NOW, SERIES, TEMPLATE, T, latest, states
|
||||
from tests.test_style_tokens import hex_colours
|
||||
|
||||
|
||||
def test_every_bundled_template_is_a_template():
|
||||
entries = library.bundled()
|
||||
assert [e.key for e in entries] == ["bundled:fabled_archives"]
|
||||
for e in entries:
|
||||
assert e.template is not None, [str(p) for p in e.problems]
|
||||
assert entries[0].name == "Fabled Archives" and entries[0].template.expects == "fabledarchives_"
|
||||
|
||||
|
||||
def test_settings_read_when_the_picker_opens():
|
||||
async def targets(db):
|
||||
return [{"value": "t1", "label": "Archives"}]
|
||||
|
||||
async def none(db):
|
||||
return []
|
||||
|
||||
widget = {"key": "w", "params": [
|
||||
{"key": "target", "type": "select", "default": "", "options_from": targets},
|
||||
{"key": "template", "type": "select", "default": "", "options_from": none, "empty": "No templates yet."},
|
||||
{"key": "hours", "type": "select", "default": 6, "options": [{"value": 6, "label": "6h"}]}]}
|
||||
filled = asyncio.run(with_options(None, widget))
|
||||
assert filled["params"][0]["options"] == [{"value": "t1", "label": "Archives"}]
|
||||
assert filled["params"][1]["options"] == []
|
||||
assert filled["params"][2] is widget["params"][2]
|
||||
assert "options" not in widget["params"][0] # the registry isn't changed
|
||||
plain = {"key": "x", "params": []}
|
||||
assert asyncio.run(with_options(None, plain)) is plain
|
||||
|
||||
|
||||
def test_details_names_the_target_or_falls_back_to_the_section():
|
||||
defn = {"detail_url": "/plugins/prometheus/targets/{target}"}
|
||||
assert detail_url(defn, {"target": "a b/c", "template": "x"}) == "/plugins/prometheus/targets/a%20b%2Fc"
|
||||
assert detail_url(defn, {}) == "/plugins/prometheus/"
|
||||
assert detail_url({"detail_url": "/monitors/"}, {"limit": 5}) == "/monitors/"
|
||||
|
||||
|
||||
def test_bars_scale_to_their_own_peak():
|
||||
assert spark_heights([1.0, 2.0, 4.0]) == [25.0, 50.0, 100.0]
|
||||
assert spark_heights([0.0, 0.0]) == [0.0, 0.0] and spark_heights([]) == []
|
||||
|
||||
|
||||
def _target(reach="up", ok_minutes=1):
|
||||
at = NOW - timedelta(minutes=ok_minutes)
|
||||
return {"id": "t1", "name": T, "reach": reach, "says": "", "ok_at": at, "scraped_at": NOW}
|
||||
|
||||
|
||||
async def _page(app, user=True, **ctx):
|
||||
from quart import session
|
||||
async with app.test_request_context("/plugins/prometheus/widget"):
|
||||
if user:
|
||||
session["user_id"] = "u1"
|
||||
session["user_role"] = "viewer"
|
||||
ctx.setdefault("spark_hours", 24)
|
||||
page = await render_plugin_template(app, "prometheus", "prometheus/widget.html", **ctx)
|
||||
return " ".join(page.split())
|
||||
|
||||
|
||||
async def test_the_widget_leads_with_the_verdict_then_problems_first(app):
|
||||
view = render(check(TEMPLATE), T, latest(SERIES), NOW,
|
||||
history={(T, "fa_disk_free_bytes"): [50.0, 100.0]})
|
||||
page = await _page(app, problem="", view=view, target=_target())
|
||||
assert page.index("3 need a look. Backup targets: Photos is late.") < page.index("Backup targets</div>")
|
||||
assert 'class="w-verdict tone-warn"' in page
|
||||
# Photos (late) before Steward (fresh) before Old (disabled, quiet).
|
||||
targets = page[page.index("Backup targets</div>"):page.index("Storage</div>")]
|
||||
assert targets.index("Photos") < targets.index("Steward") < targets.index("Old")
|
||||
assert '<span class="w-sub tone-warn">is late</span>' in page
|
||||
assert "<th>Now</th>" in page and '<th class="w-wide-only">Last backup</th>' in page
|
||||
assert "16.2 TiB of 41.8 TiB" in page and 'class="meter-fill meter-ok" style="width:38.7%"' in page
|
||||
assert '<span class="w-bars w-wide-only"' in page and 'style="height:50.0%"' in page
|
||||
assert "<th>files</th><th>trash</th>" in page and "437.7 GiB" in page
|
||||
assert "version 2026.10.12.1717 · commit 3605d0a" in page
|
||||
assert '<a href="/plugins/prometheus/targets/t1">Archives</a>' in page
|
||||
assert not hex_colours(page), hex_colours(page)
|
||||
|
||||
|
||||
async def test_the_healthy_rest_folds_after_six(app):
|
||||
many = {**SERIES}
|
||||
for i in range(9):
|
||||
many.update(states(f"target=T{i},target_id=x{i}", "fresh", ["fresh", "late"]))
|
||||
view = render(check(TEMPLATE), T, latest(many), NOW)
|
||||
page = await _page(app, problem="", view=view, target=_target())
|
||||
# 1 late, 10 fresh, 1 quiet in Backup targets: the late one, six of the eleven others.
|
||||
assert "+5 more healthy" in page
|
||||
|
||||
|
||||
async def test_a_target_not_answering_says_so_first(app):
|
||||
view = render(check(TEMPLATE), T, latest(SERIES), NOW)
|
||||
page = await _page(app, problem="", view=view, target=_target("down", ok_minutes=90))
|
||||
assert '<div class="w-verdict tone-crit">Archives isn\'t answering; these values are from' in page
|
||||
assert page.index("isn't answering") < page.index("3 need a look")
|
||||
|
||||
|
||||
async def test_a_widget_that_cant_draw_says_why(app):
|
||||
page = await _page(app, problem="The target this widget showed has been deleted.", view=None, target=None)
|
||||
assert '<div class="w-verdict tone-warn">The target this widget showed has been deleted.</div>' in page
|
||||
|
||||
|
||||
async def test_a_shared_dashboard_gets_no_links(app):
|
||||
view = render(check(TEMPLATE), T, latest(SERIES), NOW)
|
||||
page = await _page(app, user=False, problem="", view=view, target=_target())
|
||||
assert "/plugins/prometheus/targets/t1" not in page and "Archives, scraped" in page
|
||||
|
||||
|
||||
def test_the_widget_names_itself_after_its_template():
|
||||
assert asyncio.run(library.default_title(None, {"template": "bundled:fabled_archives"})) == "Fabled Archives"
|
||||
assert asyncio.run(library.default_title(None, {"template": "bundled:nope"})) is None
|
||||
Reference in New Issue
Block a user