# SDD Design: Prometheus Direct Charting (drop Grafana middleman)

**Change:** `prometheus-direct-charting`
**Phase:** design
**Date:** 2026-07-08

## 0. Source findings (read before anything else)

The proposal and spec were written against a **stale project map**. Reading actual source surfaced deviations the design must account for. Trust source, not the map.

| Spec claim | Actual source reality | Design impact |
|---|---|---|
| SC-118: "remove the Grafana branch from `ObservabilityPage`" | **`ObservabilityPage.tsx` no longer exists.** It was refactored into a per-service-type `service-tabs/` architecture (confirmed by `LinksTab.tsx` docstring: "Lifts the Grafana deep-link content from the old cross-service ObservabilityPage into an instance-scoped tab"). The stale reference survives only in `.pi-map.md` files. | Removal targets are `service-tabs/LinksTab.tsx` + its test, the `grafana` case in `service-tabs/index.ts`, the `navEntries.ts` grafana entry, `Dashboard.tsx`'s `OBSERVABILITY_TYPES` set, `ServicesPage.tsx` empty-state text, and the `useGrafanaStatus` hook. See §5. |
| "No recharts/d3 in use" (config rule to repeal) | `recharts ^3.9.2` is **declared, installed, and imported** by `GrafanaChartWidget.tsx`. Confirmed. | Repeal is a doc fix matching reality; recharts is the sanctioned renderer. |
| Proposal §5.1: "extend `PrometheusWidgetSource` ... to handle `chart`" | `PrometheusWidgetSource.fetch` currently only does instant `/api/v1/query` and returns `{"result": data}`. The frontend `PrometheusMetricWidget` consumes `data.result`. | The chart/mean paths return a **different** shape (`{series}` / `{value}`); dispatch inside `.fetch()` by `widget_kind`. See §2.2. |
| Map said registry has 7 service types | Source registry has **8**: `alertmanager, authentik, backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks`. (`authentik` and `backups` were added after the map froze.) | No design impact beyond acknowledging grafana is one of eight, not one of seven. |
| Frontend `SERVICE_REGISTRY` (registry.ts) | Has a `grafana` binding with `link` + `chart` kinds, and a `prometheus` binding with only `metric`. The `chart` kind must move grafana→prometheus. | Confirmed; §3.2 details the rebinding. |

No proposal/spec scope change is required — the *intent* (remove Grafana, direct Prom charting) still holds. Only the **removal targets** differ from what SC-118 literally names. This is flagged explicitly so the tasks phase and reviewer aren't surprised.

---

## 1. Architecture overview

This change cuts Grafana out of the chart data path and makes Prometheus the direct source. The existing recharts renderer is reused unchanged for `chart`; two new widget kinds (`gauge`, `mean`) are added to the `prometheus` service. Grafana is removed entirely.

```
 BEFORE                                   AFTER
 ───────                                   ─────
 WidgetData fetch                         WidgetData fetch
   └► GrafanaWidgetSource                   └► PrometheusWidgetSource
        └► POST {grafana}/api/ds/query            ├► kind=metric  → /api/v1/query      {result}     [unchanged]
             (datasource.type=prometheus)         ├► kind=chart   → /api/v1/query_range {series}     [NEW]
        └► normalize frames → {series}            ├► kind=gauge   → /api/v1/query      {value,...}  [NEW]
                                                  └► kind=mean    → /api/v1/query_range {value}      [NEW]

 Frontend GrafanaChartWidget (recharts)   Frontend PrometheusChartWidget (recharts)  [renamed, reused]
                                          + PrometheusGaugeWidget (recharts RadialBarChart)         [NEW]
                                          + PrometheusMeanWidget (MetricCard-style)                 [NEW]

 grafana service type / LinkWidget /      [REMOVED entirely]
 LinksTab / navEntry / status endpoint
```

**Key constraints carried from the spec:**

- Reuse the recharts chart renderer unchanged (SC-106) — the rename is structural.
- Shared series normalization (SC-102) — one helper, no duplication.
- Step derived from window presets (SC-104), users never set `step`.
- `gauge`/`mean` are scalar-only (SC-111, SC-114); `chart` stays multi-series (SC-107).
- Window presets: `1h`, `6h`, `24h`, `7d` (SC-108, SC-112).
- All adapter errors return `{"error": str}`, never raise (SC-103).
- No Grafana references remain (SC-115, SC-116, SC-117, SC-120).

