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

266 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 |
```text
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 `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.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.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"`.
- [ ] **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 `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 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").
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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.
- [ ] **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.