S1 backend transport: gateway config + normalize_grafana_frames (from 65bae95)
+ MetricSource adapter + status + validation + CHANGELOG + tests. S2 frontend:
git mv widget renames to Metric* + registry/barrel updates. Each slice leaves
pytest/npm build/npm lint green. 5 risk flags incl first non-additive sync.
24 KiB
SDD Tasks: Grafana Metric Gateway
Change: grafana-metric-gateway
Phase: tasks
Date: 2026-07-09
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~500–660 (sum of two implementation slices) |
| 400-line budget risk | Low–Medium |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: backend config + adapter + frames normalizer + status + tests → PR 2: frontend widget renames + registry + barrel |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: Low–Medium
Each slice individually lands under the 400-line review budget. Slices are ordered S1 → S2; S1 is independently shippable (backend works with gateway path, frontend just has stale
Prometheus*names until S2). Peropenspec/config.yamlrules, each slice leavesnpm run build(tsc -b + vite build),npm run lint, and backendpytestgreen.
Slice ordering rationale (critical)
Slice 1 changes the backend transport from direct Prom to Grafana gateway. After S1:
prometheusservice config usesgrafana_url/datasource_uid+grafana_api_keysecret (nobase_url).MetricSource(renamed fromPrometheusWidgetSource) queriesPOST {grafana_url}/api/ds/query.normalize_grafana_framesis restored and shared withnormalize_prometheus_matrixvia_dedup_label.get_prometheus_statusvalidates the gateway path.- Startup warns about old-shape config.
- All backend tests are updated to assert
/api/ds/queryPOST + frames mock (NOT direct Prom GET). - Frontend widget files still carry
Prometheus*names — that's fine; they still work (the KIND stringschart/gauge/mean/metricare unchanged, only the transport under the hood changes).
Slice 2 renames the three frontend widgets to neutral Metric* via git mv (history-preserving) and updates registry/barrel references. Pure rename; no rendering change. PrometheusMetricWidget is NOT renamed (the metric kind stays — proposal only names Chart/Gauge/Mean).
This ordering ensures the transport change is proven (backend tests green) before any frontend churn, so the two risks (gateway transport + widget rename) never compound in a single slice.
Slice 1: Backend gateway transport + frames normalizer + status + tests
Goal: Route all prometheus widget queries through Grafana /api/ds/query instead of direct Prom HTTP. Restore the frames→series normalizer (from git 65bae95) into the shared helper module. Update status check, startup validation, CHANGELOG, and all affected backend tests.
Satisfies: GM-101, GM-102, GM-103, GM-104, GM-105, GM-106, GM-107, GM-108, GM-109, GM-110, GM-113, GM-114, GM-115.
-
1.1 Update
PrometheusConfigto gateway fields (GM-101)- Files:
backend/src/media_library_viewer_api/integrations/prometheus.py(modify) - Lines: ~15
- Dependencies: none
- Details: In
PrometheusConfig, removebase_url: ServiceBaseUrl; addgrafana_url: ServiceBaseUrlanddatasource_uid: str = "prometheus". Keeptimeout_seconds: int = 10. In theDEFINITION.secret_fields, replace the existing optionalapi_keysecret withSecretField(key="grafana_api_key", label="Grafana API key", required=True, helper="Service account token or API key for the Grafana gateway"). Widget config models (PrometheusChartWidgetConfig,PrometheusGaugeWidgetConfig,PrometheusMeanWidgetConfig,PrometheusMetricWidgetConfig) are UNCHANGED.
- Files:
-
1.2 Add
normalize_grafana_frames+ shared_dedup_labelhelper (GM-104)- Files:
backend/src/media_library_viewer_api/widgets/prometheus_range.py(modify) - Lines: ~80
- Dependencies: none
- Details: Recover the frames→series normalizer from git commit
65bae95(GrafanaWidgetSource._fetch_chart's normalization block). Refactor into a standalonenormalize_grafana_frames(raw: dict[str, Any]) -> list[dict[str, Any]]that parsesresults.<refId>.frames[](each frame hasdata.values=[[timestamps...], [values...]]+schema.fieldswithconfig.displayName/labels/name). Label rule: preferconfig.displayName; else sortedk=vfrom Prometheus labels (excluding__-prefixed); else field name; else"value". Extract the dedup suffix logic (seendict +" (n)") into a private_dedup_label(label: str, seen: dict[str, int]) -> strshared by BOTHnormalize_grafana_framesandnormalize_prometheus_matrix(refactor the latter to call it — GM-104 "dedup logic is not copy-pasted"). Null handling:None/"NaN"/"+Inf"/"-Inf"→v: None. Keepnormalize_prometheus_matrixin the module (design decision 5: dead code after this change but harmless + future-proof).
- Files:
-
1.3 Add backend unit tests for
normalize_grafana_frames+_dedup_label- Files:
backend/tests/test_prometheus_range.py(modify) - Lines: ~80
- Dependencies: 1.2
- Details: Add tests: (a) single frame with timestamps
[1000, 2000]and values[1.5, 2.5]→[{"label": ..., "points": [{"t": 1000, "v": 1.5}, {"t": 2000, "v": 2.5}]}]; (b) label fallback chain — displayName takes priority; else sortedk=vlabels; else"value"; (c) dedup — two frames producing the same label → second gets" (1)"suffix; (d) NaN/null handling. Existingnormalize_prometheus_matrixtests stay green (they now route through_dedup_label).
- Files:
-
1.4 Rename
PrometheusWidgetSource→MetricSource+ add_gateway_query(GM-102)- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify) - Lines: ~90
- Dependencies: 1.2
- Details: Rename class
PrometheusWidgetSource→MetricSource. UpdateSERVICE_ADAPTERS["prometheus"] = MetricSource(). Add import ofnormalize_grafana_framesfromprometheus_range. Add a new_gateway_query(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, promql: str, window_seconds: int | None = None, max_data_points: int = 200) -> dict[str, Any]that: builds the/api/ds/queryPOST body (queries array withdatasource: {uid, type: "prometheus"},expr,format: "time_series",intervalMs = step * 1000,maxDataPoints,refId: "A"; plusfrom/to—window_seconds=None→ instant mappingfrom=now-1m, to=now, maxDataPoints=1;window_seconds=<N>→from=now-{N}s, to=now); setsAuthorization: Bearer {api_key}header; runsrequests.postviaasyncio.wait_for(asyncio.to_thread(...), timeout=timeout); catchesasyncio.TimeoutError→{"error": "Grafana query timed out"},requests.RequestException→{"error": f"Grafana query failed: {exc}"}; on success returns the raw Grafana JSON dict. The.fetch()method extractsgrafana_url,grafana_api_key,datasource_uid,timeoutfrom theServiceRecordand dispatches per kind (1.5–1.8). Errors never raise (GM-103).
- Files:
-
1.5 Rewrite
_fetch_chartto use gateway (GM-106)- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify, same file as 1.4) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 1.4
- Details:
_fetch_chartnow calls_gateway_query(..., window_seconds=WINDOW_PRESETS[window]), thennormalize_grafana_frames(raw)→{"series": series}. ReusesWINDOW_PRESETS+step_for_window(unchanged). Errors propagate as{"error": ...}.
- Files:
-
1.6 Rewrite
_fetch_gaugeto use gateway (GM-107)- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify, same file) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 1.4
- Details:
_fetch_gaugecalls_gateway_query(..., window_seconds=None)(instant), normalizes vianormalize_grafana_frames, enforces scalar-only (len(series) != 1→{"error": "Gauge requires a single-series query; refine your PromQL"}), extractspoints[-1]["v"]as the latest scalar, returns{"value", "warn_at", "crit_at", "min", "max", "unit"}from config. Null value →{"error": ...}.
- Files:
-
1.7 Rewrite
_fetch_meanto use gateway (GM-108)- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify, same file) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 1.4
- Details:
_fetch_meancalls_gateway_query(..., window_seconds=WINDOW_PRESETS[window]), normalizes, enforces scalar-only, averages all non-nullpoints[*]["v"]of the single series, returns{"value": mean, "unit": config.get("unit")}. Empty nums →{"error": "Mean query returned no numeric samples in the window"}.
- Files:
-
1.8 Rewrite
_fetch_metric(instant) to use gateway (GM-109)- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify, same file) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 1.4
- Details: Replace the old
_instant_querypath._fetch_metriccalls_gateway_query(..., window_seconds=None), normalizes vianormalize_grafana_frames, returns{"result": series}(array of{label, points}). The frontendPrometheusMetricWidgetadapts to this shape in Slice 2 (design §3.4 Option A) OR — if the adaptation proves too large for S2 — return a Prom-compatible shape as fallback (design Option B). Decide during apply based on the component's parsing surface.
- Files:
-
1.9 Remove dead direct-Prom code (
_range_query,_instant_query)- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify, same file) - Lines: ~−50 (deletions)
- Dependencies: 1.4–1.8
- Details: Delete the now-unused
_range_queryand_instant_queryprivate methods (they hit direct Prom/api/v1/query[_range]). The import ofnormalize_prometheus_matrixstays (design decision 5: kept as future-proof dead code; still imported harmlessly). Verifygrep -n "/api/v1/query" backend/src/media_library_viewer_api/widgets/sources.pyreturns nothing (GM-102 "no direct Prom HTTP call").
- Files:
-
1.10 Rewrite
get_prometheus_statusfor gateway path (GM-110)- Files:
backend/src/media_library_viewer_api/routers/monitoring.py(modify) - Lines: ~30
- Dependencies: 1.4
- Details: Replace the two direct-Prom HTTP calls (
/-/healthy+/api/v1/status/buildinfo) with a single gateway POST:POST {grafana_url}/api/ds/querywith body{"queries": [{"datasource": {"uid": datasource_uid, "type": "prometheus"}, "expr": "up", "format": "time_series", "intervalMs": 15000, "maxDataPoints": 1, "refId": "A"}], "from": "now-1m", "to": "now"}+Authorization: Bearer {grafana_api_key}. Readgrafana_url/grafana_api_key/datasource_uid/timeout_secondsfrom the service record directly (do NOT modify the shared_base_url/_auth_headers/_timeouthelpers — they still serve alertmanager). Error mapping: 401/403 →error="auth_failed";requests.RequestException→error="prometheus_unreachable"; other non-2xx →error="gateway_error". Success →_status_response(service, version="ok")(spec assumption #2). Missing gateway config →error="gateway_not_configured".
- Files:
-
1.11 Add startup validation for old-shape prometheus config (GM-113)
- Files:
backend/src/media_library_viewer_api/main.py(modify) - Lines: ~15
- Dependencies: none
- Details: Add a
_validate_prometheus_gateway_config(store: SettingsStore) -> Nonehelper that iteratesstore.list_services("prometheus")and, for each service whoseconfigcontains"base_url"but not"grafana_url", logs alogger.warning(...)naming the service + id + migration instruction. Call it fromlifespanafterensure_defaults(). MUST NOT crash startup (GM-113).
- Files:
-
1.12 Add CHANGELOG migration note (GM-114)
- Files:
CHANGELOG.md(modify) - Lines: ~5
- Dependencies: none
- Details: Add
[Unreleased]entry marked BREAKING:prometheusservice instances must be reconfigured — replacebase_urlwithgrafana_url, add thegrafana_api_keysecret, optionally setdatasource_uid. Metric widgets now query through Grafana's/api/ds/query.
- Files:
-
1.13 Update backend adapter tests for gateway POST + frames mock (GM-115)
- Files:
backend/tests/test_widgets.py(modify) - Lines: ~80
- Dependencies: 1.4–1.8
- Details: Existing chart/gauge/mean adapter tests currently mock
GET /api/v1/query_rangeor/api/v1/queryand assert Prom matrix/vector shapes. Rewrite to mockPOST /api/ds/queryand return a Grafana frames-shaped response. TheServiceRecordmock changes from{config: {base_url, timeout_seconds}, secrets: {api_key}}to{config: {grafana_url, datasource_uid, timeout_seconds}, secrets: {grafana_api_key}}. Output assertions ({series}/{value}) stay the same — only the transport mock + input shape change. Scalar-only enforcement tests for gauge/mean stay. Error tests (timeout, 401) update to assert gateway-specific messages.
- Files:
-
1.14 Update
get_prometheus_statustest (GM-115)- Files:
backend/tests/test_api.py(modify) - Lines: ~20
- Dependencies: 1.10
- Details: The existing
TestPrometheusStatustests mockGET /-/healthy+GET /api/v1/status/buildinfo. Rewrite to mockPOST /api/ds/querywithexpr: "up". Assert{up: true, version: "ok"}on success,{up: false, error: "auth_failed"}on 401,{up: false, error: "prometheus_unreachable"}on connection error.
- Files:
-
1.15 Update prometheus service config schema test (GM-101)
- Files:
backend/tests/test_services.py(modify) - Lines: ~10
- Dependencies: 1.1
- Details: The existing test that creates a prometheus service uses
{base_url: "http://prom:9090"}. Update to{grafana_url: "http://grafana:3000", datasource_uid: "prometheus"}+ secretgrafana_api_key. Assert the service-type info exposesgrafana_url/datasource_uidconfig fields andgrafana_api_keysecret field (notbase_url/api_key).
- Files:
-
1.16 Verify Slice 1 (pytest + ruff + frontend still builds)
- Run:
cd backend && PYTHONPATH=src python3 -m pytest -q && PYTHONPATH=src python3 -m ruff check src tests - Run:
cd frontend && npm run build && npm run lint - Verify: all backend tests pass (adapter tests assert gateway POST, not direct Prom); ruff clean; frontend still builds + lints (no frontend change yet, so this is a regression check only).
- Risk flag: existing chart/gauge/mean adapter tests MUST be updated (1.13) — they will fail if left asserting direct Prom. This is the riskiest item in Slice 1.
- Risk flag: the qBit widgets (
QbittorrentSpeedWidget,QbittorrentActiveTorrentsWidget,QbittorrentTotalsWidget) must stay green — they do NOT usePrometheusWidgetSource/MetricSource;QbittorrentSpeedWidgetusesLineSeriesChartwhich is preserved untouched. Verify the qBit widget tests pass unchanged.
- Run:
Slice 1 total: ~300–380 changed lines.
Slice 2: Frontend widget renames to neutral Metric*
Goal: Rename PrometheusChartWidget → MetricChartWidget, PrometheusGaugeWidget → MetricGaugeWidget, PrometheusMeanWidget → MetricMeanWidget via git mv (history-preserving). Update registry imports, barrel exports, and any component references. PrometheusMetricWidget is NOT renamed. If design §3.4 Option A applies, adapt PrometheusMetricWidget for the normalized series shape returned by the gateway.
Satisfies: GM-111, GM-112, GM-116.
-
2.1
git mvchart widget + rename export (GM-111)- Files:
frontend/src/widgets/PrometheusChartWidget.tsx→frontend/src/widgets/MetricChartWidget.tsx(git mv) - Lines: ~3 changed (rename export + import in self)
- Dependencies: Slice 1 (1.4)
- Details:
git mvto preserve history. Rename exported functionPrometheusChartWidget→MetricChartWidget. The rendering code (LineSeriesChart,useWidgetData,SectionCard, loading/error/empty states) is preserved UNCHANGED (GM-111 "rendering code preserved").
- Files:
-
2.2
git mvchart widget test + update import (GM-116)- Files:
frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx→frontend/src/widgets/__tests__/MetricChartWidget.test.tsx(git mv) - Lines: ~5 changed (import path, component name, describe label)
- Dependencies: 2.1
- Details:
git mv. Update import toMetricChartWidgetfrom../MetricChartWidget. Updatedescribelabel + component references. Test cases (loading skeleton, chart with series data, error alert, empty state) are preserved.
- Files:
-
2.3
git mvgauge widget + rename export (GM-111)- Files:
frontend/src/widgets/PrometheusGaugeWidget.tsx→frontend/src/widgets/MetricGaugeWidget.tsx(git mv) - Lines: ~3 changed
- Dependencies: Slice 1 (1.4)
- Details:
git mv. Rename exportPrometheusGaugeWidget→MetricGaugeWidget. Rendering (rechartsRadialBarChart, threshold bands,toPercent,formatValue) preserved UNCHANGED.
- Files:
-
2.4
git mvgauge widget test + update import (GM-116)- Files:
frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx→frontend/src/widgets/__tests__/MetricGaugeWidget.test.tsx(git mv) - Lines: ~5 changed
- Dependencies: 2.3
- Details:
git mv. Update import + describe label + component refs. Test cases (loading, with/without bands, error, empty) preserved.
- Files:
-
2.5
git mvmean widget + rename export (GM-111)- Files:
frontend/src/widgets/PrometheusMeanWidget.tsx→frontend/src/widgets/MetricMeanWidget.tsx(git mv) - Lines: ~3 changed
- Dependencies: Slice 1 (1.4)
- Details:
git mv. Rename exportPrometheusMeanWidget→MetricMeanWidget. Rendering (formatMean,MetricCard-style display, unit support) preserved UNCHANGED.
- Files:
-
2.6
git mvmean widget test + update import (GM-116)- Files:
frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx→frontend/src/widgets/__tests__/MetricMeanWidget.test.tsx(git mv) - Lines: ~5 changed
- Dependencies: 2.5
- Details:
git mv. Update import + describe label + component refs. Test cases (loading, data display, error, empty) preserved.
- Files:
-
2.7 Update registry imports + component refs (GM-112)
- Files:
frontend/src/integrations/registry.ts(modify) - Lines: ~10
- Dependencies: 2.1, 2.3, 2.5
- Details: Change imports:
import { MetricChartWidget } from "../widgets/MetricChartWidget"(wasPrometheusChartWidget), same for Gauge + Mean.PrometheusMetricWidgetimport stays. Update the threecomponent:refs in theprometheusbinding's widgets array (chart →MetricChartWidget, gauge →MetricGaugeWidget, mean →MetricMeanWidget). Widget KIND strings ("chart","gauge","mean","metric") MUST NOT change (persisted in widget instance rows).
- Files:
-
2.8 Update barrel exports (GM-111)
- Files:
frontend/src/widgets/index.ts(modify) - Lines: ~3
- Dependencies: 2.1, 2.3, 2.5
- Details: Change three exports:
export { MetricChartWidget } from "./MetricChartWidget"(was Prometheus), same for Gauge + Mean.PrometheusMetricWidgetexport stays.
- Files:
-
2.9 Adapt
PrometheusMetricWidgetfor normalized series shape (if needed)- Files:
frontend/src/widgets/PrometheusMetricWidget.tsx(modify, NOT renamed) - Lines: ~15–25 (depends on whether Option A or B from design §3.4)
- Dependencies: Slice 1 (1.8)
- Details: After Slice 1,
_fetch_metricreturns{"result": [{label, points}]}(normalized Grafana series). The component currently parses Prom{resultType, result: [{metric, value}]}vector shape. Adapt the rendering to read the last point of each series indata.result. ReuseformatPrometheusValuefor scalar formatting. If the adaptation exceeds ~25 lines, fall back to design Option B (backend returns Prom-compatible raw shape) and note the deviation. Decision point during apply.
- Files:
-
2.10 Grep-verify no stale
Prometheus*Widgetreferences (GM-111)- Run:
grep -rn "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/src - Verify: returns no matches (only
PrometheusMetricWidgetis allowed to remain). - Risk flag: this grep MUST pass before the slice is complete. If stale imports remain (e.g. in
Dashboard.tsx,WidgetConfigDialog.tsx, or any other consumer), update them.
- Run:
-
2.11 Verify Slice 2 (build + lint + test)
- Run:
cd frontend && npm run build && npm run lint && npx vitest run - Run:
cd backend && PYTHONPATH=src python3 -m pytest -q(regression: Slice 1 tests still pass) - Verify: frontend typechecks + builds (renamed components resolve); lint 0 errors; vitest passes (renamed tests +
PrometheusMetricWidgettest if adapted in 2.9); backend still green. - Risk flag: qBit widget tests must stay green —
QbittorrentSpeedWidgetimportsLineSeriesChart(unchanged), NOT anyMetric*orPrometheus*widget. Confirm.
- Run:
Slice 2 total: ~200–280 changed lines.
Integration verification (post-slice)
-
3.1 Full backend test run
- Run:
cd backend && PYTHONPATH=src python3 -m pytest -q - Verify: all tests pass (no direct-Prom HTTP assertions remain).
- Run:
-
3.2 Full frontend build + lint + test
- Run:
cd frontend && npm run build && npm run lint && npx vitest run - Verify: 0 errors; all widget tests pass including renamed
Metric*+PrometheusMetricWidget+ qBit widgets.
- Run:
-
3.3 Grep-verify no direct Prom HTTP in backend source
- Run:
grep -rn "/api/v1/query" backend/src/media_library_viewer_api/widgets/sources.py - Verify: no matches (GM-102 "no direct Prom HTTP call").
- Run:
-
3.4 Grep-verify no stale Prometheus*Widget names (Chart/Gauge/Mean only)
- Run:
grep -rn "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/src - Verify: no matches (GM-111).
- Run:
-
3.5 config.yaml accuracy check
- Verify:
openspec/config.yamlstill reflects the post-Change-A reality (Grafana as gateway transport is consistent with "Manage renders Prometheus-backed metrics via recharts"; the gateway is a transport detail, not a re-introduction of Grafana as a service type). If wording needs a small adjustment for clarity, note it — but do NOT revert the thin-dashboard rule.
- Verify:
-
3.6 CHANGELOG check
- Verify:
CHANGELOG.md[Unreleased]has the BREAKING entry naminggrafana_url,grafana_api_key,datasource_uid(GM-114).
- Verify:
Risk flags summary
-
(a) Slice 1 adapter tests must be updated (task 1.13). The existing chart/gauge/mean tests assert direct Prom
GET /api/v1/query[_range]. If left unchanged they will fail. The rewrite assertsPOST /api/ds/querywith a Grafana frames mock. This is the riskiest item — if the frames mock shape doesn't match a real Grafana/api/ds/queryresponse, the tests pass but production breaks. Mitigation: recover the exact response-handling code from65bae95(task 1.2) so the mock matches what the recovered normalizer expects. -
(b) Slice 2
git mvmust update ALL references (task 2.10). Any stale import ofPrometheusChartWidget/PrometheusGaugeWidget/PrometheusMeanWidgetwill break the build. The grep in task 2.10 catches this. Known consumers:registry.ts(2.7),index.ts(2.8). VerifyDashboard.tsx,WidgetConfigDialog.tsx,WidgetInstance.tsx, and any service-tab references don't import the widget components directly (they resolve via the registry). -
(c)
PrometheusMetricWidgetshape adaptation (task 2.9). Design §3.4 Option A may require up to ~25 lines of frontend change. If it proves larger, fall back to Option B (backend returns Prom-compatible raw shape). Decide during apply. -
(d) qBit widgets must stay green.
QbittorrentSpeedWidgetusesLineSeriesChart(the shared renderer extracted inservice-storage-harnessSlice 2). This change does NOT touchLineSeriesChart. Verify qBit widget tests pass unchanged in both slices. -
(e) First non-additive canonical sync. The
prometheus-chartingcanonical domain's transport-related SC- requirements are MODIFIED by this change. The sync phase (sdd-sync) must author## MODIFIED Requirementsdeltas, not just## ADDED. Spec §"Canonical delta intent" maps every GM→SC. This is flagged for the sync phase, not the apply slices.