# 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 |

```text
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). 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.

- [x] **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.

- [x] **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).

- [x] **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`).

- [x] **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`. 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`/`to` — `window_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.5–1.8). Errors never raise (GM-103).

- [x] **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": ...}`.

- [x] **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": ...}`.

- [x] **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"}`.

- [x] **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.

- [x] **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_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").

- [x] **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.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"`.

- [x] **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).

- [x] **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`.

- [x] **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_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.

- [x] **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.

- [x] **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`).

- [x] **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:** ~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.

- [x] **2.1 `git mv` chart 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 mv` to preserve history. Rename exported function `PrometheusChartWidget` → `MetricChartWidget`. The rendering code (`LineSeriesChart`, `useWidgetData`, `SectionCard`, loading/error/empty states) is preserved UNCHANGED (GM-111 "rendering code preserved").

- [x] **2.2 `git mv` chart 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 to `MetricChartWidget` from `../MetricChartWidget`. Update `describe` label + component references. Test cases (loading skeleton, chart with series data, error alert, empty state) are preserved.

- [x] **2.3 `git mv` gauge 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 export `PrometheusGaugeWidget` → `MetricGaugeWidget`. Rendering (recharts `RadialBarChart`, threshold bands, `toPercent`, `formatValue`) preserved UNCHANGED.

- [x] **2.4 `git mv` gauge 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.

- [x] **2.5 `git mv` mean 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 export `PrometheusMeanWidget` → `MetricMeanWidget`. Rendering (`formatMean`, `MetricCard`-style display, unit support) preserved UNCHANGED.

- [x] **2.6 `git mv` mean 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.

- [x] **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).

- [x] **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.

- [x] **2.9 Adapt `PrometheusMetricWidget` for 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_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.**

- [x] **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.

- [x] **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:** ~200–280 changed lines.

---

## Integration verification (post-slice)

- [x] **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).

- [x] **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.

- [x] **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").

- [x] **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).

- [x] **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.

- [x] **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.