---

## 2. Backend design

### 2.1 New module: `widgets/prometheus_range.py` (shared helpers)

Holds the two pieces the spec's downstream-notes asked for: the step-derivation function (SC-104) and the shared normalization helper (SC-102). Putting them in their own module (rather than inside `sources.py`) makes them unit-testable in isolation and reusable by the future `service-storage-harness` change's in-service data path, without `sources.py` growing unbounded.

**Window presets and step derivation (SC-104):**

```python
WINDOW_PRESETS: dict[str, int] = {
    "1h": 3_600,
    "6h": 21_600,
    "24h": 86_400,
    "7d": 604_800,
}

# Target ~200 points per window. Step is clamped to >= 15s so Prometheus
# doesn't reject sub-15s resolutions on high-cardinality queries.
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
    return max(15, round(window_seconds / target_points))
```

Resulting table (verified, all within the 100–300 target band):

| Preset | Window (s) | Derived step (s) | Points |
|--------|-----------|------------------|--------|
| `1h`   | 3,600     | `max(15, round(3600/200))` = 18 | 200 |
| `6h`   | 21,600    | 108 | 200 |
| `24h`  | 86,400    | 432 | 200 |
| `7d`   | 604,800   | 3,024 | 200 |

(At apply time the implementer may round steps to "nicer" values like 15/60/300/1800 for cache-friendliness; the spec only requires the 100–300 band, which the formula satisfies. The formula is the source of truth; the table is illustrative.)

**Shared normalization (SC-102) — `normalize_prometheus_matrix`:**

```python
def normalize_prometheus_matrix(
    result: list[dict[str, Any]],
) -> list[dict[str, Any]]:
    """Turn a Prometheus /api/v1/query_range `data.result` matrix into
    `{label, points:[{t:int, v:float|None}]}` series — the exact shape the
    frontend chart renderer consumes.

    Label derivation reuses the rule from the removed Grafana path:
      1. Drop __name__ from metric labels.
      2. If labels remain, join as `k=v k=v`.
      3. Else fall back to "value".
      4. Dedup collisions with a ` (n)` suffix.
    """
    series: list[dict[str, Any]] = []
    seen: dict[str, int] = {}
    for entry in result:
        metric = entry.get("metric") or {}
        values = entry.get("values") or []
        parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not k.startswith("__")]
        label = " ".join(parts) if parts else "value"
        if label in seen:
            seen[label] += 1
            label = f"{label} ({seen[label]})"
        else:
            seen[label] = 0
        points = []
        for ts, raw in values:
            v = float(raw) if raw not in (None, "NaN", "+Inf", "-Inf") else None
            points.append({"t": int(ts), "v": v})
        series.append({"label": label, "points": points})
    return series
```

This is a **direct extraction** of the label/dedup logic currently inside `GrafanaWidgetSource._fetch_chart`, retargeted at the Prometheus matrix shape (`{metric, values:[[ts,"str"],...]}`) instead of Grafana frames. The dedup rule is identical so users moving a `grafana/chart` widget to `prometheus/chart` see the same labels.

### 2.2 `PrometheusWidgetSource` — extend `.fetch()` by `widget_kind`

Today `.fetch()` does only the instant-query → `{"result": ...}` path. Extend it to dispatch by `widget_kind` while preserving the existing `metric` behavior byte-for-byte:

```python
class PrometheusWidgetSource:
    async def fetch(self, service, widget_kind, config) -> dict[str, Any]:
        if service is None:
            return {"error": "Prometheus widget is missing its service"}
        base_url = str(service.config.get("base_url") or "").rstrip("/")
        timeout = int(service.config.get("timeout_seconds") or 10)

        if widget_kind == "metric":
            return await self._fetch_instant(base_url, timeout, config)      # unchanged
        if widget_kind == "chart":
            return await self._fetch_chart(base_url, timeout, config)        # NEW
        if widget_kind == "gauge":
            return await self._fetch_gauge(base_url, timeout, config)        # NEW
        if widget_kind == "mean":
            return await self._fetch_mean(base_url, timeout, config)         # NEW
        return {"error": f"Unknown widget kind: {widget_kind}"}
```

**`_fetch_chart` (SC-101, SC-103, SC-104):**

