# Verify Report — prometheus-direct-charting
> Phase: **verify** · Change: `prometheus-direct-charting` · 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:** `67ca0fc` (`feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog`).
Three implementation slices are committed:
- `5dad982` slice 1 — prom range query + chart rebrand
- `65bae95` slice 2 — gauge + mean widgets
- `67ca0fc` slice 3 — grafana removal + config rewrite + changelog
> NOTE: the dispatch brief cited slice hashes `58be6e0` / `ba94317` (an earlier
> amend state). The actual landed commits are `65bae95` / `67ca0fc`. Content of
> all three 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 **SC-101 … SC-127** was checked against source and
**passes**. Grafana is fully excised from the live code paths (service type,
adapters, widgets, hook, API client, type, status endpoint, nav entry, service
tab). Prometheus is the direct chart source: `chart` (multi-series recharts line
chart via `/api/v1/query_range`), `gauge` (instant scalar + threshold bands via
recharts `RadialBarChart`), and `mean` (client-side average) are wired in both
registries with consistent schemas. The shared `normalize_prometheus_matrix` /
`step_for_window` helpers are extracted and unit-tested. All four gates are
green: backend `pytest` (**293 passed**), `ruff` (**clean**), frontend
`npm run build` (**exit 0**), `npm run lint` (**0 errors**).
Findings:
- **[CRITICAL — archive blocker, NOT a code defect]** **19 unchecked
implementation/verification task checkboxes** remain in `tasks.md` (all of
Slice 3 §3.1–3.14 and Integration §4.1–4.5), 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 updated and no
apply-progress artifact was produced. Reconciliation = tick the boxes + write
`apply-progress.md` (no code change). See §4.
- **[WARNING]** SC-125 is **PARTIAL**: none of the three new widget tests
(`PrometheusChartWidget` / `PrometheusGaugeWidget` / `PrometheusMeanWidget`)
exercises an explicit **loading** state (`isLoading:true` → Skeleton). Error
and rendered-data cases exist for all three. The loading branch exists in each
component; the gap is test coverage, not functionality. See §5.
- **[INFO]** Stale generated `.pi-map.md` files still reference Grafana /
`ObservabilityPage.tsx`; these are not deliverable source and are ignored by
> SC-115/116, but should be regenerated (`project_map_patch`/`validate`). The
> `config.yaml` context block also still names `ObservabilityPage.tsx` (refactored
away into `service-tabs/`) — not an SC-121 criterion, minor doc staleness.
- **[INFO]** Working tree is not pristine: an uncommitted cosmetic reformat of
`frontend/src/pages/service-tabs/MediaTab.tsx` (unrelated to this change) plus
untracked `.pi-tmp/*` and `openspec/changes/service-storage-harness/` (the
proposal for the *separate* change SC-127 requires independence from).
---
## 1. Structured status & actionContext findings
The native `gentle-pi.sdd-status` reports `changeName: null` /
`blockedReasons: ["Change selection is ambiguous: …"]` because the engine
auto-detected four active changes. This verify task was **explicitly assigned**
`prometheus-direct-charting`; the ambiguity is a parent-resolution artifact and
does not block this phase.
- `artifactStore: openspec`; change root
`openspec/changes/prometheus-direct-charting/`.
- Artifacts present: `proposal.md`, `spec.md`, `design.md`, `tasks.md`.
- **`apply-progress.md`: MISSING** (confirmed: directory contains only the four
planning docs + this report). This is the root cause of the §4 archive blocker.
- `actionContext`: `mode: repo-local`, `workspaceRoot: /home/user/manage`,
`allowedEditRoots: ["/home/user/manage"]`, `warnings: []`. Implementation
ownership and all target files are provably inside the authoritative workspace. ✓
- The `service-storage-harness` change exists only as an untracked proposal
folder; SC-127 (independence) holds — this change builds/tests green without it.
## 2. Gate results (actual output, run at `67ca0fc`)
| Gate | Command | Result | Evidence |
|------|---------|--------|----------|
| Backend tests | `cd backend && PYTHONPATH=src python3 -m pytest -q` | **PASS** | **293 passed, 2 warnings** in 36.26s. Includes `test_prometheus_range.py` (9) + chart/gauge/mean adapter tests. |
| Backend lint | `cd backend && PYTHONPATH=src python3 -m ruff check src tests` | **PASS** | `All checks passed!` |
| Frontend build | `cd frontend && npm run build` (`tsc -b` + `vite build`) | **PASS** exit 0 | `✓ built in 1.08s`; 2543 modules transformed. Non-fatal `>500 kB` chunk-size warning (pre-existing). |
| Frontend lint | `cd frontend && npm run lint` (`eslint .`) | **PASS** exit 0 | `0 errors, 1 warning`. The warning is `react-hooks/exhaustive-deps` in `WidgetConfigDialog.tsx:370` — **pre-existing, untouched by this change** (no slice modified that file). |
### Grep gates (SC-115 / SC-116 / SC-117 / SC-120)
```
grep -rin grafana backend/src --include='*.py'
→ 3 matches, ALL in prometheus_range.py docstrings/comments describing the
extraction/removal (explicitly allowed by SC-115). No live grafana code.
grep -rin grafana frontend/src
→ matches ONLY in: stale generated .pi-map.md / .pi-map.index.md files
(ignored — generated artifacts), and Dashboard.test.tsx lines 108/115
(the whitelisted user-authored shortcut *label* "Grafana"). No live
grafana code, no grafana widget/component/hook.
grep -rin grafana backend/tests --include='*.py'
→ ZERO matches (SC-120). (.pyc cache + .pi-map.md are stale; .py sources clean.)
```
---
## 3. Spec coverage (SC-101 … SC-127)
| SC | Requirement | Verdict | Evidence |
|----|-------------|---------|----------|
| SC-101 | range query → `{series}` shape | **PASS** | `sources.py:_fetch_chart` → `_range_query` hits `/api/v1/query_range` with `query/start/end/step`; returns `{"series": normalize_prometheus_matrix(matrix)}` = `{label, points:[{t:int, v:float\|null}]}`. Test `test_prometheus_chart_adapter_runs_range_query` asserts endpoint + params + shape. |
| SC-102 | shared, Prometheus-native label normalization | **PASS** | Single helper `normalize_prometheus_matrix` in `prometheus_range.py`; drops `__name__`/`__*`, joins `k=v` (sorted), falls back to `"value"`, dedups with `(n)`. No duplication. `test_prometheus_range.py` covers all rules. |
| SC-103 | range-query errors degrade gracefully | **PASS** | `_range_query`/`_instant_query` catch `asyncio.TimeoutError` + `requests.RequestException` → `{"error": ...}`; `.fetch()` wraps the whole body in `try/except → {"error"}`. Never raises. Test `test_prometheus_chart_adapter_degrades_on_http_error`. |
| SC-104 | step derived from window (100–300 pts) | **PASS** | `step_for_window = max(15, round(window/200))`. Parametrized test asserts 100–300 points for `1h/6h/24h/7d` + 15s floor + custom target. |
| SC-105 | `chart` rebind grafana→prometheus (both registries) | **PASS** | Backend `prometheus.py` declares `chart`; frontend `registry.ts` `prometheus` binding has `chart`. Grafana is gone entirely (so trivially offers no `chart`). |
| SC-106 | chart renderer reused unchanged | **PASS** | `PrometheusChartWidget.tsx` preserves `LineChart/Line/XAxis/YAxis/CartesianGrid/Tooltip/ResponsiveContainer/mergeSeries/CHART_COLORS/formatTime`; rename-only + empty-state copy fix. |
| SC-107 | chart multi-series | **PASS** | `series.map((s,i) => )` renders every series; no single-series restriction. |
| SC-108 | chart window is a preset | **PASS** | chart config = `{promql, window}`; `window ∈ {1h,6h,24h,7d}` resolved via `WINDOW_PRESETS` server-side; no raw from/to/step. |
| SC-109 | gauge = instant scalar | **PASS** | `_fetch_gauge` → instant `/api/v1/query` → parses `result[0]["value"][1]` → `{"value":...}`. Bound to `prometheus` in both registries. |
| SC-110 | gauge threshold bands | **PASS** | `PrometheusGaugeWidget` (recharts `RadialBarChart`) renders green/amber/red track cells when `warn_at`+`crit_at` set, single neutral track otherwise; `min/max` default to `0`/`max(value,1)`. Config accepts `warn_at/crit_at/min/max/unit`. |
| SC-111 | gauge scalar-only | **PASS** | `len(result) != 1 → {"error": "Gauge requires a single-series query; refine your PromQL"}`. Test `test_prometheus_gauge_adapter_rejects_multi_series`. |
| SC-112 | mean = client-side mean over window | **PASS** | `_fetch_mean` → `_range_query` → averages non-null samples of the single series → `{"value": mean, "unit?"}`. Test asserts mean of `[1,2,3]=2.0`, NaN-skip `[2,NaN,4]=3.0`. |
| SC-113 | mean = plain PromQL + preset | **PASS** | mean config = `{promql, window, unit?}`; user supplies plain PromQL (no `avg_over_time`); window is a preset. |
| SC-114 | mean scalar-only | **PASS** | `len(result) != 1 → {"error": ...}`. Test `test_prometheus_mean_adapter_rejects_multi_series`. |
| SC-115 | no grafana in backend src | **PASS** | grep → only `prometheus_range.py` docstrings (allowed). `integrations/grafana.py` deleted; no `GrafanaWidgetSource`. |
| SC-116 | no grafana in frontend src | **PASS** | grep → only `Dashboard.test.tsx` whitelisted shortcut fixture + stale `.pi-map.md` (ignored). No live code. |
| SC-117 | grafana gone from registries | **PASS** | No `grafana` in backend `SERVICE_DEFINITIONS`/`SERVICE_ADAPTERS` or frontend `SERVICE_REGISTRY`/`BUILTIN_WIDGETS`; `integrations/grafana.py` deleted. `registry.test.ts` asserts 5 service types (no grafana). |
| SC-118 | grafana status + UI removed | **PASS (intent)** | `get_grafana_status` removed (`monitoring.py`); `useGrafanaStatus`/`fetchGrafanaStatus`/`GrafanaStatus` type removed; `LinksTab.tsx`+test deleted; `service-tabs/index.ts` grafana case removed; `navEntries.ts` grafana entry removed; `Dashboard.tsx` `OBSERVABILITY_TYPES` = `{alertmanager, prometheus}`; `ServicesPage.tsx` copy updated. `ObservabilityPage.tsx` was already refactored into `service-tabs/` (spec drift acknowledged in design §0 / spec SC-118 note). |
| SC-119 | orphaned grafana widget degrades | **PASS** | `WidgetInstanceCard`: `resolveWidget` → `undefined` for a grafana-bound widget (no binding) → renders `"Unknown widget: (service-bound)"` `Alert`. No crash. |
| SC-120 | grafana tests removed | **PASS** | No grafana in backend test `.py` or frontend widget tests. Only stale `.pyc` cache + `.pi-map.md`. |
| SC-121 | config.yaml accurate | **PASS** | Stale "Do NOT re-implement charting in-app" / "No recharts/d3" claims gone; OBSERVABILITY MODEL states "Manage renders Prometheus-backed metrics directly via recharts … Grafana is no longer integrated." No grafana-as-chart-path. |
| SC-122 | CHANGELOG migration note | **PASS** | `[Unreleased]` has "Direct Prometheus charting" + "**BREAKING** — Grafana service type removed" with migration: delete grafana instances → recreate as Prometheus; `grafana/chart` widgets → `prometheus/chart`. |
| SC-123 | backend tests pass | **PASS** | `pytest` → 293 passed; covers range→`{series}`, gauge scalar-only, mean aggregation, shared label helper. |
| SC-124 | frontend build + lint | **PASS** | `npm run build` exit 0; `npm run lint` 0 errors (1 pre-existing warning). |
| SC-125 | new widget kinds have tests | **PARTIAL** | All three test files exist with **error + rendered** cases (gauge has with/without bands; mean has with/without unit). **No explicit loading-state case** (`isLoading:true`) in any of the three — see §5. |
| SC-126 | no silent data migration | **PASS** | No grafana→prometheus row transformation code anywhere; only docstring references to manual migration. `settings_store.py` migration is unrelated (`jellyseerr→jellyfin`). |
| SC-127 | independent of service-storage-harness | **PASS** | No reference to it in source (only a `prometheus_range.py` docstring noting future reuse); builds/tests green without it; that change exists only as a proposal folder. |
**Functional spec coverage: 26/27 fully PASS, 1 PARTIAL (SC-125, test-coverage only).**
---
## 4. Task completion status — ⚠ archive blocker (reconcilable)
`tasks.md` checkbox state:
- **Slice 1 (§1.1–1.10): all 10 `[x]`** ✓
- **Slice 2 (§2.1–2.10): all 10 `[x]`** ✓
- **Slice 3 (§3.1–3.14): all 14 `[ ]` — UNCHECKED**
- **Integration (§4.1–4.5): all 5 `[ ]` — UNCHECKED**
Total: 20 checked, **19 unchecked**.
**This is a CRITICAL completeness issue for the archive gate per the verify
contract.** However — and this is the important reconciliation — **the Slice 3
and Integration work is verifiably DONE against source**:
| Unchecked task | Actual state (verified) |
|----------------|-------------------------|
| 3.1 delete `integrations/grafana.py` | **deleted** (`ls` → ENOENT; slice-3 diff `73 ------`) |
| 3.2 drop grafana from backend registry | **done** (`registry.py` has no grafana import/entry) |
| 3.3 remove `GrafanaWidgetSource` + adapter | **done** (`sources.py` has no class; `SERVICE_ADAPTERS` no grafana; diff `113 ---------`) |
| 3.4 remove `get_grafana_status` | **done** (`monitoring.py` grep clean; diff `-25`) |
| 3.5 remove grafana backend tests | **done** (`.py` test grep clean) |
| 3.6 delete `GrafanaLinkWidget` + barrel | **done** (file gone; `index.ts` export dropped) |
| 3.7 remove grafana frontend binding | **done** (`registry.ts` no grafana key; registry.test asserts) |
| 3.8 remove grafana nav entry | **done** (`navEntries.ts` grep clean) |
| 3.9 remove hook + api client + type | **done** (`useObservability.ts`/`client.ts`/`types/index.ts` grep clean) |
| 3.10 delete `LinksTab` + tab-index case | **done** (file + test deleted; `index.ts` grep clean) |
| 3.11 Dashboard + ServicesPage | **done** (`OBSERVABILITY_TYPES` = `{alertmanager,prometheus}`; ServicesPage copy updated) |
| 3.12 rewrite `config.yaml` | **done** (stale claims gone) |
| 3.13 CHANGELOG note | **done** (migration entry present) |
| 3.14 verify slice 3 (grep + build + lint + test) | **done** (all green — §2) |
| 4.1 full backend pytest | **done** (293 passed) |
| 4.2 frontend build + lint | **done** (exit 0 / 0 errors) |
| 4.3 grep-clean | **done** (SC-115/116 satisfied) |
| 4.4 config.yaml check | **done** (SC-121 satisfied) |
| 4.5 CHANGELOG check | **done** (SC-122 satisfied) |
The unchecked boxes are **stale** (work performed, tracker not updated), and
**no `apply-progress.md` exists** to serve as the stale-checkbox reconciliation
record the contract permits. Resolution is a **documentation-only** step:
tick §3.1–3.14 and §4.1–4.5, and author `apply-progress.md` describing the three
landed slices. **No code change is required.**
> Per the verify contract, an unchecked implementation-task line is an archive
> blocker until reconciled. Because the implementation is verified complete, this
> blocks **archive** but does **not** block `sdd-sync` of the green code.
---
## 5. TDD compliance & assertion-quality assessment
Strict-TDD was **not** declared active for this change in `config.yaml` /
parent prompt / (absent) `apply-progress.md`, so the formal TDD-cycle-evidence
check is **not applicable**. Assertion quality was still audited adversarially.
**Backend assertions — GENUINELY BEHAVIORAL (good).** Spot-checked:
- `test_prometheus_range.py`: asserts exact label strings (`instance=h:9100 mode=idle`), dedup suffix (`job=x (1)`), null sentinels → `None`, malformed-timestamp drop, point-count band per preset, 15s floor. No tautologies.
- `test_widgets.py` adapters: assert the **called URL** (`endswith("/api/v1/query_range")` / `/api/v1/query"`), the **params** (`{start,end,step} ⊆ params`, `query=="up"`), the **return shape** (`result["series"][0]["label"]`, `result["value"]==0.75`), multi-series → `{"error" ... "single-series"}`, and mean arithmetic (`2.0`, NaN-skip `3.0`). These verify real behavior, not smoke.
**Frontend assertions — adequate, one coverage gap.**
- Gauge test asserts the **rendered value text** (`0.75 %`, `42 req/s`) and threshold-label presence/absence — meaningful.
- Mean test asserts the **rendered value** (`23.5 %`, `1500`) — meaningful.
- Chart rendered-case asserts only the **SectionCard title** (`CPU Usage`), not that recharts drew the series SVG — a weak/title-only assertion (acceptable as a "rendered data case" since the component mounts with series data, but it does not prove the lines rendered).
**[WARNING] SC-125 loading-state gap (see §3).** None of the three widget tests
sets `isLoading:true`. The `mockData` helper hard-codes `isLoading:false`, and no
case asserts the `` loading branch. Each component's loading branch
exists and is structurally identical to sibling widgets, so this is a
**test-coverage gap, not a functional defect**. Recommend adding one
`isLoading:true` case per widget to fully satisfy SC-125's enumerated
"loading state" requirement. Non-blocking.
No ghost loops, no type-only assertions, no implementation-detail CSS assertions
found. No test mocks grafana anywhere (only the whitelisted `Dashboard.test.tsx`
shortcut label).
## 6. Review-workload / PR-boundary findings
Per-slice changed lines (numstat, excluding `tasks.md` doc churn):
| Commit | Slice | Source Δ | Over 400? | Verdict |
|--------|-------|----------|-----------|---------|
| `5dad982` | 1 range+rebrand+helper | ~333 ins / ~56 del | under (337 net w/ test) | **OK** |
| `65bae95` | 2 gauge+mean | ~707 ins / ~32 del | **over** (largely new widgets+tests: gauge 137, mean 59, tests 124, backend tests 197) | **OK** — additive feature code+tests; forecast rated "Medium"; boundary is the feature, not a missed split |
| `67ca0fc` | 3 grafana removal+config+changelog | ~123 ins / **~926 del** | under (net negative) | **OK** — mostly deletions |
The `tasks.md` Review Workload Forecast (`stacked-to-main`, 3 slices, ~850–1,110
total) was followed: S1→S2→S3, each independently green. Slice 2's +707 insertions
exceed the 400-line *added* budget but are dominated by two new components + their
tests + new adapter tests (no `size:exception` flag was recorded, and the forecast
itself rated slice 2 "~310–400 changed lines" which under-counts the test volume).
This is a **minor forecast-vs-actual variance on an additive slice**, not scope
creep — the boundary is exactly the gauge+mean feature, no unrelated files touched.
Recommend recording the slice-2 actual in the archive summary. **Non-blocking.**
Scope was honored: no backend API/type-contract widening, no `service-storage-harness`
coupling, `metric` path preserved (`PrometheusMetricWidget` reads `data?.data?.result`
as the PromQL data object; `_instant_query` returns `{"result": payload.get("data",{})}`
— identical shape).
## 7. Adversarial checks
- **Dead imports after grafana removal?** None. Backend `ruff` (catches unused
imports) is clean; `sources.py` imports `WINDOW_PRESETS/normalize_prometheus_matrix/
step_for_window` — all used. Frontend `tsc`/`eslint` clean (unresolved imports
would fail the build).
- **`PrometheusWidgetSource.fetch` dispatch cross-contamination?** None. Clean
`widget_kind` dispatch: `chart`→`{series}`, `gauge`→`{value,...}`, `mean`→`{value}`,
default→`{result}` (metric). Distinct shapes; shared `_range_query`/`_instant_query`
only do HTTP + error mapping.
- **Config-schema consistency (backend models vs frontend registry)?** Consistent.
gauge: backend `{promql, warn_at, crit_at, min, max, unit}` ↔ frontend configSchema
`{promql(req), warn_at, crit_at, min, max, unit}`. chart: `{promql, window}` ↔
`{promql(req), window}`. mean: `{promql, window, unit}` ↔ `{promql(req), window, unit}`.
- **Lingering grafana mocks?** None in `.py`/`.tsx` source. Only stale `.pyc`
bytecode cache + `.pi-map.md`.
## 8. Residual risks / non-blocking findings
1. **[CRITICAL-process] 19 unchecked tasks + missing `apply-progress.md`** (§4) — archive blocker; reconciliation is doc-only.
2. **[WARNING] SC-125 loading-state tests missing** (§5) — coverage gap, not a defect.
3. **[INFO] Stale generated `.pi-map.md`** files reference Grafana / `ObservabilityPage.tsx` / `TestGrafanaStatus` / `GrafanaLinkWidget`. Not deliverable source; ignored by SC-115/116. Regenerate via `project_map_patch`/`project_map_validate` (the project-map protocol flags these `dirty`).
4. **[INFO] `config.yaml` context** still names `frontend/src/components/ObservabilityPage.tsx` (refactored away into `service-tabs/`). Not an SC-121 criterion (which targets the charting/grafana claims, which are fixed); minor doc staleness.
5. **[INFO] Uncommitted `MediaTab.tsx`** cosmetic reformat (Prettier line-wrap of a ternary) — unrelated to this change, predates/orthogonal. Dirty working tree; no files are staged.
6. **[INFO] Chunk-size build warning** (~1.1 MB JS) — non-fatal, pre-existing, orthogonal.
7. **No browser/visual smoke** performed (out of scope); the recharts `RadialBarChart` gauge and `LineChart` rendering are only structurally tested.
## 9. Exact blockers
- **BLOCKER (archive only, doc-reconcilable):** 19 unchecked implementation/verification
tasks (§3.1–3.14, §4.1–4.5) and absent `apply-progress.md`. Implementation is
verified complete; resolution = tick boxes + write `apply-progress.md`.
No code-level blockers. All functional requirements SC-101…SC-127 pass (SC-125
PARTIAL on test coverage only). All four gates green. **Code is ready for
`sdd-sync`; archive requires the checkbox/apply-progress reconciliation.**
## 10. Recommended next phase
→ **`sdd-sync`** (code PASS). Concurrently/after: author `apply-progress.md`
documenting the three landed slices, and tick §3.1–3.14 + §4.1–4.5 in
`tasks.md` to clear the archive blocker. Optionally add three `isLoading:true`
widget tests to move SC-125 PARTIAL→PASS, and regenerate the stale `.pi-map.md`.
---
### Appendix A — Verification commands run (at `67ca0fc`)
```
cd backend && PYTHONPATH=src python3 -m pytest -q → 293 passed (2 warnings)
cd backend && PYTHONPATH=src python3 -m ruff check src tests → All checks passed!
cd frontend && npm run build → exit 0 (✓ built; >500kB warning pre-existing)
cd frontend && npm run lint → exit 0 (0 errors, 1 pre-existing warning)
grep -rin grafana backend/src --include='*.py' → 3 docstring hits in prometheus_range.py (allowed)
grep -rin grafana frontend/src → Dashboard.test.tsx fixture + stale .pi-map.md only
grep -rin grafana backend/tests --include='*.py' → ZERO
ls backend/src/media_library_viewer_api/integrations/grafana.py → ENOENT (deleted)
grep -nE '^\s*- \[ \]' tasks.md → 19 unchecked (§3.1–3.14, §4.1–4.5)
```
### Appendix B — Files substantively changed
**Backend**
- `widgets/prometheus_range.py` (new) — `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix`.
- `widgets/sources.py` — dropped `GrafanaWidgetSource`; `PrometheusWidgetSource` gains `_fetch_chart/_fetch_gauge/_fetch_mean` + shared `_range_query/_instant_query`.
- `integrations/prometheus.py` — `metric`+`chart`+`gauge`+`mean` widget kinds + config models.
- `integrations/registry.py` — grafana import/entry removed.
- `integrations/grafana.py` — **deleted**.
- `routers/monitoring.py` — `get_grafana_status` removed.
- `tests/test_prometheus_range.py` (new), `tests/test_widgets.py` (chart/gauge/mean adapter tests; grafana tests removed), `tests/test_api.py` + `tests/test_services.py` (grafana fixtures/tests removed).
**Frontend**
- `widgets/PrometheusChartWidget.tsx` (renamed from GrafanaChartWidget), `PrometheusGaugeWidget.tsx` (new), `PrometheusMeanWidget.tsx` (new) + their tests.
- `widgets/GrafanaLinkWidget.tsx` — **deleted**; `widgets/index.ts` barrel updated.
- `integrations/registry.ts` — grafana binding removed; chart/gauge/mean added to prometheus; `registry.test.ts` updated.
- `integrations/navEntries.ts` — grafana entry removed.
- `hooks/useObservability.ts`, `api/client.ts`, `types/index.ts` — grafana hook/client/type removed.
- `pages/service-tabs/LinksTab.tsx` (+test) — **deleted**; `service-tabs/index.ts` grafana case removed.
- `pages/Dashboard.tsx` (`OBSERVABILITY_TYPES`), `pages/ServicesPage.tsx` (copy) — grafana removed.
**Docs**
- `openspec/config.yaml` — stale thin-dashboard/no-recharts claims repealed.
- `CHANGELOG.md` — direct-Prometheus-charting + grafana-removal migration note.