feat(prometheus): fold state sets on the target page; forget series an endpoint drops (#5689)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 1m20s
CI / unit (push) Successful in 1m28s
CI / publish (push) Successful in 20s

#5689 "Prometheus target page: fold state sets, show epoch times, size
bytes", step 1 of milestone 508 "Prometheus widget templates".

- A state set (name ending in _state, a state label, every series 0 or 1;
  or the OpenMetrics form, the label named after the metric) reads as one
  row per thing it describes, naming the current state, the others on
  hover. fabledarchives_jobs{state} is a count and stays as it is.
- label_pairs / state_label / fold_states in service.py, for the widget
  templates to reuse.
- After a good scrape, series it no longer has lose their newest value
  (forget_gone): Archives' old build_info row read as current beside the
  new one. History stays; a failed scrape forgets nothing; alert rules keep
  their own last-sample time.
- Epoch times and _bytes sizes were already read by shown_value /
  metric_value.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 15:11:24 -04:00
co-authored by Claude Opus 5.5
parent 4c546a9c4d
commit ec3e5e7e0d
8 changed files with 176 additions and 21 deletions
+9 -4
View File
@@ -60,7 +60,11 @@ def test_series_are_grouped_by_metric_with_their_labels_and_units():
assert [g["metric"] for g in groups] == ["fa_disk_free_bytes", "fa_last_backup_timestamp_seconds", "fa_state"]
assert groups[0]["rows"][0]["labels"] == "" and groups[0]["rows"][0]["text"] == "2.0 GiB"
assert groups[1]["rows"][0]["moment"] == datetime.fromtimestamp(stamp, timezone.utc)
assert [r["labels"] for r in groups[2]["rows"]] == ["target=S,state=fresh", "target=S,state=late"]
# A state set reads as one row naming the current state.
assert groups[2]["states"] and not groups[0]["states"]
assert [(r["labels"], r["text"], r["states"]) for r in groups[2]["rows"]] == [
("target=S", "fresh", ["fresh", "late"])]
assert groups[2]["rows"][0]["resource"] == "A:target=S,state=fresh"
assert shown_value("x_timestamp_seconds", 0) == {"text": "never", "moment": None}
assert shown_value("jobs_per_sec", 0.25)["text"] == "0.25/s"
assert shown_value("job_seconds", 90)["text"] == "1m 30s"
@@ -69,9 +73,9 @@ def test_series_are_grouped_by_metric_with_their_labels_and_units():
async def test_the_target_page_leads_with_its_state_then_its_series(app):
from quart import session
groups = [{"metric": "fa_state", "rows": [
{"labels": "target=S,state=late", "resource": "archives:target=S,state=late", "at": None,
"text": "0", "moment": None}]}]
groups = [{"metric": "fa_state", "states": True, "rows": [
{"labels": "target=S", "resource": "archives:target=S,state=late", "moment": None,
"text": "late", "states": ["fresh", "late"]}]}]
async with app.test_request_context("/plugins/prometheus/targets/id-archives"):
session["user_role"] = "viewer"
page = await render_plugin_template(app, "prometheus", "prometheus/target.html",
@@ -81,6 +85,7 @@ async def test_the_target_page_leads_with_its_state_then_its_series(app):
assert page.index("Answering") < page.index("Series stored") < page.index("fa_state")
assert "3 series weren't stored" in page and "Keeping fabledarchives_*" in page
assert "resource=archives%3Atarget%3DS%2Cstate%3Dlate" in page and "metric=fa_state" in page
assert 'title="One of: fresh, late">late</a>' in page and "a state set" in page
assert 'href="/plugins/prometheus/">Prometheus</a>' in page # its breadcrumb
assert "/edit" not in page
+34 -2
View File
@@ -8,8 +8,8 @@ import pytest
from plugins.prometheus.models import PrometheusTarget
from plugins.prometheus.scheduler import is_due
from plugins.prometheus.scraper import ScrapeError, scrape
from plugins.prometheus.service import (FormError, map_families, parse_form, patterns,
rate_name, resource_for, target_state, wanted)
from plugins.prometheus.service import (FormError, fold_states, label_pairs, map_families, parse_form,
patterns, rate_name, resource_for, target_state, wanted)
from steward.core import prometheus
from steward.core.counter_rates import CounterRates
@@ -75,6 +75,38 @@ def test_names():
assert patterns("a_*, b_*\n\n a_* \nc") == ["a_*", "b_*", "c"]
def test_labels_read_back_as_stored_even_with_a_comma_in_a_value():
labels = resource_for("A", (("job", "Nightly"), ("schedule", "0 3 * * 1,3,5"), ("state", "ok")))
assert label_pairs(labels[2:]) == [("job", "Nightly"), ("schedule", "0 3 * * 1,3,5"), ("state", "ok")]
assert label_pairs("") == []
def _rows(metric_rows):
return [{"labels": labels, "value": value, "resource": f"A:{labels}"} for labels, value in metric_rows]
def test_a_state_set_folds_to_its_current_state_per_thing():
rows = _rows([("target=S,state=late", 0), ("target=S,state=fresh", 1), ("target=S,state=missing", 0),
("target=T,state=fresh", 0), ("target=T,state=late", 1), ("target=T,state=missing", 1),
("target=U,state=fresh", 0), ("target=U,state=late", 0)])
folded = fold_states("fa_backup_target_state", rows)
assert [(f["labels"], f["state"]) for f in folded] == [
("target=S", "fresh"), ("target=T", "late, missing"), ("target=U", "")]
assert folded[0]["states"] == ["fresh", "late", "missing"]
assert folded[0]["resource"] == "A:target=S,state=fresh" # the current one's chart
assert folded[2]["resource"] == "A:target=U,state=fresh" # none current: the first
# OpenMetrics names a stateset's label after the metric itself.
assert fold_states("fa_mode", _rows([("fa_mode=a", 1), ("fa_mode=b", 0)]))[0]["state"] == "a"
def test_only_a_state_set_is_folded():
# A count split by a state label reads 0 or 1 often enough; its name says it is a count.
assert fold_states("fa_jobs", _rows([("kind=scan,state=waiting", 1), ("kind=scan,state=running", 0)])) is None
assert fold_states("fa_state", _rows([("state=a", 1), ("state=b", 2)])) is None # not 0/1
assert fold_states("fa_state", _rows([("state=a", 1), ("target=S", 0)])) is None # a row without one
assert fold_states("fa_state", []) is None
def test_a_counter_becomes_a_rate_and_a_reset_is_skipped():
rates = CounterRates()
t0 = datetime(2026, 10, 12, 12, 0, tzinfo=timezone.utc)