```python
async def _fetch_chart(self, base_url, timeout, config) -> dict[str, Any]:
    promql = config.get("promql")
    if not promql:
        return {"error": "promql is required"}
    window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
    step = step_for_window(window)
    end = int(time.time())
    start = end - window
    try:
        resp = await asyncio.wait_for(asyncio.to_thread(
            requests.get, f"{base_url}/api/v1/query_range",
            params={"query": promql, "start": start, "end": end, "step": step},
            timeout=timeout,
        ), timeout=timeout)
        resp.raise_for_status()
        payload = resp.json()
    except asyncio.TimeoutError:
        return {"error": "Prometheus query timed out"}
    except requests.RequestException as exc:
        return {"error": f"Prometheus query failed: {exc}"}
    result = payload.get("data", {}).get("result", [])
    return {"series": normalize_prometheus_matrix(result)}
```

**`_fetch_gauge` (SC-109, SC-110, SC-111) — instant query, scalar-only:**

```python
async def _fetch_gauge(self, base_url, timeout, config) -> dict[str, Any]:
    promql = config.get("promql")
    if not promql:
        return {"error": "promql is required"}
    raw = await self._instant_query(base_url, timeout, promql)   # shared helper
    if "error" in raw:
        return raw
    result = raw["result"]
    if len(result) != 1:
        return {"error": "Gauge requires a single-series query; refine your PromQL"}
    # vector entry: {metric, value:[ts, "str"]}
    try:
        value = float(result[0]["value"][1])
    except (KeyError, IndexError, ValueError, TypeError):
        return {"error": "Gauge query returned no scalar value"}
    return {
        "value": value,
        "warn_at": config.get("warn_at"),
        "crit_at": config.get("crit_at"),
        "min": config.get("min"),
        "max": config.get("max"),
        "unit": config.get("unit"),
    }
```

`_instant_query` is extracted from the current `metric` path so `metric`/`gauge` share it; it returns `{"result": [...]}` or `{"error": ...}`.

**`_fetch_mean` (SC-112, SC-113, SC-114) — range query, client-side mean, scalar-only:**

```python
async def _fetch_mean(self, base_url, timeout, config) -> dict[str, Any]:
    promql = config.get("promql")
    if not promql:
        return {"error": "promql is required"}
    window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
    step = step_for_window(window)
    end = int(time.time()); start = end - window
    # ... run query_range, handle errors identically to _fetch_chart ...
    result = payload.get("data", {}).get("result", [])
    if len(result) != 1:
        return {"error": "Mean requires a single-series query; refine your PromQL"}
    points = result[0].get("values") or []
    nums = [float(v) for _, v in points if v not in (None, "NaN", "+Inf", "-Inf")]
    if not nums:
        return {"error": "Mean query returned no numeric samples in the window"}
    mean = sum(nums) / len(nums)
    return {"value": mean, "unit": config.get("unit")}
```

The range-query HTTP call + error handling is shared between `_fetch_chart` and `_fetch_mean` via a private `_range_query(base_url, timeout, promql, window) -> dict` returning `{"matrix": result}` or `{"error": ...}`. This keeps the chart and mean paths DRY without inventing a generic adapter registry (non-goal enforced).

### 2.3 `integrations/prometheus.py` — declare new widget kinds

Add two Pydantic widget-config models and two `widget_kind(...)` entries; leave `PrometheusMetricWidgetConfig` and the existing `metric` kind untouched (non-goal: `prometheus_metric` stays as-is):

```python
class PrometheusChartWidgetConfig(WidgetConfigBase):
    promql: str
    window: str = "1h"          # one of 1h/6h/24h/7d

class PrometheusGaugeWidgetConfig(WidgetConfigBase):
    promql: str
    warn_at: float | None = None
    crit_at: float | None = None
    min: float | None = None
    max: float | None = None
    unit: str | None = None

class PrometheusMeanWidgetConfig(WidgetConfigBase):
    promql: str
    window: str = "1h"
    unit: str | None = None
```

`DEFINITION.widget_kinds` gains `chart`, `gauge`, `mean` (refresh 60s for chart/mean, 30s for gauge). Validation that `window ∈ {1h,6h,24h,7d}` can be a `field_validator` on the two models that use it, or simply enforced by `WINDOW_PRESETS.get(..., default)` server-side — prefer the latter (lenient) so a future preset addition doesn't require a model change.

### 2.4 `integrations/grafana.py` — delete; registry drops the entry

