Files
manage/openspec/changes/grafana-metric-gateway/tasks.md
T
Developer 798196ffc7 spec(grafana-metric-gateway): add tasks (2 slices, each <=400 lines)
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.
2026-07-09 21:01:38 +00:00

24 KiB
Raw Blame History

SDD Tasks: Grafana Metric Gateway

Change: grafana-metric-gateway Phase: tasks Date: 2026-07-09

Review Workload Forecast

Field Value
Estimated changed lines ~500660 (sum of two implementation slices)
400-line budget risk LowMedium
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: LowMedium

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). Per openspec/config.yaml rules, each slice leaves npm run build (tsc -b + vite build), npm run lint, and backend pytest green.


Slice ordering rationale (critical)

Slice 1 changes the backend transport from direct Prom to Grafana gateway. After S1:

  • prometheus service config uses grafana_url/datasource_uid + grafana_api_key secret (no base_url).
  • MetricSource (renamed from PrometheusWidgetSource) queries POST {grafana_url}/api/ds/query.
  • normalize_grafana_frames is restored and shared with normalize_prometheus_matrix via _dedup_label.
  • get_prometheus_status validates the gateway path.
  • Startup warns about old-shape config.
  • All backend tests are updated to assert /api/ds/query POST + frames mock (NOT direct Prom GET).
  • Frontend widget files still carry Prometheus* names — that's fine; they still work (the KIND strings chart/gauge/mean/metric are 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 PrometheusConfig to gateway fields (GM-101)

    • Files: backend/src/media_library_viewer_api/integrations/prometheus.py (modify)
    • Lines: ~15
    • Dependencies: none
    • Details: In PrometheusConfig, remove base_url: ServiceBaseUrl; add grafana_url: ServiceBaseUrl and datasource_uid: str = "prometheus". Keep timeout_seconds: int = 10. In the DEFINITION.secret_fields, replace the existing optional api_key secret with SecretField(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.
  • 1.2 Add normalize_grafana_frames + shared _dedup_label helper (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 standalone normalize_grafana_frames(raw: dict[str, Any]) -> list[dict[str, Any]] that parses results.<refId>.frames[] (each frame has data.values = [[timestamps...], [values...]] + schema.fields with config.displayName / labels / name). Label rule: prefer config.displayName; else sorted k=v from Prometheus labels (excluding __-prefixed); else field name; else "value". Extract the dedup suffix logic (seen dict + " (n)") into a private _dedup_label(label: str, seen: dict[str, int]) -> str shared by BOTH normalize_grafana_frames and normalize_prometheus_matrix (refactor the latter to call it — GM-104 "dedup logic is not copy-pasted"). Null handling: None/"NaN"/"+Inf"/"-Inf"v: None. Keep normalize_prometheus_matrix in the module (design decision 5: dead code after this change but harmless + future-proof).
  • 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 sorted k=v labels; else "value"; (c) dedup — two frames producing the same label → second gets " (1)" suffix; (d) NaN/null handling. Existing normalize_prometheus_matrix tests stay green (they now route through _dedup_label).
  • 1.4 Rename PrometheusWidgetSourceMetricSource + add _gateway_query (GM-102)

    • Files: backend/src/media_library_viewer_api/widgets/sources.py (modify)
    • Lines: ~90
    • Dependencies: 1.2
    • Details: Rename class PrometheusWidgetSourceMetricSource. Update SERVICE_ADAPTERS["prometheus"] = MetricSource(). Add import of normalize_grafana_frames from prometheus_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/query POST body (queries array with datasource: {uid, type: "prometheus"}, expr, format: "time_series", intervalMs = step * 1000, maxDataPoints, refId: "A"; plus from/towindow_seconds=None → instant mapping from=now-1m, to=now, maxDataPoints=1; window_seconds=<N>from=now-{N}s, to=now); sets Authorization: Bearer {api_key} header; runs requests.post via asyncio.wait_for(asyncio.to_thread(...), timeout=timeout); catches asyncio.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 extracts grafana_url, grafana_api_key, datasource_uid, timeout from the ServiceRecord and dispatches per kind (1.51.8). Errors never raise (GM-103).
  • 1.5 Rewrite _fetch_chart to 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_chart now calls _gateway_query(..., window_seconds=WINDOW_PRESETS[window]), then normalize_grafana_frames(raw){"series": series}. Reuses WINDOW_PRESETS + step_for_window (unchanged). Errors propagate as {"error": ...}.
  • 1.6 Rewrite _fetch_gauge to 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_gauge calls _gateway_query(..., window_seconds=None) (instant), normalizes via normalize_grafana_frames, enforces scalar-only (len(series) != 1{"error": "Gauge requires a single-series query; refine your PromQL"}), extracts points[-1]["v"] as the latest scalar, returns {"value", "warn_at", "crit_at", "min", "max", "unit"} from config. Null value → {"error": ...}.
  • 1.7 Rewrite _fetch_mean to 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_mean calls _gateway_query(..., window_seconds=WINDOW_PRESETS[window]), normalizes, enforces scalar-only, averages all non-null points[*]["v"] of the single series, returns {"value": mean, "unit": config.get("unit")}. Empty nums → {"error": "Mean query returned no numeric samples in the window"}.
  • 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_query path. _fetch_metric calls _gateway_query(..., window_seconds=None), normalizes via normalize_grafana_frames, returns {"result": series} (array of {label, points}). The frontend PrometheusMetricWidget adapts 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.
  • 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.41.8
    • Details: Delete the now-unused _range_query and _instant_query private methods (they hit direct Prom /api/v1/query[_range]). The import of normalize_prometheus_matrix stays (design decision 5: kept as future-proof dead code; still imported harmlessly). Verify grep -n "/api/v1/query" backend/src/media_library_viewer_api/widgets/sources.py returns nothing (GM-102 "no direct Prom HTTP call").
  • 1.10 Rewrite get_prometheus_status for 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/query with 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}. Read grafana_url/grafana_api_key/datasource_uid/timeout_seconds from the service record directly (do NOT modify the shared _base_url/_auth_headers/_timeout helpers — they still serve alertmanager). Error mapping: 401/403 → error="auth_failed"; requests.RequestExceptionerror="prometheus_unreachable"; other non-2xx → error="gateway_error". Success → _status_response(service, version="ok") (spec assumption #2). Missing gateway config → error="gateway_not_configured".
  • 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) -> None helper that iterates store.list_services("prometheus") and, for each service whose config contains "base_url" but not "grafana_url", logs a logger.warning(...) naming the service + id + migration instruction. Call it from lifespan after ensure_defaults(). MUST NOT crash startup (GM-113).
  • 1.12 Add CHANGELOG migration note (GM-114)

    • Files: CHANGELOG.md (modify)
    • Lines: ~5
    • Dependencies: none
    • Details: Add [Unreleased] entry marked BREAKING: prometheus service instances must be reconfigured — replace base_url with grafana_url, add the grafana_api_key secret, optionally set datasource_uid. Metric widgets now query through Grafana's /api/ds/query.
  • 1.13 Update backend adapter tests for gateway POST + frames mock (GM-115)

    • Files: backend/tests/test_widgets.py (modify)
    • Lines: ~80
    • Dependencies: 1.41.8
    • Details: Existing chart/gauge/mean adapter tests currently mock GET /api/v1/query_range or /api/v1/query and assert Prom matrix/vector shapes. Rewrite to mock POST /api/ds/query and return a Grafana frames-shaped response. The ServiceRecord mock 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.
  • 1.14 Update get_prometheus_status test (GM-115)

    • Files: backend/tests/test_api.py (modify)
    • Lines: ~20
    • Dependencies: 1.10
    • Details: The existing TestPrometheusStatus tests mock GET /-/healthy + GET /api/v1/status/buildinfo. Rewrite to mock POST /api/ds/query with expr: "up". Assert {up: true, version: "ok"} on success, {up: false, error: "auth_failed"} on 401, {up: false, error: "prometheus_unreachable"} on connection error.
  • 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"} + secret grafana_api_key. Assert the service-type info exposes grafana_url/datasource_uid config fields and grafana_api_key secret field (not base_url/api_key).
  • 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 use PrometheusWidgetSource/MetricSource; QbittorrentSpeedWidget uses LineSeriesChart which is preserved untouched. Verify the qBit widget tests pass unchanged.

Slice 1 total: ~300380 changed lines.


Slice 2: Frontend widget renames to neutral Metric*

Goal: Rename PrometheusChartWidgetMetricChartWidget, PrometheusGaugeWidgetMetricGaugeWidget, PrometheusMeanWidgetMetricMeanWidget 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 mv chart widget + rename export (GM-111)

    • Files: frontend/src/widgets/PrometheusChartWidget.tsxfrontend/src/widgets/MetricChartWidget.tsx (git mv)
    • Lines: ~3 changed (rename export + import in self)
    • Dependencies: Slice 1 (1.4)
    • Details: git mv to preserve history. Rename exported function PrometheusChartWidgetMetricChartWidget. The rendering code (LineSeriesChart, useWidgetData, SectionCard, loading/error/empty states) is preserved UNCHANGED (GM-111 "rendering code preserved").
  • 2.2 git mv chart widget test + update import (GM-116)

    • Files: frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsxfrontend/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 to MetricChartWidget from ../MetricChartWidget. Update describe label + component references. Test cases (loading skeleton, chart with series data, error alert, empty state) are preserved.
  • 2.3 git mv gauge widget + rename export (GM-111)

    • Files: frontend/src/widgets/PrometheusGaugeWidget.tsxfrontend/src/widgets/MetricGaugeWidget.tsx (git mv)
    • Lines: ~3 changed
    • Dependencies: Slice 1 (1.4)
    • Details: git mv. Rename export PrometheusGaugeWidgetMetricGaugeWidget. Rendering (recharts RadialBarChart, threshold bands, toPercent, formatValue) preserved UNCHANGED.
  • 2.4 git mv gauge widget test + update import (GM-116)

    • Files: frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsxfrontend/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.
  • 2.5 git mv mean widget + rename export (GM-111)

    • Files: frontend/src/widgets/PrometheusMeanWidget.tsxfrontend/src/widgets/MetricMeanWidget.tsx (git mv)
    • Lines: ~3 changed
    • Dependencies: Slice 1 (1.4)
    • Details: git mv. Rename export PrometheusMeanWidgetMetricMeanWidget. Rendering (formatMean, MetricCard-style display, unit support) preserved UNCHANGED.
  • 2.6 git mv mean widget test + update import (GM-116)

    • Files: frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsxfrontend/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.
  • 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" (was PrometheusChartWidget), same for Gauge + Mean. PrometheusMetricWidget import stays. Update the three component: refs in the prometheus binding's widgets array (chart → MetricChartWidget, gauge → MetricGaugeWidget, mean → MetricMeanWidget). Widget KIND strings ("chart", "gauge", "mean", "metric") MUST NOT change (persisted in widget instance rows).
  • 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. PrometheusMetricWidget export stays.
  • 2.9 Adapt PrometheusMetricWidget for normalized series shape (if needed)

    • Files: frontend/src/widgets/PrometheusMetricWidget.tsx (modify, NOT renamed)
    • Lines: ~1525 (depends on whether Option A or B from design §3.4)
    • Dependencies: Slice 1 (1.8)
    • Details: After Slice 1, _fetch_metric returns {"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 in data.result. Reuse formatPrometheusValue for 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.
  • 2.10 Grep-verify no stale Prometheus*Widget references (GM-111)

    • Run: grep -rn "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/src
    • Verify: returns no matches (only PrometheusMetricWidget is 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.
  • 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 + PrometheusMetricWidget test if adapted in 2.9); backend still green.
    • Risk flag: qBit widget tests must stay green — QbittorrentSpeedWidget imports LineSeriesChart (unchanged), NOT any Metric* or Prometheus* widget. Confirm.

Slice 2 total: ~200280 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).
  • 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.
  • 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").
  • 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).
  • 3.5 config.yaml accuracy check

    • Verify: openspec/config.yaml still 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.
  • 3.6 CHANGELOG check

    • Verify: CHANGELOG.md [Unreleased] has the BREAKING entry naming grafana_url, grafana_api_key, datasource_uid (GM-114).

