diff --git a/plugins/prometheus/routes.py b/plugins/prometheus/routes.py index 0d79c9a..d5db334 100644 --- a/plugins/prometheus/routes.py +++ b/plugins/prometheus/routes.py @@ -20,7 +20,7 @@ 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, +from .service import (OWN_METRICS, SOURCE, FormError, fold_states, form_values, is_moment, map_families, parse_form, patterns, shown_value, target_state) prometheus_bp = Blueprint("prometheus", __name__, template_folder="templates") @@ -325,7 +325,7 @@ async def series_detail(target_id: str): 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") + stamp_axis = is_moment(metric) 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, diff --git a/plugins/prometheus/service.py b/plugins/prometheus/service.py index 7f39569..9b33acf 100644 --- a/plugins/prometheus/service.py +++ b/plugins/prometheus/service.py @@ -122,13 +122,23 @@ def fold_states(metric: str, rows: list[dict]) -> list[dict] | None: return folded +# A series named for a moment holds Unix seconds, not a span of time: the +# convention's *_timestamp_seconds, and the *_time_seconds some exporters use +# (Home Assistant's homeassistant_last_updated_time_seconds). +MOMENT_SUFFIXES = ("_timestamp_seconds", "_time_seconds") + + +def is_moment(metric: str) -> bool: + return metric.endswith(MOMENT_SUFFIXES) + + def shown_value(metric: str, value: float | None) -> dict: """A series' value in its unit, from the Prometheus naming conventions. - A *_timestamp_seconds is a moment: it comes back as `moment` for the page - to show as "3m ago" in the reader's zone; 0 there means "never".""" + A moment (is_moment) comes back as `moment` for the page to show as + "3m ago" in the reader's zone; 0 there means "never".""" if value is None: return {"text": "—", "moment": None} - if metric.endswith("_timestamp_seconds"): + if is_moment(metric): if value <= 0: return {"text": "never", "moment": None} return {"text": "", "moment": datetime.fromtimestamp(value, timezone.utc)} @@ -184,11 +194,12 @@ def map_families(target: str, families: dict[str, prometheus.Family], keep: list # ── The form ───────────────────────────────────────────────────────────────── def patterns(text: str) -> list[str]: - """Name patterns from a textarea: one per line or comma-separated.""" + """Name patterns from a textarea: one per line, or separated by commas or + spaces. A metric name never holds whitespace, so a paste that turned line + breaks into spaces still reads as the patterns it was.""" out: list[str] = [] - for line in (text or "").replace(",", "\n").splitlines(): - p = line.strip() - if p and p not in out: + for p in (text or "").replace(",", " ").split(): + if p not in out: out.append(p) return out diff --git a/plugins/prometheus/templates/prometheus/series.html b/plugins/prometheus/templates/prometheus/series.html index 3d99b25..0885ffd 100644 --- a/plugins/prometheus/templates/prometheus/series.html +++ b/plugins/prometheus/templates/prometheus/series.html @@ -1,6 +1,7 @@ {# plugins/prometheus/templates/prometheus/series.html — one stored series of a target (milestone 506): its newest value and a chart over a chosen - range. A *_timestamp_seconds series charts as moments, not numbers. #} + range. A moment series (*_timestamp_seconds, *_time_seconds) charts as + moments, not numbers. #} {% extends "base.html" %} {% from "_macros.html" import crumbs, page_header %} {% block title %}{{ metric }} — {{ target.name }} — Steward{% endblock %} diff --git a/tests/plugins/prometheus/test_pages.py b/tests/plugins/prometheus/test_pages.py index c4267e5..c90a672 100644 --- a/tests/plugins/prometheus/test_pages.py +++ b/tests/plugins/prometheus/test_pages.py @@ -72,6 +72,9 @@ def test_series_are_grouped_by_metric_with_their_labels_and_units(): ("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} + # Home Assistant names its Unix times *_time_seconds (#6423): a moment, not "20751d 21h". + assert shown_value("homeassistant_last_updated_time_seconds", 1792964340.0) == { + "text": "", "moment": datetime.fromtimestamp(1792964340.0, timezone.utc)} assert shown_value("jobs_per_sec", 0.25)["text"] == "0.25/s" assert shown_value("job_seconds", 90)["text"] == "1m 30s" assert shown_value("anything", None)["text"] == "—" diff --git a/tests/plugins/prometheus/test_service.py b/tests/plugins/prometheus/test_service.py index dda8e14..8467c71 100644 --- a/tests/plugins/prometheus/test_service.py +++ b/tests/plugins/prometheus/test_service.py @@ -73,6 +73,10 @@ def test_names(): assert resource_for("T", ()) == "T" assert resource_for("T", (("a", "1"), ("b", "x"))) == "T:a=1,b=x" assert patterns("a_*, b_*\n\n a_* \nc") == ["a_*", "b_*", "c"] + # A paste that turned its line breaks into spaces (#6423): no metric name + # holds whitespace, so each word is a pattern. + assert patterns("ha_last_updated_time_seconds ha_entity_available\tha_entity_info") == [ + "ha_last_updated_time_seconds", "ha_entity_available", "ha_entity_info"] def test_labels_read_back_as_stored_even_with_a_comma_in_a_value():