feat(prometheus-direct-charting): slice 1 — prom range query + chart rebrand

Add PrometheusWidgetSource._fetch_chart hitting /api/v1/query_range directly
(SC-101..104). New shared helpers in widgets/prometheus_range.py:
step_for_window (window preset -> step, ~200pts) and normalize_prometheus_matrix
(extracted label/dedup rule, retargeted at Prom matrix, robust to malformed
data). Chart widget kind moved grafana->prometheus in both registries;
GrafanaChartWidget renamed -> PrometheusChartWidget (git mv, recharts body
preserved). Grafana binding/service untouched (removed in slice 3).

Backend: 298 pytest pass, ruff clean. Frontend: 128 vitest pass, build+lint green.
This commit is contained in:
Developer
2026-07-08 21:51:49 +00:00
parent d906b0392b
commit 5dad98231f
12 changed files with 383 additions and 56 deletions
@@ -25,6 +25,13 @@ class PrometheusMetricWidgetConfig(WidgetConfigBase):
promql: str promql: str
class PrometheusChartWidgetConfig(WidgetConfigBase):
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="prometheus", service_type="prometheus",
name="Prometheus", name="Prometheus",
@@ -42,5 +49,13 @@ DEFINITION = ServiceDefinition(
default_config={"promql": ""}, default_config={"promql": ""},
refresh_interval_ms=30_000, refresh_interval_ms=30_000,
), ),
widget_kind(
kind="chart",
name="Chart",
description="Multi-series line chart from a PromQL range query.",
model_cls=PrometheusChartWidgetConfig,
default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000,
),
], ],
) )
@@ -0,0 +1,97 @@
"""Shared helpers for Prometheus range queries.
These two pieces were called out by the spec's downstream-notes as needing a
home: the step-derivation function (SC-104) and the series normalization helper
(SC-102). Keeping them in their own module makes them unit-testable in isolation
and reusable by the chart and mean widget paths (and, later, the in-service data
path of the service-storage-harness change) without ``sources.py`` growing
unbounded.
``normalize_prometheus_matrix`` is a direct extraction of the metric-label →
readable-label rule that previously lived inside the Grafana datasource-proxy
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
labels.
"""
from __future__ import annotations
from typing import Any
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
WINDOW_PRESETS: dict[str, int] = {
"1h": 3_600,
"6h": 21_600,
"24h": 86_400,
"7d": 604_800,
}
#: Sentinel values Prometheus serialises for non-finite floats; map these to
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
Clamped to a minimum of 15 seconds so Prometheus does not reject
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
the resulting point count to land in the 100300 band; with
``target_points=200`` every preset yields 200 points.
"""
return max(15, round(window_seconds / target_points))
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
renderer consumes.
Label rule (matches the removed Grafana path so labels are stable on
migration):
1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
3. Else fall back to ``"value"``.
4. Dedup label collisions with a `` (n)`` suffix.
"""
series: list[dict[str, Any]] = []
seen: dict[str, int] = {}
for entry in result:
metric = entry.get("metric") or {}
values = entry.get("values") or []
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
label = " ".join(parts) if parts else "value"
if label in seen:
seen[label] += 1
label = f"{label} ({seen[label]})"
else:
seen[label] = 0
points: list[dict[str, Any]] = []
for ts, raw in values:
t = _safe_int(ts)
if t is None:
# Drop samples whose timestamp is unusable rather than raising.
continue
points.append({"t": t, "v": _safe_float(raw)})
series.append({"label": label, "points": points})
return series
def _safe_float(raw: Any) -> float | None:
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
if raw in _NON_NUMERIC:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _safe_int(ts: Any) -> int | None:
"""Best-effort int conversion for a Prometheus timestamp."""
try:
return int(float(ts))
except (TypeError, ValueError):
return None
@@ -12,6 +12,7 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Protocol from typing import Any, Protocol
@@ -25,6 +26,11 @@ from media_library_viewer_api.domain.dashboard import (
from media_library_viewer_api.integrations.alertmanager import summarize_alerts from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_prometheus_matrix,
step_for_window,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -209,7 +215,7 @@ class GrafanaWidgetSource:
class PrometheusWidgetSource: class PrometheusWidgetSource:
"""Run a PromQL instant query against a Prometheus service.""" """Run PromQL queries against a Prometheus service (instant + range)."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try: try:
@@ -217,6 +223,9 @@ class PrometheusWidgetSource:
return {"error": "Prometheus widget is missing its service"} return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/") base_url = str(service.config.get("base_url") or "").rstrip("/")
timeout = int(service.config.get("timeout_seconds") or 10) timeout = int(service.config.get("timeout_seconds") or 10)
if widget_kind == "chart":
return await self._fetch_chart(base_url, timeout, config)
# Default: instant-query metric path (unchanged).
promql = config.get("promql") promql = config.get("promql")
if not promql: if not promql:
return {"error": "promql is required"} return {"error": "promql is required"}
@@ -242,6 +251,47 @@ class PrometheusWidgetSource:
logger.exception("prometheus adapter failed") logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"} return {"error": f"Prometheus query failed: {exc}"}
async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
"""Run a Prometheus ``/api/v1/query_range`` over a window (seconds).
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
per SC-103).
"""
step = step_for_window(window)
end = int(time.time())
start = end - window
try:
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
f"{base_url}/api/v1/query_range",
params={"query": promql, "start": start, "end": end, "step": step},
timeout=timeout,
),
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
except asyncio.TimeoutError:
return {"error": "Prometheus query timed out"}
except requests.RequestException as exc:
logger.exception("prometheus range query failed")
return {"error": f"Prometheus query failed: {exc}"}
result = payload.get("data", {}).get("result", [])
return {"matrix": result}
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
raw = await self._range_query(base_url, timeout, promql, window)
if "error" in raw:
return raw
return {"series": normalize_prometheus_matrix(raw["matrix"])}
class AlertmanagerWidgetSource: class AlertmanagerWidgetSource:
"""Fetch firing alerts from an Alertmanager service and summarize them.""" """Fetch firing alerts from an Alertmanager service and summarize them."""
+106
View File
@@ -0,0 +1,106 @@
"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104)."""
from __future__ import annotations
import pytest
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_prometheus_matrix,
step_for_window,
)
class TestStepForWindow:
"""SC-104: every preset must yield 100300 points."""
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
window = WINDOW_PRESETS[preset]
step = step_for_window(window)
# Clamped minimum.
assert step >= 15
point_count = window // step
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
def test_floor_of_fifteen_seconds(self) -> None:
# A tiny window that would otherwise produce a sub-15s step is clamped.
assert step_for_window(60) == 15
def test_custom_target_points(self) -> None:
# Targeting 100 points for 1h yields step 36 (3600/100).
assert step_for_window(3_600, target_points=100) == 36
class TestNormalizePrometheusMatrix:
"""SC-102: label rule + null handling + dedup."""
def test_empty_matrix(self) -> None:
assert normalize_prometheus_matrix([]) == []
def test_drops_dunder_labels_and_joins(self) -> None:
result = [
{
"metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"},
"values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]],
}
]
out = normalize_prometheus_matrix(result)
assert len(out) == 1
assert out[0]["label"] == "instance=host:9100 mode=idle"
assert out[0]["points"] == [
{"t": 1_700_000_000, "v": 12.5},
{"t": 1_700_000_030, "v": 13.0},
]
def test_falls_back_to_value_when_no_labels(self) -> None:
result = [{"metric": {}, "values": [[100, "1"]]}]
out = normalize_prometheus_matrix(result)
assert out[0]["label"] == "value"
def test_dedup_collisions_with_suffix(self) -> None:
# Two series with identical visible labels get a "(1)" suffix on the 2nd.
result = [
{"metric": {"job": "x"}, "values": [[1, "1"]]},
{"metric": {"job": "x"}, "values": [[1, "2"]]},
]
out = normalize_prometheus_matrix(result)
labels = [s["label"] for s in out]
assert labels == ["job=x", "job=x (1)"]
def test_non_numeric_sentinels_become_none(self) -> None:
result = [
{
"metric": {"job": "x"},
"values": [
[1, "NaN"],
[2, "+Inf"],
[3, "-Inf"],
[4, "3.5"],
],
}
]
out = normalize_prometheus_matrix(result)
assert out[0]["points"] == [
{"t": 1, "v": None},
{"t": 2, "v": None},
{"t": 3, "v": None},
{"t": 4, "v": 3.5},
]
def test_malformed_values_are_ignored_not_raised(self) -> None:
result = [
{
"metric": {"job": "x"},
"values": [
[1, "3.5"],
["not-a-ts", "9"], # unusable timestamp → dropped
[3, "junk-value"], # unparseable value → v: None
],
}
]
out = normalize_prometheus_matrix(result)
assert out[0]["points"] == [
{"t": 1, "v": 3.5},
{"t": 3, "v": None},
]
+1 -1
View File
@@ -100,7 +100,7 @@ def test_authentik_service_definition():
def test_definitions_declare_widget_kinds(): def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"} assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"}
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"} assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
assert get_service_definition("nextcloud").widget_kinds == [] assert get_service_definition("nextcloud").widget_kinds == []
+67
View File
@@ -667,6 +667,73 @@ async def test_grafana_adapter_chart_handles_http_failure():
assert "failed" in result["error"].lower() assert "failed" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_runs_range_query():
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "up", "instance": "h:9100"},
"values": [[100, "1"], [130, "1"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
# query_range endpoint + window-derived start/end/step params.
call = mock_get.call_args
assert call.args[0].endswith("/api/v1/query_range")
params = call.kwargs["params"]
assert params["query"] == "up"
assert {"start", "end", "step"}.issubset(params)
# {series} shape with the shared normalization (label drops __name__).
assert "series" in result
assert result["series"][0]["label"] == "instance=h:9100"
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
result = await adapter.fetch(service, "chart", {"promql": ""})
assert result == {"error": "promql is required"}
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_degrades_on_http_error():
"""SC-103: a connection error returns {error} rather than raising."""
import requests as req_mod
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
)
with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
assert "error" in result
assert "failed" in result["error"].lower()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_jellyfin_now_playing_filters_active_sessions(): async def test_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions.""" """now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
@@ -23,6 +23,9 @@ describe("service registry", () => {
it("binds widget kinds per service", () => { it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link", "link",
]);
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
"metric",
"chart", "chart",
]); ]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
+23 -34
View File
@@ -2,7 +2,7 @@ import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget"; import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { GrafanaChartWidget } from "../widgets/GrafanaChartWidget"; import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget"; import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -88,39 +88,6 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
component: GrafanaLinkWidget, component: GrafanaLinkWidget,
}, },
{
kind: "chart",
name: "Chart",
description: "Live time-series chart from a Grafana datasource query.",
refreshIntervalMs: 60_000,
defaultConfig: {
datasource_uid: "prometheus",
query: "",
from_ts: "now-1h",
to_ts: "now",
interval_ms: 30_000,
max_data_points: 100,
},
configSchema: {
type: "object",
properties: {
datasource_uid: {
type: "string",
description: "Grafana datasource UID (e.g. 'prometheus')",
},
query: {
type: "string",
description: "Query expression (e.g. PromQL)",
},
from_ts: { type: "string" },
to_ts: { type: "string" },
interval_ms: { type: "integer" },
max_data_points: { type: "integer" },
},
required: ["query"],
},
component: GrafanaChartWidget,
},
], ],
}, },
prometheus: { prometheus: {
@@ -141,6 +108,28 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
component: PrometheusMetricWidget, component: PrometheusMetricWidget,
}, },
{
kind: "chart",
name: "Chart",
description: "Multi-series line chart from a PromQL range query.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query expression",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
},
required: ["promql"],
},
component: PrometheusChartWidget,
},
], ],
}, },
jellyfin: { jellyfin: {
@@ -59,7 +59,7 @@ const CHART_COLORS = [
"var(--color-chart-5)", "var(--color-chart-5)",
]; ];
export function GrafanaChartWidget({ export function PrometheusChartWidget({
widget, widget,
refreshIntervalMs, refreshIntervalMs,
description, description,
@@ -111,7 +111,7 @@ export function GrafanaChartWidget({
) : ( ) : (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
No data. Check your query and datasource_uid in the widget config. No data. Check your PromQL query and window in the widget config.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest"; import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react"; import { render, screen } from "@testing-library/react";
import { GrafanaChartWidget } from "../GrafanaChartWidget"; import { PrometheusChartWidget } from "../PrometheusChartWidget";
import type { WidgetInstance } from "../../types"; import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets"; import * as useWidgets from "../../hooks/useWidgets";
@@ -29,7 +29,7 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>); } as unknown as ReturnType<typeof useWidgets.useWidgetData>);
} }
describe("GrafanaChartWidget", () => { describe("PrometheusChartWidget", () => {
it("renders a chart with series data", () => { it("renders a chart with series data", () => {
mockData({ mockData({
series: [ series: [
@@ -42,20 +42,20 @@ describe("GrafanaChartWidget", () => {
}, },
], ],
}); });
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />); render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
// recharts renders an SVG; the title from SectionCard should be present. // recharts renders an SVG; the title from SectionCard should be present.
expect(screen.getByText("CPU Usage")).toBeInTheDocument(); expect(screen.getByText("CPU Usage")).toBeInTheDocument();
}); });
it("shows error Alert on error", () => { it("shows error Alert on error", () => {
mockData(null, "Grafana api_key is required for chart queries"); mockData(null, "promql is required");
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />); render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/api_key is required/i)).toBeInTheDocument(); expect(screen.getByText(/promql is required/i)).toBeInTheDocument();
}); });
it("shows empty state when no series", () => { it("shows empty state when no series", () => {
mockData({ series: [] }); mockData({ series: [] });
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />); render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument(); expect(screen.getByText(/No data/i)).toBeInTheDocument();
}); });
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget"; export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget"; export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget"; export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { GrafanaChartWidget } from "./GrafanaChartWidget"; export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { JellyfinWidget } from "./JellyfinWidget"; export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget"; export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
export { SshTaskWidget } from "./SshTaskWidget"; export { SshTaskWidget } from "./SshTaskWidget";
@@ -45,61 +45,61 @@ This ordering ensures the chart capability is proven against Prometheus before t
**Satisfies:** SC-101, SC-102, SC-103, SC-104, SC-105, SC-106, SC-107, SC-108. **Satisfies:** SC-101, SC-102, SC-103, SC-104, SC-105, SC-106, SC-107, SC-108.
- [ ] **1.1 Create shared Prometheus range helpers module** - [x] **1.1 Create shared Prometheus range helpers module**
- Files: `backend/src/media_library_viewer_api/widgets/prometheus_range.py` (new) - Files: `backend/src/media_library_viewer_api/widgets/prometheus_range.py` (new)
- Lines: ~60 - Lines: ~60
- Dependencies: none - Dependencies: none
- Details: Implement `WINDOW_PRESETS = {"1h": 3600, "6h": 21600, "24h": 86400, "7d": 604800}`, `step_for_window(window_seconds, target_points=200) -> int` returning `max(15, round(window_seconds / target_points))`, and `normalize_prometheus_matrix(result: list[dict]) -> list[dict]` that converts a Prometheus `/api/v1/query_range` `data.result` matrix into `{label, points:[{t:int, v:float|None}]}` series. Label rule: drop `__name__` from metric labels; join remaining as `k=v`; fall back to `"value"`; dedup collisions with `(n)` suffix. Null handling: `"NaN"`, `"+Inf"`, `"-Inf"`, `None``v: None`. - Details: Implement `WINDOW_PRESETS = {"1h": 3600, "6h": 21600, "24h": 86400, "7d": 604800}`, `step_for_window(window_seconds, target_points=200) -> int` returning `max(15, round(window_seconds / target_points))`, and `normalize_prometheus_matrix(result: list[dict]) -> list[dict]` that converts a Prometheus `/api/v1/query_range` `data.result` matrix into `{label, points:[{t:int, v:float|None}]}` series. Label rule: drop `__name__` from metric labels; join remaining as `k=v`; fall back to `"value"`; dedup collisions with `(n)` suffix. Null handling: `"NaN"`, `"+Inf"`, `"-Inf"`, `None``v: None`.
- [ ] **1.2 Add backend unit tests for range helpers** - [x] **1.2 Add backend unit tests for range helpers**
- Files: `backend/tests/test_prometheus_range.py` (new) - Files: `backend/tests/test_prometheus_range.py` (new)
- Lines: ~70 - Lines: ~70
- Dependencies: 1.1 - Dependencies: 1.1
- Details: Test `step_for_window` for all four presets asserts result yields 100300 points. Test `normalize_prometheus_matrix`: feed a two-entry sample matrix (one with `__name__`, one colliding label) → assert `{series}` shape, label dedup `(1)` suffix, null handling for `"NaN"` string. - Details: Test `step_for_window` for all four presets asserts result yields 100300 points. Test `normalize_prometheus_matrix`: feed a two-entry sample matrix (one with `__name__`, one colliding label) → assert `{series}` shape, label dedup `(1)` suffix, null handling for `"NaN"` string.
- [ ] **1.3 Add `_fetch_chart` + shared range-query plumbing to `PrometheusWidgetSource`** - [x] **1.3 Add `_fetch_chart` + shared range-query plumbing to `PrometheusWidgetSource`**
- Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
- Lines: ~55 - Lines: ~55
- Dependencies: 1.1 - Dependencies: 1.1
- Details: Import `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix` from `prometheus_range`. Add a private `_range_query(base_url, timeout, promql, window) -> dict` returning `{"matrix": result}` or `{"error": ...}` (shared by chart now and mean in S2). Add `_fetch_chart(self, base_url, timeout, config)` calling `_range_query` and returning `{"series": normalize_prometheus_matrix(matrix)}`. Dispatch `widget_kind == "chart"` in `.fetch()`. Existing `metric` path stays byte-for-byte unchanged. Errors (timeout, `RequestException`) → `{"error": str}`, never raise. - Details: Import `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix` from `prometheus_range`. Add a private `_range_query(base_url, timeout, promql, window) -> dict` returning `{"matrix": result}` or `{"error": ...}` (shared by chart now and mean in S2). Add `_fetch_chart(self, base_url, timeout, config)` calling `_range_query` and returning `{"series": normalize_prometheus_matrix(matrix)}`. Dispatch `widget_kind == "chart"` in `.fetch()`. Existing `metric` path stays byte-for-byte unchanged. Errors (timeout, `RequestException`) → `{"error": str}`, never raise.
- [ ] **1.4 Declare `chart` widget kind in Prometheus integration** - [x] **1.4 Declare `chart` widget kind in Prometheus integration**
- Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify) - Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify)
- Lines: ~15 - Lines: ~15
- Dependencies: 1.3 - Dependencies: 1.3
- Details: Add `PrometheusChartWidgetConfig(WidgetConfigBase)` with `promql: str` and `window: str = "1h"`. Add a `widget_kind(...)` entry for `chart` (refresh 60s, default config `{"promql": "", "window": "1h"}`). Leave `metric` kind untouched. - Details: Add `PrometheusChartWidgetConfig(WidgetConfigBase)` with `promql: str` and `window: str = "1h"`. Add a `widget_kind(...)` entry for `chart` (refresh 60s, default config `{"promql": "", "window": "1h"}`). Leave `metric` kind untouched.
- [ ] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`** - [x] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`**
- Files: `frontend/src/widgets/GrafanaChartWidget.tsx``frontend/src/widgets/PrometheusChartWidget.tsx` (git mv) - Files: `frontend/src/widgets/GrafanaChartWidget.tsx``frontend/src/widgets/PrometheusChartWidget.tsx` (git mv)
- Lines: ~5 changed (rename export, fix empty-state copy) - Lines: ~5 changed (rename export, fix empty-state copy)
- Dependencies: none - Dependencies: none
- Details: `git mv` to preserve history. Rename exported function `GrafanaChartWidget``PrometheusChartWidget`. The recharts body (`LineChart`, `Line`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `ResponsiveContainer`, `mergeSeries`, `CHART_COLORS`, `formatTime`) is preserved unchanged (SC-106). Fix empty-state copy: "Check your query and datasource_uid" → "Check your PromQL query and window." - Details: `git mv` to preserve history. Rename exported function `GrafanaChartWidget``PrometheusChartWidget`. The recharts body (`LineChart`, `Line`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `ResponsiveContainer`, `mergeSeries`, `CHART_COLORS`, `formatTime`) is preserved unchanged (SC-106). Fix empty-state copy: "Check your query and datasource_uid" → "Check your PromQL query and window."
- [ ] **1.6 Rename chart widget test** - [x] **1.6 Rename chart widget test**
- Files: `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx``frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx` (git mv) - Files: `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx``frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx` (git mv)
- Lines: ~10 changed (import path, component name, error-message assertion) - Lines: ~10 changed (import path, component name, error-message assertion)
- Dependencies: 1.5 - Dependencies: 1.5
- Details: `git mv`. Update import to `PrometheusChartWidget`. Update error-state assertion: the old Grafana-specific error string (`"Grafana api_key is required"`) → a Prom error string (e.g. `"promql is required"`). Keep loading + rendered-data test cases. - Details: `git mv`. Update import to `PrometheusChartWidget`. Update error-state assertion: the old Grafana-specific error string (`"Grafana api_key is required"`) → a Prom error string (e.g. `"promql is required"`). Keep loading + rendered-data test cases.
- [ ] **1.7 Rebind `chart` from grafana to prometheus in frontend registry** - [x] **1.7 Rebind `chart` from grafana to prometheus in frontend registry**
- Files: `frontend/src/integrations/registry.ts` (modify) - Files: `frontend/src/integrations/registry.ts` (modify)
- Lines: ~30 - Lines: ~30
- Dependencies: 1.5 - Dependencies: 1.5
- Details: Import `PrometheusChartWidget`. Add a `chart` entry to the `prometheus` binding's `widgets` array (kind `chart`, refresh 60s, configSchema with `promql` + `window`). Remove the `chart` entry from the `grafana` binding's `widgets` array (leave `link` intact). Do NOT delete the `grafana` binding itself. - Details: Import `PrometheusChartWidget`. Add a `chart` entry to the `prometheus` binding's `widgets` array (kind `chart`, refresh 60s, configSchema with `promql` + `window`). Remove the `chart` entry from the `grafana` binding's `widgets` array (leave `link` intact). Do NOT delete the `grafana` binding itself.
- [ ] **1.8 Update frontend widgets barrel export** - [x] **1.8 Update frontend widgets barrel export**
- Files: `frontend/src/widgets/index.ts` (modify) - Files: `frontend/src/widgets/index.ts` (modify)
- Lines: ~2 - Lines: ~2
- Dependencies: 1.5 - Dependencies: 1.5
- Details: Rename the `GrafanaChartWidget` export to `PrometheusChartWidget`. Leave `GrafanaLinkWidget` export intact. - Details: Rename the `GrafanaChartWidget` export to `PrometheusChartWidget`. Leave `GrafanaLinkWidget` export intact.
- [ ] **1.9 Update registry tests for chart rebind** - [x] **1.9 Update registry tests for chart rebind**
- Files: `frontend/src/integrations/registry.test.ts` (modify) - Files: `frontend/src/integrations/registry.test.ts` (modify)
- Lines: ~15 - Lines: ~15
- Dependencies: 1.7 - Dependencies: 1.7
- Details: Assert `prometheus` binding has `metric` + `chart` kinds. Assert `grafana` binding has only `link` (no `chart`). - Details: Assert `prometheus` binding has `metric` + `chart` kinds. Assert `grafana` binding has only `link` (no `chart`).
- [ ] **1.10 Verify Slice 1 (build + lint + test)** - [x] **1.10 Verify Slice 1 (build + lint + test)**
- Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint` - Run: `cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint`
- Verify: helpers tests pass; existing widget tests pass (grafana link adapter still wired); frontend typechecks and lints; `prometheus/chart` widget resolves to `PrometheusChartWidget`. - Verify: helpers tests pass; existing widget tests pass (grafana link adapter still wired); frontend typechecks and lints; `prometheus/chart` widget resolves to `PrometheusChartWidget`.