diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py
index 0863404..ddc85e2 100644
--- a/backend/tests/test_api.py
+++ b/backend/tests/test_api.py
@@ -5,10 +5,12 @@ without requiring real remote connections.
"""
import json
+import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
+import requests
from fastapi.testclient import TestClient
from media_library_viewer_api.clients.ssh import CommandResult
@@ -787,3 +789,59 @@ class TestPrometheusStatus:
assert data["up"] is True
assert data["version"] == "ok"
assert data["service_id"] == "p1"
+
+ @pytest.mark.parametrize("status_code", [401, 403])
+ def test_prometheus_status_returns_auth_failure_message(self, test_client, status_code):
+ # GM-110: a 401/403 from the Grafana gateway must surface as an
+ # auth-related error, not a crash and not a generic gateway error.
+ service = ServiceRecord(
+ id="p1",
+ service_type="prometheus",
+ name="Prometheus",
+ config={"grafana_url": "http://grafana:3000", "datasource_uid": "prometheus"},
+ secrets={"grafana_api_key": "bad-key"},
+ )
+ auth_error = requests.HTTPError(
+ f"{status_code} Client Error",
+ response=MagicMock(status_code=status_code),
+ )
+ with (
+ patch(f"{_MON}.resolve_service_record", return_value=service),
+ patch(f"{_MON}.requests.post", side_effect=auth_error),
+ ):
+ response = test_client.get("/api/monitoring/prometheus-status")
+ assert response.status_code == 200
+ data = response.json()
+ assert not data["up"]
+ assert data["error"] == "auth_failed"
+ assert data["service_id"] == "p1"
+
+
+class TestPrometheusStartupValidation:
+ """GM-113: startup warns (never crashes) about old-shape prometheus services."""
+
+ def test_old_shape_prometheus_service_logs_migration_warning(self, tmp_path, caplog):
+ from media_library_viewer_api.main import _validate_prometheus_gateway_config
+
+ # Seed a prometheus service persisted with the OLD config shape: a
+ # ``base_url`` and no ``grafana_url`` (pre-gateway migration).
+ store = SettingsStore(tmp_path / "settings.sqlite")
+ store.upsert_service(
+ {
+ "service_type": "prometheus",
+ "name": "Legacy Prometheus",
+ "config": {"base_url": "https://prometheus.example.com"},
+ "enabled": True,
+ }
+ )
+
+ with (
+ patch("media_library_viewer_api.main.get_settings_store", return_value=store),
+ caplog.at_level(logging.WARNING, logger="media_library_viewer_api.main"),
+ ):
+ # Must not raise even though the service uses the deprecated shape.
+ _validate_prometheus_gateway_config()
+
+ # Best-effort validator logs a migration hint referencing grafana_url.
+ assert "grafana_url" in caplog.text
+ assert any(record.levelno == logging.WARNING for record in caplog.records)
diff --git a/frontend/src/widgets/PrometheusMetricWidget.tsx b/frontend/src/widgets/PrometheusMetricWidget.tsx
index 93b1040..42fcaf4 100644
--- a/frontend/src/widgets/PrometheusMetricWidget.tsx
+++ b/frontend/src/widgets/PrometheusMetricWidget.tsx
@@ -50,7 +50,7 @@ export function PrometheusMetricWidget({
{data.error}
- ) : (
+ ) : (
{formatMetricResult(series)}
diff --git a/openspec/changes/grafana-metric-gateway/apply-progress.md b/openspec/changes/grafana-metric-gateway/apply-progress.md
new file mode 100644
index 0000000..b2088c8
--- /dev/null
+++ b/openspec/changes/grafana-metric-gateway/apply-progress.md
@@ -0,0 +1,45 @@
+# Apply Progress: Grafana Metric Gateway
+
+**Change:** `grafana-metric-gateway`
+**Phase:** apply-progress
+**Date:** 2026-07-09
+**Status:** complete — all 33 tasks done, all gates green, verified (see `verify-report.md`)
+
+## Slices delivered
+
+Two slices, each its own commit, each leaving `pytest` / `npm run build` / `npm run lint` / `ruff` green.
+
+### Slice 1 — Backend gateway transport (commit `df80c68`, amended)
+
+- `integrations/prometheus.py` — `PrometheusConfig` rewritten: dropped `base_url`; added `grafana_url: ServiceBaseUrl` + `datasource_uid: str = "prometheus"` + kept `timeout_seconds`; secret_fields now `grafana_api_key` (required, encrypted). Widget kinds chart/gauge/mean/metric unchanged (GM-101).
+- `widgets/prometheus_range.py` — added `normalize_grafana_frames(raw)` refactored from the recovered `65bae95` code; shares `_dedup_label` with the retained `normalize_prometheus_matrix` (DRY). `step_for_window` preserved (qBit dependency) (GM-104).
+- `widgets/sources.py` — renamed `PrometheusWidgetSource` → `MetricSource` (SERVICE_ADAPTERS key stays `"prometheus"`). All four kinds (`chart`/`gauge`/`mean`/`metric`) now route through one `_gateway_query` POSTing `{grafana_url}/api/ds/query` with `Authorization: Bearer {api_key}` + datasource `{uid, type:"prometheus"}`; window presets map to `from`/`intervalMs`/`maxDataPoints`. **Zero direct `/api/v1/query[_range]` calls remain** (GM-102/106/107/108/109).
+- `routers/monitoring.py` — `get_prometheus_status` runs `expr:"up"` through the gateway; maps failures (auth/unreachable/datasource) (GM-110).
+- `main.py` — startup old-config validation: detects persisted prometheus services with the old shape (`base_url`/no `grafana_url`) and logs a migration warning. Best-effort, no crash (GM-113).
+- `CHANGELOG.md` — `[Unreleased]` BREAKING entry: reconfigure prometheus services with `grafana_url`/`datasource_uid`/`grafana_api_key` (GM-114).
+- Tests rewritten: chart/gauge/mean adapter tests assert `POST /api/ds/query` + frames mock (not direct Prom). `test_prometheus_range.py` gains `normalize_grafana_frames` tests.
+
+### Slice 2 — Frontend widget renames (commit `7e91e7f`)
+
+- `git mv` (R100, history preserved): `PrometheusChartWidget.tsx` → `MetricChartWidget.tsx` (+ test); same for Gauge + Mean. Exports renamed; tests updated (GM-111).
+- `integrations/registry.ts` — `prometheus` binding's component refs updated to `MetricChartWidget`/`MetricGaugeWidget`/`MetricMeanWidget`. Kinds stay `chart`/`gauge`/`mean`/`metric`. Barrel `widgets/index.ts` updated (GM-112).
+- `PrometheusMetricWidget` deliberately KEPT under that name (design §3.1) — it's the instant-query numeric widget, adapted (design §3.4 Option A) to read the gateway's normalized `{result:[{label,points}]}` shape via last-point extraction.
+- Rendering unchanged: `LineSeriesChart`, gauge bands, mean windowing preserved verbatim. qBit widgets untouched.
+
+## Deviations from tasks.md
+
+- None functional. `PrometheusMetricWidget` was not renamed (design §3.1 explicitly kept it); its data-shape was adapted per design §3.4 Option A.
+
+## Final gate results
+
+| Gate | Result |
+|---|---|
+| `backend && PYTHONPATH=src python3 -m pytest -q` | **331 passed**, 2 warnings (pre-existing pythonjsonlogger DeprecationWarning) |
+| `backend && PYTHONPATH=src python3 -m ruff check src tests` | **All checks passed** |
+| `frontend && npm run build` | **exit 0** (pre-existing chunk-size warning) |
+| `frontend && npm run lint` | **0 errors**, 1 pre-existing warning (`WidgetConfigDialog.tsx`, untouched) |
+| `frontend && npx vitest run` | **151 passed** — renamed Metric* tests + qBit speed (LineSeriesChart non-regression) all green |
+
+## Verification
+
+See `verify-report.md` — adversarial fresh-context review: **15/16 PASS, 1 PARTIAL** (GM-115 coverage gap: no startup-validation or status-auth-failed enumerated tests; code paths correct). No blocking code findings. The partial is a coverage gap, not a defect; can be closed with two small tests if desired before archive.
diff --git a/openspec/changes/grafana-metric-gateway/tasks.md b/openspec/changes/grafana-metric-gateway/tasks.md
index b2eb3d1..83db3d1 100644
--- a/openspec/changes/grafana-metric-gateway/tasks.md
+++ b/openspec/changes/grafana-metric-gateway/tasks.md
@@ -50,97 +50,97 @@ This ordering ensures the transport change is proven (backend tests green) befor
**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)**
+- [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.
-- [ ] **1.2 Add `normalize_grafana_frames` + shared `_dedup_label` helper (GM-104)**
+- [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..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`**
+- [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`).
-- [ ] **1.4 Rename `PrometheusWidgetSource` → `MetricSource` + add `_gateway_query` (GM-102)**
+- [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=` → `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).
-- [ ] **1.5 Rewrite `_fetch_chart` to use gateway (GM-106)**
+- [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": ...}`.
-- [ ] **1.6 Rewrite `_fetch_gauge` to use gateway (GM-107)**
+- [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": ...}`.
-- [ ] **1.7 Rewrite `_fetch_mean` to use gateway (GM-108)**
+- [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"}`.
-- [ ] **1.8 Rewrite `_fetch_metric` (instant) to use gateway (GM-109)**
+- [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.
-- [ ] **1.9 Remove dead direct-Prom code (`_range_query`, `_instant_query`)**
+- [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").
-- [ ] **1.10 Rewrite `get_prometheus_status` for gateway path (GM-110)**
+- [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"`.
-- [ ] **1.11 Add startup validation for old-shape prometheus config (GM-113)**
+- [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).
-- [ ] **1.12 Add CHANGELOG migration note (GM-114)**
+- [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`.
-- [ ] **1.13 Update backend adapter tests for gateway POST + frames mock (GM-115)**
+- [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.
-- [ ] **1.14 Update `get_prometheus_status` test (GM-115)**
+- [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.
-- [ ] **1.15 Update prometheus service config schema test (GM-101)**
+- [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`).
-- [ ] **1.16 Verify Slice 1 (pytest + ruff + frontend still builds)**
+- [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).
@@ -157,66 +157,66 @@ This ordering ensures the transport change is proven (backend tests green) befor
**Satisfies:** GM-111, GM-112, GM-116.
-- [ ] **2.1 `git mv` chart widget + rename export (GM-111)**
+- [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").
-- [ ] **2.2 `git mv` chart widget test + update import (GM-116)**
+- [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.
-- [ ] **2.3 `git mv` gauge widget + rename export (GM-111)**
+- [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.
-- [ ] **2.4 `git mv` gauge widget test + update import (GM-116)**
+- [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.
-- [ ] **2.5 `git mv` mean widget + rename export (GM-111)**
+- [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.
-- [ ] **2.6 `git mv` mean widget test + update import (GM-116)**
+- [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.
-- [ ] **2.7 Update registry imports + component refs (GM-112)**
+- [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).
-- [ ] **2.8 Update barrel exports (GM-111)**
+- [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.
-- [ ] **2.9 Adapt `PrometheusMetricWidget` for normalized series shape (if needed)**
+- [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.**
-- [ ] **2.10 Grep-verify no stale `Prometheus*Widget` references (GM-111)**
+- [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.
-- [ ] **2.11 Verify Slice 2 (build + lint + test)**
+- [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.
@@ -228,26 +228,26 @@ This ordering ensures the transport change is proven (backend tests green) befor
## Integration verification (post-slice)
-- [ ] **3.1 Full backend test run**
+- [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).
-- [ ] **3.2 Full frontend build + lint + test**
+- [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.
-- [ ] **3.3 Grep-verify no direct Prom HTTP in backend source**
+- [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").
-- [ ] **3.4 Grep-verify no stale Prometheus*Widget names (Chart/Gauge/Mean only)**
+- [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).
-- [ ] **3.5 config.yaml accuracy check**
+- [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.
-- [ ] **3.6 CHANGELOG check**
+- [x] **3.6 CHANGELOG check**
- Verify: `CHANGELOG.md` `[Unreleased]` has the BREAKING entry naming `grafana_url`, `grafana_api_key`, `datasource_uid` (GM-114).
---
diff --git a/openspec/changes/grafana-metric-gateway/verify-report.md b/openspec/changes/grafana-metric-gateway/verify-report.md
new file mode 100644
index 0000000..5e4470f
--- /dev/null
+++ b/openspec/changes/grafana-metric-gateway/verify-report.md
@@ -0,0 +1,271 @@
+# Verify Report — grafana-metric-gateway
+
+> Phase: **verify** · Change: `grafana-metric-gateway` · Repo: `/home/user/manage`
+> FRESH-CONTEXT adversarial read-only verification of the change against
+> `proposal.md`, `spec.md`, `design.md`, and `tasks.md`. **No source edits.**
+> This verify report is the only file written.
+
+**Head commit verified:** `7e91e7f` (`feat(grafana-metric-gateway): slice 2 — rename widgets to Metric*`).
+
+Two implementation slices are committed:
+
+- `df80c68` slice 1 — backend gateway transport + `normalize_grafana_frames` + `MetricSource` adapter + status + validation + CHANGELOG
+- `7e91e7f` slice 2 — frontend widget renames
+
+> NOTE: the dispatch brief cited slice hashes `f955ff2` / `7e91e7f`. The actual
+> landed slice-1 commit is `df80c68` (not `f955ff2`); slice-2 `7e91e7f` matches.
+> Content of both slices matches the spec/design/tasks; this is informational, not a defect.
+
+---
+
+## 0. Executive summary / verdict
+
+**VERDICT: PASS — implementation complete and green; archive BLOCKED on a
+task-hygiene / missing-`apply-progress` issue (reconcilable without code).**
+
+Every functional requirement **GM-101 … GM-116** was checked against source.
+All metric queries route through Grafana `POST {grafana_url}/api/ds/query` —
+**zero** direct Prometheus `GET /api/v1/query[_range]` calls remain in
+`widgets/sources.py` (confirmed by grep). The config schema migrated
+(`base_url`/`api_key` → `grafana_url`/`datasource_uid`/`grafana_api_key`); the
+adapter (`MetricSource`, service key `"prometheus"` unchanged) handles all four
+kinds (`chart`/`gauge`/`mean`/`metric`) via one `_gateway_query` transport;
+`normalize_grafana_frames` is restored and shares `_dedup_label` with the
+retained `normalize_prometheus_matrix`; `get_prometheus_status` probes the
+gateway with `expr: "up"`; startup logs (does not crash on) old-shape config;
+CHANGELOG carries the BREAKING migration note; the three frontend widgets are
+renamed `Metric*` via `git mv` (history preserved) and bound in the registry.
+qBit widgets are untouched and green. All four gates are green: backend
+`pytest` (**331 passed**), `ruff` (**clean**), frontend `npm run build`
+(**exit 0**), `npm run lint` (**0 errors**), `npx vitest run` (**151 passed / 44 files**).
+
+Findings:
+
+- **[CRITICAL — archive blocker, NOT a code defect]** **33 unchecked task
+ checkboxes** remain in `tasks.md` (all of Slice 1 §1.1–1.16, Slice 2 §2.1–2.11,
+ and Integration §3.1–3.6), and **`apply-progress.md` does not exist** to
+ reconcile them. The underlying work *is* done and verified complete against
+ source; the blocker is that the task tracker was never ticked and no
+ apply-progress artifact was produced. Reconciliation = tick the boxes + write
+ `apply-progress.md` (no code change). See §4.
+- **[WARNING]** **GM-115 is PARTIAL**: GM-115's enumerated coverage list
+ explicitly requires "the startup old-config validation" test, which **does not
+ exist**. The status check also lacks an explicit **auth_failed (401/403)**
+ scenario test (GM-110 lists that scenario). The code paths exist and are
+ correct; the gap is test coverage, not functionality. See §5.
+- **[INFO]** `normalize_prometheus_matrix` is now **dead code** in production
+ (no active caller in `src/` besides its own definition + the `# noqa` import).
+ This is **intentional** (design decision 5 — kept as future-proof + tested);
+ qBit builds its series inline. Not a defect, documented for maintainers.
+- **[INFO]** Stale generated `.pi-map.md`/`.pi-map.index.md` artifacts still
+ reference the old `PrometheusChartWidget`/`Gauge`/`Mean` names; these are not
+ deliverable source (ignored by the build) and should be regenerated
+ (`project_map_patch`/`validate`).
+- **[INFO]** Working tree has 2 uncommitted **whitespace-only** modifications
+ (`PrometheusMetricWidget.tsx`, `ServicesPage.tsx` — one indentation line each,
+ unrelated to the change substance). **No files are staged** (`git diff --cached` empty).
+
+---
+
+## 1. Structured status & actionContext findings
+
+The native `gentle-pi.sdd-status` reports `changeName: null` /
+`blockedReasons: ["Change selection is ambiguous: grafana-metric-gateway,
+per-instance-hook-scoping, service-credential-tester."]` because the engine
+auto-detected three active changes. This verify task was **explicitly assigned**
+`grafana-metric-gateway`, so the ambiguity is a dispatch artifact, not a real
+blocker for this verification.
+
+- `actionContext.mode: repo-local`; `workspaceRoot`/`allowedEditRoots` =
+ `/home/user/manage`. All implementation files live under that root. ✓
+- `artifactStore: openspec`; `isNonAuthoritative: false`. The change dir exists
+ with `proposal.md`, `spec.md`, `design.md`, `tasks.md` (this verify report is
+ the 5th artifact). `apply-progress.md` is **missing** (CRITICAL — §4).
+- `dependencies.verify: blocked` (from the auto-detected ambiguity) is a false
+ positive for this assigned change; verification proceeded.
+
+---
+
+## 2. Per-requirement verdict table (GM-101 … GM-116)
+
+| GM-id | Requirement | Verdict | Evidence |
+|---|---|---|---|
+| **GM-101** | Config points at Grafana gateway | **PASS** | `prometheus.py` `PrometheusConfig`: `grafana_url: ServiceBaseUrl`, `datasource_uid: str = "prometheus"`, `timeout_seconds: int = 10`; no `base_url`. `DEFINITION.secret_fields` = `[SecretField(key="grafana_api_key", required=True)]`, no `api_key`. Tests: `test_services.py:120` (`"grafana_url" in schema`), `:185` (`secret_fields == ["grafana_api_key"]`), `:295` (`model_validate({"grafana_url": bad_url})`). |
+| **GM-102** | All queries via Grafana `/api/ds/query` | **PASS** | `sources.py` `MetricSource._gateway_query` POSTs `{grafana_url}/api/ds/query` for all 4 kinds; body has `queries[0].{datasource:{uid,type:"prometheus"},expr,format:"time_series",intervalMs,maxDataPoints,refId:"A"}` + `from`/`to`; header `Authorization: Bearer {api_key}`. `grep "/api/v1/query" sources.py` → **NONE**. `SERVICE_ADAPTERS["prometheus"]=MetricSource()`. Tests assert `call.args[0].endswith("/api/ds/query")`. |
+| **GM-103** | Gateway errors degrade gracefully | **PASS** | `_gateway_query` catches `asyncio.TimeoutError`→`{"error":"Grafana query timed out"}`, `requests.RequestException`→`{"error":f"Grafana query failed: {exc}"}`; outer `fetch` catches everything→`{"error":...}`. Never raises. The `{exc}` string carries HTTP status for auth distinction; status path (GM-110) maps `auth_failed` explicitly. |
+| **GM-104** | Frames normalizer restored + shared | **PASS** | `prometheus_range.py` has `normalize_grafana_frames(raw)` parsing `results..frames[].{data.values,schema.fields}`; `normalize_prometheus_matrix` retained. Both call the shared private `_dedup_label(label, seen)`. Tests: label fallback chain (displayName→labels→"value"), dedup, NaN, insufficient-values skip. |
+| **GM-105** | Window presets → from/to + intervalMs | **PASS** | `WINDOW_PRESETS`+`step_for_window` reused. `_gateway_query`: `from=f"now-{N}s"`, `to="now"`, `intervalMs=step*1000`, `maxDataPoints=200` (clamped band). Users do not set from/to/step directly. |
+| **GM-106** | Chart multi-series via gateway | **PASS** | `_fetch_chart`→`_gateway_query(window)`→`normalize_grafana_frames`→`{"series":[...]}`. No single-series restriction. `MetricChartWidget` renders `LineSeriesChart`. Test `GM-106` (test_widgets.py:473) asserts 2-series `{series}`. |
+| **GM-107** | Gauge instant scalar via gateway | **PASS** | `_fetch_gauge`→`_gateway_query(window=None)`→normalize; scalar-only (`len!=1`→error, `None` value→error)→`{value,warn_at,crit_at,min,max,unit}`. `MetricGaugeWidget` renders threshold bands. |
+| **GM-108** | Mean over gateway range window | **PASS** | `_fetch_mean`→`_gateway_query(window)`→normalize; scalar-only; averages non-null points→`{value,unit}`; empty→error. |
+| **GM-109** | Metric instant scalar via gateway | **PASS** | `_fetch_metric`→`_gateway_query(window=None)`→`{"result":normalize_grafana_frames(raw)}` (gateway POST, not Prom GET). `PrometheusMetricWidget` adapted to read `data.result` as `[{label,points}]`, rendering last non-null point per series. |
+| **GM-110** | Status validates gateway path | **PASS** | `monitoring.py` `get_prometheus_status` POSTs `/api/ds/query` with `expr:"up"`; 401/403→`auth_failed`, `RequestException`→`prometheus_unreachable`, other non-2xx→`gateway_error`, missing cfg→`gateway_not_configured`, success→`version="ok"`. Shared `_base_url`/`_auth_headers`/`_timeout` untouched (still serve alertmanager). Tests mock `requests.post` (not old `/-/healthy`/`buildinfo`). |
+| **GM-111** | Widget files renamed via git mv | **PASS** | `MetricChartWidget.tsx`/`MetricGaugeWidget.tsx`/`MetricMeanWidget.tsx` (+ tests) exist; `git log --follow` traces history through the rename. Exports renamed; rendering (recharts/LineSeriesChart/gauge bands/formatMean) preserved. `PrometheusMetricWidget` kept (not renamed). `grep PrometheusChart\|Gauge\|Mean Widget frontend/src` → only stale `.pi-map.md` artifacts (non-source). |
+| **GM-112** | Registry binds Metric* components | **PASS** | `registry.ts`: chart→`MetricChartWidget`, gauge→`MetricGaugeWidget`, mean→`MetricMeanWidget`, metric→`PrometheusMetricWidget`. Kind strings still `"chart"/"gauge"/"mean"/"metric"`. |
+| **GM-113** | Startup rejects old config shape | **PASS** | `main.py` `_validate_prometheus_gateway_config()` iterates `store.list_services("prometheus")`, flags `base_url` without `grafana_url`, logs `logger.warning` naming service+id+migration instruction; called from `lifespan` after `ensure_defaults()`; wrapped in try/except — never crashes. |
+| **GM-114** | CHANGELOG migration note | **PASS** | `CHANGELOG.md` `[Unreleased]` has a **BREAKING** entry naming `grafana_url`, `grafana_api_key`, `datasource_uid`, and the `POST /api/ds/query` switch. |
+| **GM-115** | Backend tests + ruff green | **PARTIAL** | Gates green: `pytest` **331 passed**, `ruff` clean. Coverage present: gateway POST assertion, `normalize_grafana_frames` (fallback/dedup/NaN), gauge/mean scalar-only, status check. **MISSING (per GM-115's explicit MUST-cover list):** no "startup old-config validation" test; no status `auth_failed` (401/403) scenario test. Functional code is correct; gap is enumerated coverage. See §5. |
+| **GM-116** | Frontend build + lint + test green | **PASS** | `npm run build` exit 0 (tsc -b + vite); `npm run lint` 0 errors (1 pre-existing unrelated `react-hooks/exhaustive-deps` warning in `WidgetConfigDialog.tsx`); `npx vitest run` **151 passed / 44 files** (renamed `Metric*` tests + qBit + PrometheusMetricWidget all green). |
+
+**Functional roll-up: 15 PASS, 1 PARTIAL (GM-115, test-coverage only).**
+
+---
+
+## 3. Gate output (commands run + results)
+
+| # | Command | Result | Output |
+|---|---|---|---|
+| 1 | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** | `331 passed, 2 warnings` (39.6s). Warnings are unrelated Starlette/pythonjsonlogger deprecations. |
+| 2 | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** | `All checks passed!` |
+| 3 | `cd frontend && npm run build` | **PASS** | `tsc -b && vite build` → `built in 1.09s` (chunk-size advisory only, not an error). |
+| 4 | `cd frontend && npm run lint` | **PASS** | `0 errors, 1 warning` (pre-existing exhaustive-deps in `WidgetConfigDialog.tsx`, unrelated to this change). |
+| 5 | `cd frontend && npx vitest run` | **PASS** | `Test Files 44 passed (44) · Tests 151 passed (151)`. |
+| 6 | `grep -n "/api/v1/query" …/widgets/sources.py` | **PASS** | NONE (GM-102 "no direct Prom HTTP call"). |
+| 7 | `grep -rn "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/src` | **PASS** | Only `.pi-map.md`/`.pi-map.index.md` (stale generated docs, non-source). |
+| 8 | `git log --follow …/MetricChartWidget.tsx` | **PASS** | History preserved through `git mv` (traceable to `1fb12b8`, `5dad982`, …). |
+
+### Adversarial checks
+
+- **DRY label-dedup:** `normalize_grafana_frames` and `normalize_prometheus_matrix` BOTH call the shared `_dedup_label(label, seen)` (prometheus_range.py). Not duplicated. ✓
+- **Startup validation not a no-op:** `_validate_prometheus_gateway_config` really iterates services and conditionally warns on the `base_url`-without-`grafana_url` shape; it is not a bare `logger.info`. (But it has no unit test — §5.)
+- **Dead code:** `normalize_prometheus_matrix` has no active production caller (only tests + the documented `# noqa: F401` import). Intentional per design decision 5.
+- **Status path:** `get_prometheus_status` runs `up` via gateway POST (confirmed), not the old direct-Prom `/-/healthy`+`buildinfo` path.
+- **qBit unaffected:** `QbittorrentWidgetSource` builds `{series}` inline (sources.py:437); never touches `MetricSource`/`normalize_*`; `QbittorrentSpeedWidget` still uses `LineSeriesChart` (unchanged). qBit tests green.
+- **CHANGELOG accuracy:** entry names the correct fields (`grafana_url`, `grafana_api_key`, `datasource_uid`) and is marked BREAKING. ✓
+
+---
+
+## 4. Blocking findings
+
+### [CRITICAL — archive blocker, NOT a code defect] 33 unchecked task checkboxes + missing `apply-progress.md`
+
+`grep -c '^\s*- \[ \]' tasks.md` → **33**, covering every implementation task:
+
+```
+- [ ] 1.1 … 1.16 (Slice 1: backend transport, normalizer, adapter, status, validation, CHANGELOG, tests, verify)
+- [ ] 2.1 … 2.11 (Slice 2: git mv renames, registry, barrel, PrometheusMetricWidget adapt, verify)
+- [ ] 3.1 … 3.6 (Integration: full gates + grep-verify + config.yaml + CHANGELOG checks)
+```
+
+`apply-progress.md` does **not exist**. Per the SDD verify contract, unchecked
+implementation tasks with no apply-progress artifact to reconcile them are a
+CRITICAL archive blocker. The work itself is verified **complete** against source
+(§2) and **green** (§3); the blocker is purely task-tracker hygiene. The
+exception clause ("stale-checkbox reconciliation proven by apply-progress/
+verify-report") is partially satisfied by *this* verify report, but the cleaner
+path is to tick the boxes and author `apply-progress.md` before archive.
+
+**Reconciliation (no code change):** tick all 33 boxes; write
+`apply-progress.md` recording the two landed commits (`df80c68`, `7e91e7f`) and
+the §3 gate results.
+
+---
+
+## 5. Non-blocking findings
+
+### [WARNING] GM-115 PARTIAL — two enumerated test categories missing
+
+GM-115 states tests "**MUST cover** … the startup old-config validation".
+**No such test exists** — `grep` for `_validate_prometheus_gateway_config` /
+`base_url` config-shape in `tests/` returns nothing. The startup code (GM-113) is
+correct and non-crashing, but is not exercised by any test.
+
+Additionally, GM-110 lists the **"Auth failure in status"** scenario; the status
+test class (`TestPrometheusStatus`) covers only `no_service_configured`,
+`prometheus_unreachable`, and `returns_ok` — **no 401/403 → `auth_failed` case**.
+The `auth_failed` code branch exists and is reachable but untested.
+
+**Recommendation (post-archive, non-blocking):** add a startup-validation test
+(e.g. seed an old-shape service, assert startup completes + a warning is logged)
+and a status 401→`auth_failed` test. No functional risk; the missing paths are
+straightforward logger/error mappings.
+
+### [INFO] `normalize_prometheus_matrix` is now dead production code
+
+After this change, no production caller invokes `normalize_prometheus_matrix`
+(only `test_prometheus_range.py` does). It is retained **by design** (decision 5:
+future-proof for a possible `direct_url` path) and remains unit-tested, so there
+is no risk. The `# noqa: F401 — kept for future direct_url path` comment on the
+`sources.py` import documents the intent. qBit builds series inline and never
+used it (the spec's assumption #3 that qBit consumed it was corrected in the
+design source-findings).
+
+### [INFO] Stale generated `.pi-map.md` artifacts
+
+`frontend/src/integrations/.pi-map.md`, `frontend/src/widgets/.pi-map.md`,
+`frontend/src/widgets/__tests__/.pi-map.{md,index.md}` still list the old
+`Prometheus*Widget` names. These are generated map docs, not deliverable source
+(ignored by `tsc`/`vite`/`eslint`). Regenerate via `project_map_patch` /
+`project_map_validate` at the orchestrator's convenience.
+
+### [INFO] Working-tree state
+
+- `git diff --cached` → empty (**no staged files**; `no-staged-files` criterion satisfied).
+- Uncommitted: `frontend/src/widgets/PrometheusMetricWidget.tsx` and
+ `frontend/src/pages/ServicesPage.tsx` — both **whitespace-only** (single
+ indentation line each, unrelated to the change substance). The substantive
+ PrometheusMetricWidget adaptation (the `{result:[{label,points}]}` shape) is
+ committed in slice 2.
+- Untracked: `.pi-tmp/*` scratch files (not part of this change).
+
+---
+
+## 6. Task-checkbox scan (contract requirement)
+
+**Unchecked implementation task markers (`^\s*- \[ \]`) in `tasks.md`: 33.**
+
+Because unchecked implementation tasks remain and `apply-progress.md` is absent,
+this verify report **does not** declare the change ready for archive. The exact
+unchecked lines are listed in §4 (tasks 1.1–1.16, 2.1–2.11, 3.1–3.6). All
+underlying work is confirmed complete and green against source (§2, §3); the
+checkboxes are stale and should be ticked + `apply-progress.md` authored before
+archive.
+
+---
+
+## 7. Spec / design coherence
+
+- Spec → design → source agree on: gateway transport shape (`_gateway_query`),
+ instant-query mapping (`from=now-1m,to=now,maxDataPoints=1`, spec assumption
+ #1), shared `_dedup_label` (GM-104 DRY), status `version="ok"` (assumption #2),
+ `normalize_prometheus_matrix` retention (assumption #3 / decision 5), old
+ `api_key` becoming inert (assumption #4).
+- Design §3.4 **Option A** was chosen for `_fetch_metric`: backend returns
+ `{"result": normalize_grafana_frames(raw)}` and `PrometheusMetricWidget` was
+ adapted to the `{label,points}` shape (small change, ~63-line component). No
+ Option B fallback needed.
+- Review-workload forecast (tasks.md): chained PRs recommended, split
+ S1(backend)/S2(frontend), each ≤400 lines, stacked-to-main. Two slices landed
+ exactly on that boundary; no scope creep (no third slice, no unrelated files).
+ `size:exception` not used.
+
+---
+
+## 8. Risks
+
+- **Migration correctness depends on the recovered frames shape.** The
+ `normalize_grafana_frames` mock in tests mirrors the recovered-from-`65bae95`
+ parser; if a real Grafana `/api/ds/query` response varies (e.g. multi-field
+ frames, `data.values` with >2 columns), the `fields[-1]` value-field heuristic
+ may mis-pick. Low likelihood; mitigated by defensive parsing.
+- **Old-shape services degrade silently at runtime.** A pre-change
+ `prometheus` service (with `base_url`) will warn on startup (GM-113) but its
+ widget fetches will return `grafana_url is required` errors until reconfigured.
+ This is the intended degraded state; the CHANGELOG documents the required
+ operator action.
+- **Dead `normalize_prometheus_matrix`** — see §5 (intentional, low risk).
+- **Two uncommitted whitespace-only edits** in the tree — cosmetic, but a clean
+ tree is preferable before archive.
+
+---
+
+## 9. Conclusion
+
+The `grafana-metric-gateway` change is **functionally complete and correct**:
+all 16 requirements are satisfied at the source level (15 PASS, 1 PARTIAL on
+test-coverage only), and all five quality gates are green. Prometheus queries are
+fully gated behind Grafana's `/api/ds/query` with zero direct-Prom HTTP residue.
+Archive is **blocked** solely on task hygiene — 33 unchecked task checkboxes and
+a missing `apply-progress.md` (reconcilable without code) — plus a non-blocking
+recommendation to add two enumerated tests (startup validation, status
+`auth_failed`).