- Delete the file.
- In `integrations/registry.py`, drop the `from ...grafana import DEFINITION as GRAFANA` import and the `GRAFANA.service_type: GRAFANA,` line from `SERVICE_DEFINITIONS`. No other registry change.

### 2.5 `widgets/sources.py` — drop Grafana; wire nothing new

- Delete `GrafanaWidgetSource` and its `_fetch_chart`.
- Remove `"grafana": GrafanaWidgetSource(),` from `SERVICE_ADAPTERS`. (The label/dedup logic has already been *extracted* into `prometheus_range.normalize_prometheus_matrix` in §2.1; it is not lost when the Grafana class is deleted.)
- `PrometheusWidgetSource` gains the three new methods from §2.2.

### 2.6 `routers/monitoring.py` — remove `get_grafana_status`

Delete the `@router.get("/grafana-status")` endpoint (lines ~175–195). No other monitoring change; `get_prometheus_status` / `get_alertmanager_status` stay.

### 2.7 No SettingsStore or DB schema change

Widget *instances* are stored generically (`service_id`, `widget_kind`, `config_json`). A `prometheus` `chart` instance is just a row with `service_id=<prom instance>` and `widget_kind="chart"`. Existing `grafana/chart` rows become orphans resolved by the existing "unknown widget" path (SC-119) — no migration code, no schema change (SC-126).

---

## 3. Frontend design

### 3.1 Rename + reuse: `GrafanaChartWidget` → `PrometheusChartWidget`

- Rename `frontend/src/widgets/GrafanaChartWidget.tsx` → `PrometheusChartWidget.tsx`; rename the exported function/component.
- The recharts body (`LineChart`, `Line`, `XAxis`, `YAxis`, `CartesianGrid`, `Tooltip`, `ResponsiveContainer`, `mergeSeries`, `CHART_COLORS`, `formatTime`) is **preserved unchanged** (SC-106).
- One tiny text fix: the empty-state Alert copy "Check your query and datasource_uid" becomes "Check your PromQL query and window." (`datasource_uid` no longer exists.)
- Rename test file `__tests__/GrafanaChartWidget.test.tsx` → `PrometheusChartWidget.test.tsx`; update import + the one error-message assertion (`"Grafana api_key is required"` → a Prom error string).

### 3.2 `integrations/registry.ts` — rebind + add

- Delete the entire `grafana` key from `SERVICE_REGISTRY`.
- In the `prometheus` binding's `widgets` array, add `chart`, `gauge`, `mean` alongside the existing `metric`. Each has a `configSchema` mirroring §2.3 (`promql`, `window` for chart/mean; `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit` for gauge). `chart`/`mean` refresh 60s; `gauge` 30s.
- Import the renamed `PrometheusChartWidget` and the two new components.

### 3.3 New: `PrometheusGaugeWidget.tsx` (SC-109, SC-110, SC-111)

**Renderer choice: recharts `RadialBarChart`.** Justification: recharts is already a dependency (no new dep), `RadialBarChart` renders a single-value gauge with domain bands natively, and it shares tooltip/styling conventions with the chart widget — keeping the two visualizations consistent. The alternative (a ~50-line bespoke SVG gauge) was rejected because it would introduce a second rendering dialect for no benefit; the proposal's "fall back to SVG if recharts proves heavy" fallback remains documented but is not the default.

Threshold-band rendering: render three stacked `RadialBar` cells (green `0→warn`, amber `warn→crit`, red `crit→max`) as the track, and a fourth cell (the actual value) as the needle/bar. When `warn_at`/`crit_at` are absent, render a single neutral-color track. `min`/`max` default to `0`/`max(value, 1)` when omitted so the gauge has a sane domain. The component reuses `SectionCard` + `Alert`/`Skeleton` for loading/error states, matching every other widget.

Config fields surfaced to the user (matching §2.3): `promql`, `warn_at`, `crit_at`, `min`, `max`, `unit`.

### 3.4 New: `PrometheusMeanWidget.tsx` (SC-112, SC-113, SC-114)

A single-value display reusing the existing `MetricCard` pattern (already used by `BackupDashboardWidget` / `PrometheusMetricWidget`-adjacent tiles): big number, optional `unit` suffix, optional subtext showing the window ("mean over last 1h"). Loading/error/empty states via `Skeleton`/`Alert` as usual. No charting library involvement — it's a number, deliberately.

### 3.5 Grafana removal on the frontend

Per the source findings (§0), the removal targets are **not** an `ObservabilityPage` section. They are:

