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
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(
service_type="prometheus",
name="Prometheus",
@@ -42,5 +49,13 @@ DEFINITION = ServiceDefinition(
default_config={"promql": ""},
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 logging
import time
from dataclasses import dataclass, field
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.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.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_prometheus_matrix,
step_for_window,
)
logger = logging.getLogger(__name__)
@@ -209,7 +215,7 @@ class GrafanaWidgetSource:
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]:
try:
@@ -217,6 +223,9 @@ class PrometheusWidgetSource:
return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
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")
if not promql:
return {"error": "promql is required"}
@@ -242,6 +251,47 @@ class PrometheusWidgetSource:
logger.exception("prometheus adapter failed")
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:
"""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():
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("jellyfin").widget_kinds} == {"activity", "now_playing"}
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()
@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
async def test_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
@@ -23,6 +23,9 @@ describe("service registry", () => {
it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
"link",
]);
expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
"metric",
"chart",
]);
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 { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
import { GrafanaChartWidget } from "../widgets/GrafanaChartWidget";
import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
@@ -88,39 +88,6 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
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: {
@@ -141,6 +108,28 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
},
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: {
@@ -59,7 +59,7 @@ const CHART_COLORS = [
"var(--color-chart-5)",
];
export function GrafanaChartWidget({
export function PrometheusChartWidget({
widget,
refreshIntervalMs,
description,
@@ -111,7 +111,7 @@ export function GrafanaChartWidget({
) : (
<Alert>
<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>
</Alert>
)}
@@ -1,6 +1,6 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { GrafanaChartWidget } from "../GrafanaChartWidget";
import { PrometheusChartWidget } from "../PrometheusChartWidget";
import type { WidgetInstance } from "../../types";
import * as useWidgets from "../../hooks/useWidgets";
@@ -29,7 +29,7 @@ function mockData(data: unknown, error?: string) {
} as unknown as ReturnType<typeof useWidgets.useWidgetData>);
}
describe("GrafanaChartWidget", () => {
describe("PrometheusChartWidget", () => {
it("renders a chart with series data", () => {
mockData({
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.
expect(screen.getByText("CPU Usage")).toBeInTheDocument();
});
it("shows error Alert on error", () => {
mockData(null, "Grafana api_key is required for chart queries");
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/api_key is required/i)).toBeInTheDocument();
mockData(null, "promql is required");
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/promql is required/i)).toBeInTheDocument();
});
it("shows empty state when no series", () => {
mockData({ series: [] });
render(<GrafanaChartWidget widget={widget} refreshIntervalMs={60000} />);
render(<PrometheusChartWidget widget={widget} refreshIntervalMs={60000} />);
expect(screen.getByText(/No data/i)).toBeInTheDocument();
});
});
+1 -1
View File
@@ -1,7 +1,7 @@
export { AlertmanagerAlertsWidget } from "./AlertmanagerAlertsWidget";
export { BackupsWidget } from "./BackupsWidget";
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
export { GrafanaChartWidget } from "./GrafanaChartWidget";
export { PrometheusChartWidget } from "./PrometheusChartWidget";
export { JellyfinWidget } from "./JellyfinWidget";
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
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.
- [ ] **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)
- Lines: ~60
- 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`.
- [ ] **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)
- Lines: ~70
- 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.
- [ ] **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)
- Lines: ~55
- 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.
- [ ] **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)
- Lines: ~15
- 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.
- [ ] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`**
- [x] **1.5 Rename `GrafanaChartWidget` → `PrometheusChartWidget`**
- Files: `frontend/src/widgets/GrafanaChartWidget.tsx``frontend/src/widgets/PrometheusChartWidget.tsx` (git mv)
- Lines: ~5 changed (rename export, fix empty-state copy)
- 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."
- [ ] **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)
- Lines: ~10 changed (import path, component name, error-message assertion)
- 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.
- [ ] **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)
- Lines: ~30
- 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.
- [ ] **1.8 Update frontend widgets barrel export**
- [x] **1.8 Update frontend widgets barrel export**
- Files: `frontend/src/widgets/index.ts` (modify)
- Lines: ~2
- Dependencies: 1.5
- 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)
- Lines: ~15
- Dependencies: 1.7
- 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`
- Verify: helpers tests pass; existing widget tests pass (grafana link adapter still wired); frontend typechecks and lints; `prometheus/chart` widget resolves to `PrometheusChartWidget`.