spec(grafana-metric-gateway): verify + close GM-115 + reconcile tracking

Add 3 test cases (startup old-config validation warning; status auth_failed
for 401/403) closing the GM-115 PARTIAL. Write apply-progress.md, tick all 33
tasks, add verify-report.md (15/16 PASS, 1 PARTIAL->PASS). Gates green: 331+
pytest, ruff clean, npm build+lint 0 errors, 151 vitest.
This commit is contained in:
Developer
2026-07-09 21:53:18 +00:00
parent 7e91e7f931
commit c886fcdf09
5 changed files with 408 additions and 34 deletions
+58
View File
@@ -5,10 +5,12 @@ without requiring real remote connections.
""" """
import json import json
import logging
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
import pytest import pytest
import requests
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from media_library_viewer_api.clients.ssh import CommandResult from media_library_viewer_api.clients.ssh import CommandResult
@@ -787,3 +789,59 @@ class TestPrometheusStatus:
assert data["up"] is True assert data["up"] is True
assert data["version"] == "ok" assert data["version"] == "ok"
assert data["service_id"] == "p1" 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)
@@ -50,7 +50,7 @@ export function PrometheusMetricWidget({
<Alert variant="destructive"> <Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription> <AlertDescription>{data.error}</AlertDescription>
</Alert> </Alert>
) : ( ) : (
<pre className="whitespace-pre-wrap text-sm"> <pre className="whitespace-pre-wrap text-sm">
{formatMetricResult(series)} {formatMetricResult(series)}
</pre> </pre>
@@ -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.
@@ -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. **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) - Files: `backend/src/media_library_viewer_api/integrations/prometheus.py` (modify)
- Lines: ~15 - Lines: ~15
- Dependencies: none - 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. - 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) - Files: `backend/src/media_library_viewer_api/widgets/prometheus_range.py` (modify)
- Lines: ~80 - Lines: ~80
- Dependencies: none - 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). - 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`** - [x] **1.3 Add backend unit tests for `normalize_grafana_frames` + `_dedup_label`**
- Files: `backend/tests/test_prometheus_range.py` (modify) - Files: `backend/tests/test_prometheus_range.py` (modify)
- Lines: ~80 - Lines: ~80
- Dependencies: 1.2 - 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`). - 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) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify)
- Lines: ~90 - Lines: ~90
- Dependencies: 1.2 - 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). - 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)** - [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) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify, same file as 1.4)
- Lines: ~10 (within the ~90 of 1.4) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 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": ...}`. - 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) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify, same file)
- Lines: ~10 (within the ~90 of 1.4) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 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": ...}`. - 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) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify, same file)
- Lines: ~10 (within the ~90 of 1.4) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 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"}`. - 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) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify, same file)
- Lines: ~10 (within the ~90 of 1.4) - Lines: ~10 (within the ~90 of 1.4)
- Dependencies: 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. - 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) - Files: `backend/src/media_library_viewer_api/widgets/sources.py` (modify, same file)
- Lines: ~50 (deletions) - Lines: ~50 (deletions)
- Dependencies: 1.41.8 - 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"). - 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) - Files: `backend/src/media_library_viewer_api/routers/monitoring.py` (modify)
- Lines: ~30 - Lines: ~30
- Dependencies: 1.4 - 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"`. - 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) - Files: `backend/src/media_library_viewer_api/main.py` (modify)
- Lines: ~15 - Lines: ~15
- Dependencies: none - 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). - 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) - Files: `CHANGELOG.md` (modify)
- Lines: ~5 - Lines: ~5
- Dependencies: none - 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`. - 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) - Files: `backend/tests/test_widgets.py` (modify)
- Lines: ~80 - Lines: ~80
- Dependencies: 1.41.8 - 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. - 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) - Files: `backend/tests/test_api.py` (modify)
- Lines: ~20 - Lines: ~20
- Dependencies: 1.10 - 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. - 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) - Files: `backend/tests/test_services.py` (modify)
- Lines: ~10 - Lines: ~10
- Dependencies: 1.1 - 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`). - 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 backend && PYTHONPATH=src python3 -m pytest -q && PYTHONPATH=src python3 -m ruff check src tests`
- Run: `cd frontend && npm run build && npm run lint` - 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). - 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. **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) - Files: `frontend/src/widgets/PrometheusChartWidget.tsx``frontend/src/widgets/MetricChartWidget.tsx` (git mv)
- Lines: ~3 changed (rename export + import in self) - Lines: ~3 changed (rename export + import in self)
- Dependencies: Slice 1 (1.4) - 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"). - 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) - 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) - Lines: ~5 changed (import path, component name, describe label)
- Dependencies: 2.1 - 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. - 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) - Files: `frontend/src/widgets/PrometheusGaugeWidget.tsx``frontend/src/widgets/MetricGaugeWidget.tsx` (git mv)
- Lines: ~3 changed - Lines: ~3 changed
- Dependencies: Slice 1 (1.4) - Dependencies: Slice 1 (1.4)
- Details: `git mv`. Rename export `PrometheusGaugeWidget``MetricGaugeWidget`. Rendering (recharts `RadialBarChart`, threshold bands, `toPercent`, `formatValue`) preserved UNCHANGED. - 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) - Files: `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx``frontend/src/widgets/__tests__/MetricGaugeWidget.test.tsx` (git mv)
- Lines: ~5 changed - Lines: ~5 changed
- Dependencies: 2.3 - Dependencies: 2.3
- Details: `git mv`. Update import + describe label + component refs. Test cases (loading, with/without bands, error, empty) preserved. - 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) - Files: `frontend/src/widgets/PrometheusMeanWidget.tsx``frontend/src/widgets/MetricMeanWidget.tsx` (git mv)
- Lines: ~3 changed - Lines: ~3 changed
- Dependencies: Slice 1 (1.4) - Dependencies: Slice 1 (1.4)
- Details: `git mv`. Rename export `PrometheusMeanWidget``MetricMeanWidget`. Rendering (`formatMean`, `MetricCard`-style display, unit support) preserved UNCHANGED. - 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) - Files: `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx``frontend/src/widgets/__tests__/MetricMeanWidget.test.tsx` (git mv)
- Lines: ~5 changed - Lines: ~5 changed
- Dependencies: 2.5 - Dependencies: 2.5
- Details: `git mv`. Update import + describe label + component refs. Test cases (loading, data display, error, empty) preserved. - 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) - Files: `frontend/src/integrations/registry.ts` (modify)
- Lines: ~10 - Lines: ~10
- Dependencies: 2.1, 2.3, 2.5 - 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). - 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) - Files: `frontend/src/widgets/index.ts` (modify)
- Lines: ~3 - Lines: ~3
- Dependencies: 2.1, 2.3, 2.5 - Dependencies: 2.1, 2.3, 2.5
- Details: Change three exports: `export { MetricChartWidget } from "./MetricChartWidget"` (was Prometheus), same for Gauge + Mean. `PrometheusMetricWidget` export stays. - 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) - Files: `frontend/src/widgets/PrometheusMetricWidget.tsx` (modify, NOT renamed)
- Lines: ~1525 (depends on whether Option A or B from design §3.4) - Lines: ~1525 (depends on whether Option A or B from design §3.4)
- Dependencies: Slice 1 (1.8) - 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.** - 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` - Run: `grep -rn "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/src`
- Verify: returns no matches (only `PrometheusMetricWidget` is allowed to remain). - 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. - **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 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) - 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. - 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) ## 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` - Run: `cd backend && PYTHONPATH=src python3 -m pytest -q`
- Verify: all tests pass (no direct-Prom HTTP assertions remain). - 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` - 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. - 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` - 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"). - 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` - Run: `grep -rn "PrometheusChartWidget\|PrometheusGaugeWidget\|PrometheusMeanWidget" frontend/src`
- Verify: no matches (GM-111). - 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. - 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). - Verify: `CHANGELOG.md` `[Unreleased]` has the BREAKING entry naming `grafana_url`, `grafana_api_key`, `datasource_uid` (GM-114).
--- ---
@@ -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.11.16, Slice 2 §2.12.11,
and Integration §3.13.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.<refId>.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.11.16, 2.12.11, 3.13.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`).