Risk flags summary

  1. (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 asserts POST /api/ds/query with a Grafana frames mock. This is the riskiest item — if the frames mock shape doesn't match a real Grafana /api/ds/query response, the tests pass but production breaks. Mitigation: recover the exact response-handling code from 65bae95 (task 1.2) so the mock matches what the recovered normalizer expects.

  2. (b) Slice 2 git mv must update ALL references (task 2.10). Any stale import of PrometheusChartWidget/PrometheusGaugeWidget/PrometheusMeanWidget will break the build. The grep in task 2.10 catches this. Known consumers: registry.ts (2.7), index.ts (2.8). Verify Dashboard.tsx, WidgetConfigDialog.tsx, WidgetInstance.tsx, and any service-tab references don't import the widget components directly (they resolve via the registry).

  3. (c) PrometheusMetricWidget shape 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.

  4. (d) qBit widgets must stay green. QbittorrentSpeedWidget uses LineSeriesChart (the shared renderer extracted in service-storage-harness Slice 2). This change does NOT touch LineSeriesChart. Verify qBit widget tests pass unchanged in both slices.

  5. (e) First non-additive canonical sync. The prometheus-charting canonical domain's transport-related SC- requirements are MODIFIED by this change. The sync phase (sdd-sync) must author ## MODIFIED Requirements deltas, not just ## ADDED. Spec §"Canonical delta intent" maps every GM→SC. This is flagged for the sync phase, not the apply slices.