| File | Change |
|------|--------|
| `widgets/GrafanaLinkWidget.tsx` | **delete** |
| `widgets/GrafanaChartWidget.tsx` | **rename** to PrometheusChartWidget (§3.1) — not a pure delete |
| `widgets/index.ts` | drop `GrafanaLinkWidget` export; rename the chart export |
| `integrations/registry.ts` | drop `grafana` binding (§3.2) |
| `integrations/navEntries.ts` | drop the `grafana` entry from `SERVICE_TYPE_NAV_ENTRIES`; drop the now-unused `Link2` icon import |
| `hooks/useObservability.ts` | drop `useGrafanaStatus` + its `fetchGrafanaStatus` import |
| `api/client.ts` | drop `fetchGrafanaStatus` |
| `types/index.ts` | drop `GrafanaStatus` interface |
| `pages/service-tabs/LinksTab.tsx` | **delete** (it is the grafana-specific tab; its `GrafanaLinkCard` + machine deep-links are Grafana-only) |
| `pages/service-tabs/__tests__/LinksTab.test.tsx` | **delete** |
| `pages/service-tabs/index.ts` | drop the `LinksTab` import and the `case "grafana":` from `serviceContentTabs` |
| `pages/Dashboard.tsx` | drop `"grafana"` from `OBSERVABILITY_TYPES` set (line ~68) |
| `pages/ServicesPage.tsx` | update empty-state copy "Add a Grafana, Prometheus, …" → "Add a Prometheus, …" |
| `pages/__tests__/Dashboard.test.tsx` | the one "Grafana" label literal there is for a *shortcut* (a website link), unrelated to the Grafana service — **leave it** (grep-clean criterion SC-116 still passes; it's not a grafana service reference, just user-typed shortcut text in a test fixture). Flag for reviewer. |

**Spec-text note (not a design change):** SC-118 literally names "the ObservabilityPage Grafana section," which no longer exists. The *intent* of SC-118 ("Grafana status checks are removed") is satisfied by dropping `get_grafana_status` (§2.6) + `useGrafanaStatus`. The tasks phase should note this textual drift so the reviewer doesn't treat it as a missed requirement. If the parent prefers, SC-118 can be reworded in `spec.md` to name `LinksTab`/`useGrafanaStatus` instead; this design does not require that edit to proceed.

### 3.6 Types

`types/index.ts`: remove `GrafanaStatus`. No new widget-payload types — `chart` uses `{series}` (existing), `gauge` uses `{value, warn_at, crit_at, min, max, unit}` (all optional beyond `value`), `mean` uses `{value, unit?}`. These are read off `data?.data` untyped-as-before; no `WidgetDataResponse` generic change is needed (it's already `data: dict | None`).

---

## 4. Data flow

1. **Chart** — `useWidgetData(widgetId, 60_000)` → `GET /api/widgets/instances/{id}/data` → `PrometheusWidgetSource.fetch(kind="chart")` → `_fetch_chart` → `GET {prom}/api/v1/query_range?query=...&start=...&end=...&step=...` → `normalize_prometheus_matrix` → `{"series":[...]}` → `PrometheusChartWidget` renders recharts (unchanged).
2. **Gauge** — `useWidgetData(widgetId, 30_000)` → `fetch(kind="gauge")` → `_fetch_gauge` → instant query → scalar-only check → `{"value":..., "warn_at":..., ...}` → `PrometheusGaugeWidget` renders RadialBarChart.
3. **Mean** — `useWidgetData(widgetId, 60_000)` → `fetch(kind="mean")` → `_fetch_mean` → range query → client-side mean → `{"value":..., "unit":...}` → `PrometheusMeanWidget` renders a MetricCard.
4. **Metric (unchanged)** — existing path preserved byte-for-byte.
5. **Orphaned grafana widget** — `resolveWidget` finds no `grafana` binding → returns `undefined` → `WidgetInstanceCard` renders its existing "unknown widget" Alert (SC-119). No crash, no migration.

Errors at any step return `{"error": str}` (SC-103); the per-widget `Alert variant="destructive"` renders it and siblings keep polling.

---

## 5. Testing approach

### Backend

Extend `backend/tests/test_widgets.py` (and/or a focused `test_prometheus_range.py`):

- `normalize_prometheus_matrix`: feed a sample Prom `/api/v1/query_range` `data.result` (two entries, one with `__name__`, one colliding label) → assert `{series}` shape, label dedup, null handling for `"NaN"`.
- `step_for_window`: assert the 1h/6h/24h/7d → step mapping stays within 100–300 points.
- `PrometheusWidgetSource` chart path: mock `requests.get` → assert `query_range` URL + params (`start`/`end`/`step` present, no `from_ts`/`to_ts`) and `{"series": ...}` return.
- Gauge scalar-only: mock instant query returning 2 series → assert `{"error": ...}`.
- Mean scalar-only + client-side mean: mock range query returning 1 series with known values → assert the arithmetic mean; mock 2 series → assert error.
- Error paths: timeout / `RequestException` → `{"error": ...}` (no raise).

### Frontend

- `PrometheusChartWidget.test.tsx` (renamed from GrafanaChartWidget test): loading, error, rendered-data cases (SC-125).
- `PrometheusGaugeWidget.test.tsx`: loading, error, rendered-with-bands, rendered-without-bands.
- `PrometheusMeanWidget.test.tsx`: loading, error, rendered value.
- `registry.test.ts`: assert no `grafana` binding; assert `prometheus` binding has `metric`, `chart`, `gauge`, `mean`.

### Build/lint gates

Each slice: `PYTHONPATH=src pytest` (from `backend/`), `npm run build`, `npm run lint` must be green.

---

## 6. File-level plan

### Create

| File | Rationale |
|------|-----------|
| `backend/src/media_library_viewer_api/widgets/prometheus_range.py` | `WINDOW_PRESETS`, `step_for_window`, `normalize_prometheus_matrix` (SC-102, SC-104). |
| `backend/tests/test_prometheus_range.py` | Unit tests for the helpers. |
| `frontend/src/widgets/PrometheusGaugeWidget.tsx` | recharts RadialBarChart gauge (SC-109/110/111). |
| `frontend/src/widgets/PrometheusMeanWidget.tsx` | MetricCard-style mean (SC-112/113/114). |
| `frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx` | SC-125. |
| `frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx` | SC-125. |

### Rename (git mv)

| From → To | Rationale |
|-----------|-----------|
| `widgets/GrafanaChartWidget.tsx` → `widgets/PrometheusChartWidget.tsx` | SC-105/106. Body preserved. |
| `widgets/__tests__/GrafanaChartWidget.test.tsx` → `widgets/__tests__/PrometheusChartWidget.test.tsx` | match rename. |

### Modify

| File | Rationale |
|------|-----------|
| `backend/.../widgets/sources.py` | Drop `GrafanaWidgetSource`; add chart/gauge/mean to `PrometheusWidgetSource`. |
| `backend/.../integrations/prometheus.py` | Add 3 widget-config models + 3 `widget_kind` entries. |
| `backend/.../integrations/registry.py` | Drop grafana import + entry. |
| `backend/.../routers/monitoring.py` | Drop `get_grafana_status`. |
| `backend/tests/test_widgets.py` | Drop grafana adapter tests; add prom chart/gauge/mean tests. |
| `frontend/src/integrations/registry.ts` | Drop grafana binding; add chart/gauge/mean to prometheus. |
| `frontend/src/integrations/navEntries.ts` | Drop grafana entry. |
| `frontend/src/hooks/useObservability.ts` | Drop `useGrafanaStatus`. |
| `frontend/src/api/client.ts` | Drop `fetchGrafanaStatus`. |
| `frontend/src/types/index.ts` | Drop `GrafanaStatus`. |
| `frontend/src/widgets/index.ts` | Drop GrafanaLinkWidget export; rename chart export. |
| `frontend/src/pages/service-tabs/index.ts` | Drop LinksTab import + grafana case. |
| `frontend/src/pages/Dashboard.tsx` | Drop "grafana" from `OBSERVABILITY_TYPES`. |
| `frontend/src/pages/ServicesPage.tsx` | Update empty-state copy. |
| `openspec/config.yaml` | Repeal stale thin-dashboard/no-recharts wording (SC-121). |
| `CHANGELOG.md` | Migration note (SC-122). |

### Delete

| File | Rationale |
|------|-----------|
| `backend/.../integrations/grafana.py` | SC-117. |
| `frontend/src/widgets/GrafanaLinkWidget.tsx` | SC-117. |
| `frontend/src/pages/service-tabs/LinksTab.tsx` | Grafana-only tab; no Prom equivalent needed (chart widget covers viz). |
| `frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx` | matches deletion. |

---

## 7. Slice boundaries (≤400 changed lines each)

Each slice leaves `pytest` + `npm run build` + `npm run lint` green and the app in a working state.

### Slice 1 — Prom range path + chart rebrand + shared helper (foundational)

**Files:** create `prometheus_range.py` + test; rename `GrafanaChartWidget`→`PrometheusChartWidget` (+ test); modify `sources.py` (`_fetch_chart`, drop nothing yet), `integrations/prometheus.py` (add `chart` kind only), `registry.ts` (move `chart` grafana→prometheus), `widgets/index.ts`, `registry.test.ts`.
**State after S1:** a `prometheus/chart` widget renders from direct Prom `query_range`; the `grafana/chart` binding is gone but `GrafanaWidgetSource`/`GrafanaLinkWidget`/grafana service type still exist (removal is S3). Grafana link widgets still work. ~300–380 lines.

### Slice 2 — Gauge + mean widgets (additive)

**Files:** create `PrometheusGaugeWidget.tsx` (+test), `PrometheusMeanWidget.tsx` (+test); modify `sources.py` (`_fetch_gauge`, `_fetch_mean`, shared `_instant_query`/`_range_query`), `integrations/prometheus.py` (add `gauge`/`mean` kinds), `registry.ts` (add gauge/mean bindings), `test_widgets.py` (add adapter tests).
**State after S2:** gauge + mean widgets selectable and rendering; no grafana change. ~300–380 lines.

### Slice 3 — Grafana removal + config + changelog (cleanup)

**Files:** delete `integrations/grafana.py`, `GrafanaLinkWidget.tsx`, `LinksTab.tsx` (+test); modify `sources.py` (drop `GrafanaWidgetSource` + `SERVICE_ADAPTERS` entry), `registry.py`, `monitoring.py`, `navEntries.ts`, `useObservability.ts`, `client.ts`, `types/index.ts`, `widgets/index.ts`, `service-tabs/index.ts`, `Dashboard.tsx`, `ServicesPage.tsx`, `test_widgets.py` (drop grafana tests); rewrite `config.yaml`; add `CHANGELOG.md` entry.
**State after S3:** grep-clean (SC-115/116), config accurate (SC-121), migration documented (SC-122). ~250–350 lines (mostly deletions).

**Order:** S1 → S2 → S3. S1 and S2 are independently shippable; S3 must follow S1 (it removes the grafana chart binding S1 replaces).

---

## 8. Decisions log (answers to spec downstream-notes)

1. **Step derivation (SC-104):** `step_for_window(window_seconds, target_points=200) = max(15, round(window_seconds/200))`, in `widgets/prometheus_range.py`. Table in §2.1.
2. **Shared normalization (SC-102):** `normalize_prometheus_matrix(result)` in `widgets/prometheus_range.py`; extracts the dedup rule from the to-be-deleted Grafana path; retargeted at Prom matrix shape. Signature in §2.1.
3. **Gauge renderer (SC-110):** recharts `RadialBarChart` (no new dep, consistent styling). Threshold bands via stacked track cells; neutral single-color when `warn_at`/`crit_at` absent. Config fields: `promql, warn_at, crit_at, min, max, unit`. §3.3.
4. **Mean adapter (SC-112/113):** `_fetch_mean` runs `query_range` over the window preset, averages non-null samples of the single series client-side, returns `{value, unit?}`. Scalar-only enforced via `len(result) != 1 → error`. §2.2.
5. **Slice plan:** 3 slices (S1 range+rebrand+helper, S2 gauge+mean, S3 grafana removal+config+changelog), each ≤400 lines, order S1→S2→S3. §7.

---

## 9. Open items for the tasks/apply phases

- Implementer should `git mv` the chart widget/test so history is preserved (not delete+create).
- Implementer should verify the recharts `RadialBarChart` gauge renders acceptably; if it proves fiddly, the proposal's SVG fallback (~50 lines) is sanctioned — but try recharts first.
- SC-118 text names a file that no longer exists; tasks phase should record this so verify doesn't flag it as a miss. Intent is satisfied by removing `get_grafana_status` + `useGrafanaStatus` + `LinksTab`.
- `Dashboard.test.tsx` contains the literal "Grafana" in a shortcut test fixture (not a grafana service reference) — grep for SC-116 should be scoped to service/widget references, or that line whitelisted. Flag for reviewer.
