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.
22 KiB
SDD Tasks: Prometheus Direct Charting (drop Grafana middleman)
Change: prometheus-direct-charting
Phase: tasks
Date: 2026-07-08
Review Workload Forecast
| Field | Value |
|---|---|
| Estimated changed lines | ~850–1,110 (sum of three implementation slices) |
| 400-line budget risk | Medium |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: Prom range path + chart rebrand → PR 2: gauge + mean widgets → PR 3: Grafana removal + config rewrite |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
Decision needed before apply: No
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: Medium
Each slice individually lands under the 400-line review budget. Slices are ordered S1 → S2 → S3; S1 and S2 are independently shippable, S3 must follow S1 (it removes the grafana chart binding S1 replaces). Per
openspec/config.yamlrules, each slice leavesnpm run build(tsc -b + vite build),npm run lint, and backendpytestgreen.
Slice ordering rationale (critical)
Slice 1 ADDS the prometheus chart capability without removing anything from grafana. After S1:
prometheusbinding hasmetric+chart;grafanabinding has onlylink(chart kind moved away).GrafanaWidgetSourcestill exists (serves thelinkkind; its_fetch_chartis now dead but harmless).GrafanaLinkWidgetand thegrafanaservice type are untouched.- A
prometheus/chartwidget renders from a direct/api/v1/query_rangecall.
This ordering ensures the chart capability is proven against Prometheus before the grafana surface is removed in S3, so the two risks (new query path + grafana removal) never compound in a single slice.
Slice 1: Prometheus range query path + shared helper + chart rebrand
Goal: Make a prometheus/chart widget render multi-series line charts from a direct /api/v1/query_range call, reusing the existing recharts renderer. Extract the series-normalization and step-derivation helpers into a testable module. Move the chart kind from grafana to prometheus in both registries. Do NOT yet remove the grafana service type, GrafanaWidgetSource, or GrafanaLinkWidget.
Satisfies: SC-101, SC-102, SC-103, SC-104, SC-105, SC-106, SC-107, SC-108.
-
1.1 Create shared Prometheus range helpers module
- Files:
backend/src/media_library_viewer_api/widgets/prometheus_range.py(new) - Lines: ~60
- Dependencies: none
- Details: Implement
WINDOW_PRESETS = {"1h": 3600, "6h": 21600, "24h": 86400, "7d": 604800},step_for_window(window_seconds, target_points=200) -> intreturningmax(15, round(window_seconds / target_points)), andnormalize_prometheus_matrix(result: list[dict]) -> list[dict]that converts a Prometheus/api/v1/query_rangedata.resultmatrix into{label, points:[{t:int, v:float|None}]}series. Label rule: drop__name__from metric labels; join remaining ask=v; fall back to"value"; dedup collisions with(n)suffix. Null handling:"NaN","+Inf","-Inf",None→v: None.
- Files:
-
1.2 Add backend unit tests for range helpers
- Files:
backend/tests/test_prometheus_range.py(new) - Lines: ~70
- Dependencies: 1.1
- Details: Test
step_for_windowfor all four presets asserts result yields 100–300 points. Testnormalize_prometheus_matrix: feed a two-entry sample matrix (one with__name__, one colliding label) → assert{series}shape, label dedup(1)suffix, null handling for"NaN"string.
- Files:
-
1.3 Add
_fetch_chart+ shared range-query plumbing toPrometheusWidgetSource- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify) - Lines: ~55
- Dependencies: 1.1
- Details: Import
WINDOW_PRESETS,step_for_window,normalize_prometheus_matrixfromprometheus_range. Add a private_range_query(base_url, timeout, promql, window) -> dictreturning{"matrix": result}or{"error": ...}(shared by chart now and mean in S2). Add_fetch_chart(self, base_url, timeout, config)calling_range_queryand returning{"series": normalize_prometheus_matrix(matrix)}. Dispatchwidget_kind == "chart"in.fetch(). Existingmetricpath stays byte-for-byte unchanged. Errors (timeout,RequestException) →{"error": str}, never raise.
- Files:
-
1.4 Declare
chartwidget kind in Prometheus integration- Files:
backend/src/media_library_viewer_api/integrations/prometheus.py(modify) - Lines: ~15
- Dependencies: 1.3
- Details: Add
PrometheusChartWidgetConfig(WidgetConfigBase)withpromql: strandwindow: str = "1h". Add awidget_kind(...)entry forchart(refresh 60s, default config{"promql": "", "window": "1h"}). Leavemetrickind untouched.
- Files:
-
1.5 Rename
GrafanaChartWidget→PrometheusChartWidget- Files:
frontend/src/widgets/GrafanaChartWidget.tsx→frontend/src/widgets/PrometheusChartWidget.tsx(git mv) - Lines: ~5 changed (rename export, fix empty-state copy)
- Dependencies: none
- Details:
git mvto preserve history. Rename exported functionGrafanaChartWidget→PrometheusChartWidget. The recharts body (LineChart,Line,XAxis,YAxis,CartesianGrid,Tooltip,ResponsiveContainer,mergeSeries,CHART_COLORS,formatTime) is preserved unchanged (SC-106). Fix empty-state copy: "Check your query and datasource_uid" → "Check your PromQL query and window."
- Files:
-
1.6 Rename chart widget test
- Files:
frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx→frontend/src/widgets/__tests__/PrometheusChartWidget.test.tsx(git mv) - Lines: ~10 changed (import path, component name, error-message assertion)
- Dependencies: 1.5
- Details:
git mv. Update import toPrometheusChartWidget. Update error-state assertion: the old Grafana-specific error string ("Grafana api_key is required") → a Prom error string (e.g."promql is required"). Keep loading + rendered-data test cases.
- Files:
-
1.7 Rebind
chartfrom grafana to prometheus in frontend registry- Files:
frontend/src/integrations/registry.ts(modify) - Lines: ~30
- Dependencies: 1.5
- Details: Import
PrometheusChartWidget. Add achartentry to theprometheusbinding'swidgetsarray (kindchart, refresh 60s, configSchema withpromql+window). Remove thechartentry from thegrafanabinding'swidgetsarray (leavelinkintact). Do NOT delete thegrafanabinding itself.
- Files:
-
1.8 Update frontend widgets barrel export
- Files:
frontend/src/widgets/index.ts(modify) - Lines: ~2
- Dependencies: 1.5
- Details: Rename the
GrafanaChartWidgetexport toPrometheusChartWidget. LeaveGrafanaLinkWidgetexport intact.
- Files:
-
1.9 Update registry tests for chart rebind
- Files:
frontend/src/integrations/registry.test.ts(modify) - Lines: ~15
- Dependencies: 1.7
- Details: Assert
prometheusbinding hasmetric+chartkinds. Assertgrafanabinding has onlylink(nochart).
- Files:
-
1.10 Verify Slice 1 (build + lint + test)
- Run:
cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint - Verify: helpers tests pass; existing widget tests pass (grafana link adapter still wired); frontend typechecks and lints;
prometheus/chartwidget resolves toPrometheusChartWidget.
- Run:
Slice 1 total: ~260–330 changed lines.
Slice 2: Gauge + mean widgets
Goal: Add gauge and mean widget kinds to the prometheus service. Gauge renders an instant scalar with configurable threshold bands. Mean renders a single value computed client-side over a range-query window. Both are scalar-only.
Satisfies: SC-109, SC-110, SC-111, SC-112, SC-113, SC-114.
-
2.1 Extract shared
_instant_queryhelper + add_fetch_gaugetoPrometheusWidgetSource- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify) - Lines: ~40
- Dependencies: Slice 1 (1.3)
- Details: Extract the instant-query HTTP call from the existing
metricpath into a private_instant_query(base_url, timeout, promql) -> dictreturning{"result": [...]}or{"error": ...}. Refactor themetricpath to use it (behavior unchanged). Add_fetch_gauge(self, base_url, timeout, config)using_instant_query: assertlen(result) == 1(scalar-only, SC-111); parsefloat(result[0]["value"][1]); return{"value": float, "warn_at": config.get("warn_at"), "crit_at": config.get("crit_at"), "min": config.get("min"), "max": config.get("max"), "unit": config.get("unit")}. Dispatchwidget_kind == "gauge"in.fetch().
- Files:
-
2.2 Add
_fetch_meantoPrometheusWidgetSource- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify) - Lines: ~30
- Dependencies: 2.1, Slice 1 (1.3 for
_range_query) - Details: Add
_fetch_mean(self, base_url, timeout, config)using the shared_range_queryfrom S1. Assertlen(result) == 1(scalar-only, SC-114). Collect non-null numeric values from the single series; compute arithmetic mean; return{"value": mean, "unit": config.get("unit")}. If no numeric samples →{"error": "..."}. Dispatchwidget_kind == "mean"in.fetch().
- Files:
-
2.3 Declare
gauge+meanwidget kinds in Prometheus integration- Files:
backend/src/media_library_viewer_api/integrations/prometheus.py(modify) - Lines: ~25
- Dependencies: 2.1, 2.2
- Details: Add
PrometheusGaugeWidgetConfig(promql: str,warn_at: float|None,crit_at: float|None,min: float|None,max: float|None,unit: str|None) andPrometheusMeanWidgetConfig(promql: str,window: str = "1h",unit: str|None). Addwidget_kind(...)entries:gauge(refresh 30s),mean(refresh 60s).
- Files:
-
2.4 Add backend tests for gauge + mean adapters
- Files:
backend/tests/test_widgets.py(modify) orbackend/tests/test_prometheus_range.py(modify) - Lines: ~60
- Dependencies: 2.1, 2.2
- Details: Mock
requests.getfor gauge: instant query returning 1 series → assert{value, ...}shape; returning 2 series → assert{"error": ...}(SC-111). Mock for mean: range query returning 1 series with known values[1.0, 2.0, 3.0]→ assert mean2.0; returning 2 series → assert{"error": ...}(SC-114). Test timeout/RequestException →{"error": ...}.
- Files:
-
2.5 Create
PrometheusGaugeWidgetcomponent- Files:
frontend/src/widgets/PrometheusGaugeWidget.tsx(new) - Lines: ~90
- Dependencies: Slice 1 (1.5 for widget pattern)
- Details: Render via recharts
RadialBarChart(no new dep; SC-110). Threshold bands: three stackedRadialBartrack cells (green0→warn, amberwarn→crit, redcrit→max) + a value cell. Whenwarn_at/crit_atabsent → single neutral-color track.min/maxdefault to0/max(value, 1). ReuseSectionCard+Alert/Skeletonfor loading/error states. Consumedata?.data?.value,warn_at, etc. offuseWidgetData.
- Files:
-
2.6 Create
PrometheusMeanWidgetcomponent- Files:
frontend/src/widgets/PrometheusMeanWidget.tsx(new) - Lines: ~50
- Dependencies: Slice 1
- Details: Single-value display reusing the
MetricCardpattern (big number + optionalunitsuffix + subtext "mean over last {window}"). Loading/error/empty viaSkeleton/Alert. No charting library — it's a number (SC-112).
- Files:
-
2.7 Create gauge + mean frontend tests
- Files:
frontend/src/widgets/__tests__/PrometheusGaugeWidget.test.tsx(new),frontend/src/widgets/__tests__/PrometheusMeanWidget.test.tsx(new) - Lines: ~60
- Dependencies: 2.5, 2.6
- Details: Each covers loading, error, and rendered-data case (SC-125). Gauge test: render with bands (
warn_at/crit_atset) and without (single color). Mean test: render withvalue+unit.
- Files:
-
2.8 Add gauge + mean bindings to frontend registry
- Files:
frontend/src/integrations/registry.ts(modify) - Lines: ~35
- Dependencies: 2.5, 2.6
- Details: Import
PrometheusGaugeWidget+PrometheusMeanWidget. Addgauge(refresh 30s, configSchema withpromql,warn_at,crit_at,min,max,unit) andmean(refresh 60s, configSchema withpromql,window,unit) entries to theprometheusbinding'swidgetsarray alongsidemetricandchart.
- Files:
-
2.9 Update widgets barrel + registry tests
- Files:
frontend/src/widgets/index.ts(modify),frontend/src/integrations/registry.test.ts(modify) - Lines: ~10
- Dependencies: 2.5, 2.6, 2.8
- Details: Export
PrometheusGaugeWidget+PrometheusMeanWidget. Assertprometheusbinding hasmetric,chart,gauge,mean(four kinds).
- Files:
-
2.10 Verify Slice 2 (build + lint + test)
- Run:
cd backend && PYTHONPATH=src pytest tests/test_prometheus_range.py tests/test_widgets.py && cd ../frontend && npm run build && npm run lint - Verify: gauge/mean adapter tests pass; frontend typechecks and lints; all four prometheus widget kinds resolve.
- Run:
Slice 2 total: ~310–400 changed lines.
Slice 3: Grafana removal + config rewrite + changelog
Goal: Remove the entire Grafana surface (service type, link widget, chart source, status checks, nav entries, UI tabs). Rewrite config.yaml to match reality. Add CHANGELOG migration note. Leave the app grep-clean of grafana service references.
Satisfies: SC-115, SC-116, SC-117, SC-118, SC-119, SC-120, SC-121, SC-122, SC-126.
Spec-text drift note: SC-118 literally names
ObservabilityPage.tsx, which was refactored intoservice-tabs/. The intent (no Grafana UI surface) is verified by the removals below.Dashboard.test.tsxcontains "Grafana" as a user-authored shortcut label (unrelated to the grafana service) — SC-116 grep should not flag it; it is left intact.
-
3.1 Delete backend Grafana integration module
- Files:
backend/src/media_library_viewer_api/integrations/grafana.py(delete) - Lines: ~−20 (deletion)
- Dependencies: Slice 1 (chart kind already moved to prometheus)
- Details: Delete the file. It contains
GrafanaConfig+GrafanaLinkWidgetConfig.
- Files:
-
3.2 Remove Grafana from backend integration registry
- Files:
backend/src/media_library_viewer_api/integrations/registry.py(modify) - Lines: ~−3
- Dependencies: 3.1
- Details: Drop
from ...grafana import DEFINITION as GRAFANAand theGRAFANA.service_type: GRAFANAentry fromSERVICE_DEFINITIONS.
- Files:
-
3.3 Remove
GrafanaWidgetSource+ adapter registration- Files:
backend/src/media_library_viewer_api/widgets/sources.py(modify) - Lines: ~−100 (deletion of
GrafanaWidgetSourceclass +_fetch_chart) - Dependencies: Slice 1 (normalization logic already extracted to
prometheus_range.py) - Details: Delete the
GrafanaWidgetSourceclass entirely (including_fetch_chart— its logic was extracted tonormalize_prometheus_matrixin S1). Remove"grafana": GrafanaWidgetSource()fromSERVICE_ADAPTERS.
- Files:
-
3.4 Remove
get_grafana_statusendpoint- Files:
backend/src/media_library_viewer_api/routers/monitoring.py(modify) - Lines: ~−25
- Dependencies: none
- Details: Delete the
@router.get("/grafana-status")endpoint and its helper. Leaveget_prometheus_status/get_alertmanager_statusintact.
- Files:
-
3.5 Remove Grafana backend tests
- Files:
backend/tests/test_widgets.py(modify),backend/tests/test_api.py(modify),backend/tests/test_services.py(modify) - Lines: ~−40
- Dependencies: 3.3
- Details: Delete grafana adapter tests (
test_grafana_adapter_*), grafana service fixtures, and theTestGrafanaStatustest class. SC-120.
- Files:
-
3.6 Delete frontend
GrafanaLinkWidget+ barrel export- Files:
frontend/src/widgets/GrafanaLinkWidget.tsx(delete),frontend/src/widgets/index.ts(modify) - Lines: ~−35
- Dependencies: none
- Details: Delete the file. Remove the
GrafanaLinkWidgetexport fromwidgets/index.ts.
- Files:
-
3.7 Remove
grafanabinding from frontend registry- Files:
frontend/src/integrations/registry.ts(modify) - Lines: ~−30
- Dependencies: 3.6
- Details: Delete the entire
grafanakey fromSERVICE_REGISTRY. Drop theGrafanaLinkWidgetimport. SC-117.
- Files:
-
3.8 Remove Grafana nav entry
- Files:
frontend/src/integrations/navEntries.ts(modify) - Lines: ~−5
- Dependencies: none
- Details: Drop the
grafanaentry fromSERVICE_TYPE_NAV_ENTRIESand any now-unused icon import (e.g.Link2).
- Files:
-
3.9 Remove Grafana status hook + API client function + type
- Files:
frontend/src/hooks/useObservability.ts(modify),frontend/src/api/client.ts(modify),frontend/src/types/index.ts(modify) - Lines: ~−15
- Dependencies: 3.4
- Details: Drop
useGrafanaStatus+ itsfetchGrafanaStatusimport fromuseObservability.ts. DropfetchGrafanaStatusfromclient.ts. DropGrafanaStatusinterface fromtypes/index.ts.
- Files:
-
3.10 Delete Grafana service tab + remove from tab index
- Files:
frontend/src/pages/service-tabs/LinksTab.tsx(delete),frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx(delete),frontend/src/pages/service-tabs/index.ts(modify) - Lines: ~−80
- Dependencies: none
- Details: Delete
LinksTab.tsx(grafana-specific per its docstring) and its test. Remove theLinksTabimport andcase "grafana":fromservice-tabs/index.ts.
- Files:
-
3.11 Remove Grafana from Dashboard + ServicesPage
- Files:
frontend/src/pages/Dashboard.tsx(modify),frontend/src/pages/ServicesPage.tsx(modify) - Lines: ~−5
- Dependencies: none
- Details: Drop
"grafana"fromOBSERVABILITY_TYPESset inDashboard.tsx. UpdateServicesPage.tsxempty-state copy: "Add a Grafana, Prometheus, …" → "Add a Prometheus, …".
- Files:
-
3.12 Rewrite
openspec/config.yamlthin-dashboard rule- Files:
openspec/config.yaml(modify) - Lines: ~8
- Dependencies: none
- Details: Remove "Do NOT re-implement charting in-app" and "No recharts/d3 is in use" claims. Replace with accurate wording: in-app charting via
rechartsis the sanctioned approach for Prometheus-backed series; Grafana is no longer referenced. SC-121.
- Files:
-
3.13 Add CHANGELOG migration note
- Files:
CHANGELOG.md(modify) - Lines: ~8
- Dependencies: none
- Details: Add entry under an appropriate version heading: instruct operators to delete existing Grafana service instances and recreate them as Prometheus services; note that
grafana/chartwidgets must be recreated asprometheus/chartwidgets. SC-122, SC-126.
- Files:
-
3.14 Verify Slice 3 (grep-clean + build + lint + test)
- Run:
grep -ri grafana backend/src --include='*.py'→ expect zero matches (SC-115) - Run:
grep -ri grafana frontend/src→ expect zero matches exceptDashboard.test.tsxshortcut fixture (SC-116) - Run:
cd backend && PYTHONPATH=src pytest && cd ../frontend && npm run build && npm run lint - Verify: no grafana service references remain; all tests pass; frontend builds and lints.
- Run:
Slice 3 total: ~250–350 changed lines (mostly deletions).
Integration and acceptance verification
-
4.1 Full backend test run
- Run:
cd backend && PYTHONPATH=src pytest - Verify: all tests pass; no grafana test references remain; prometheus range/gauge/mean tests pass.
- Run:
-
4.2 Full frontend build + lint
- Run:
cd frontend && npm run build && npm run lint - Verify: TypeScript compiles; no lint failures; no grafana imports unresolved.
- Run:
-
4.3 Grep-clean verification
- Run:
grep -ri grafana backend/src --include='*.py'→ zero matches - Run:
grep -ri grafana frontend/src→ zero matches excludingDashboard.test.tsxshortcut fixture - Verify: SC-115, SC-116 satisfied.
- Run:
-
4.4 config.yaml accuracy check
- Verify:
openspec/config.yamldoes not contain "Do NOT re-implement charting" or "No recharts/d3"; reflects recharts as sanctioned renderer (SC-121).
- Verify:
-
4.5 CHANGELOG check
- Verify:
CHANGELOG.mddocuments the grafana→prometheus migration (SC-122).
- Verify:
Total estimate
| Slice | Changed lines | Satisfies |
|---|---|---|
| Slice 1: Prom range path + chart rebrand + helper | ~260–330 | SC-101…108 |
| Slice 2: Gauge + mean widgets | ~310–400 | SC-109…114 |
| Slice 3: Grafana removal + config + changelog | ~250–350 | SC-115…122, 126 |
| Integration verification | ~0 | SC-123…125, 127 |
| Total | ~820–1,080 | SC-101…127 |
Each slice is under the 400-line review budget. Use three chained PRs (S1 → S2 → S3), each independently buildable and green.
Risk flags for the apply phase
git mvfor chart widget rename — usegit mv(not delete+create) to preserve file history (design §9).- recharts
RadialBarChartgauge — try recharts first; if rendering proves fiddly, the proposal sanctions a ~50-line SVG fallback (no new dep). Decision at apply time. Dashboard.test.tsx"Grafana" literal — this is a user-authored shortcut label in a test fixture, not a grafana service reference. SC-116 grep should not flag it. Flagged for reviewer awareness.- SC-118 textual drift —
ObservabilityPage.tsxno longer exists (refactored toservice-tabs/). The intent is satisfied by removingget_grafana_status+useGrafanaStatus+LinksTab. Verify against intent, not literal filename. _range_query/_instant_queryrefactoring timing —_range_queryis created in S1 (1.3) for_fetch_chart;_instant_queryis extracted in S2 (2.1) when_fetch_gaugeneeds it. Both shared helpers must be in place before S3 (which deletesGrafanaWidgetSourceand its private chart logic).- Stale project map — the pi-map references
ObservabilityPage.tsxand 7 service types (actual: 8, includingauthentik+backups). Trust source, not the map. Runproject_map_patchafter source edits andproject_map_validatebefore final handoff.