Files
manage/openspec/changes/archive/2026-07-08-prometheus-direct-charting/design.md
T
Developer 7efc06a629 chore(prometheus-direct-charting): archive verified+synced change
Move to openspec/changes/archive/2026-07-08-prometheus-direct-charting/
(git mv, history preserved). 9 artifacts: proposal/spec/design/tasks/
apply-progress/verify-report/sync-report/archive-report + delta spec.
Canonical openspec/specs/prometheus-charting/ remains.
2026-07-08 23:01:35 +00:00

29 KiB
Raw Blame History

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):

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 100300 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 100300 band, which the formula satisfies. The formula is the source of truth; the table is illustrative.)

Shared normalization (SC-102) — normalize_prometheus_matrix:

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:

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):

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:

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:

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):

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 ~175195). 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: GrafanaChartWidgetPrometheusChartWidget

  • Rename frontend/src/widgets/GrafanaChartWidget.tsxPrometheusChartWidget.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.tsxPrometheusChartWidget.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. ChartuseWidgetData(widgetId, 60_000)GET /api/widgets/instances/{id}/dataPrometheusWidgetSource.fetch(kind="chart")_fetch_chartGET {prom}/api/v1/query_range?query=...&start=...&end=...&step=...normalize_prometheus_matrix{"series":[...]}PrometheusChartWidget renders recharts (unchanged).
  2. GaugeuseWidgetData(widgetId, 30_000)fetch(kind="gauge")_fetch_gauge → instant query → scalar-only check → {"value":..., "warn_at":..., ...}PrometheusGaugeWidget renders RadialBarChart.
  3. MeanuseWidgetData(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 widgetresolveWidget finds no grafana binding → returns undefinedWidgetInstanceCard 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 100300 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.tsxwidgets/PrometheusChartWidget.tsx SC-105/106. Body preserved.
widgets/__tests__/GrafanaChartWidget.test.tsxwidgets/__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 GrafanaChartWidgetPrometheusChartWidget (+ 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. ~300380 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. ~300380 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). ~250350 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.