Route metric queries through Grafana /api/ds/query instead of direct Prom
(Prom is firewalled / unreachable from Manage; Grafana is the only path).
Partial revert of prometheus-direct-charting: keep the gauge/mean/
LineSeriesChart rendering, restore the frames->series normalizer (recovered
from git 65bae95), change prometheus service config to hold Grafana gateway
fields (url + api_key + datasource_uid). Rename widgets to neutral Metric*.
First non-additive canonical sync (prometheus-charting MODIFIED). Updates the
pending service-credential-tester proposal dependency.
13 KiB
SDD Proposal: Grafana Metric Gateway
Change: grafana-metric-gateway
Phase: proposal
Date: 2026-07-09
1. Problem / Why Now
New network constraint surfaced after prometheus-direct-charting shipped (2026-07-08): the Prometheus instance is not directly reachable from the Manage backend (firewalled / different network / no auth proxy). Grafana is the only reachable surface and already holds an authenticated, datasource-aware API. The direct-Prom path added by prometheus-direct-charting therefore cannot work in production — every chart/gauge/mean/metric widget call fails at the network layer.
Rather than expose Prometheus directly (infra change outside Manage's control), route metric queries through Grafana's existing /api/ds/query datasource proxy. Grafana becomes the transport; Prometheus remains the logical source.
This is a partial revert of prometheus-direct-charting, justified by new information that was not on the table when direct-Prom was chosen. It is not a fourth flip of preference — the network reality forces it.
2. Target Users and Situations
- Primary users: Operators whose Prometheus sits behind a firewall or on an unreachable network, with Grafana as the only authenticated entry point.
- Workflow moments: identical to today — open a dashboard, see metric charts/gauges/means. The only change is that the data flows Grafana→Prom instead of Manage→Prom directly.
- Urgency: High. The widgets added in Change A are currently non-functional in this network topology.
3. Product Outcome
After this change:
- Operators configure a
prometheusservice whose connection fields point at a Grafana gateway (URL + API key + datasource UID), not at Prometheus directly. - The chart / gauge / mean / metric widget kinds behave exactly as today (same config, same rendering, same
{series}shape) — only the transport changes. - Widgets are renamed to neutral
Metric*names (MetricChartWidget,MetricGaugeWidget,MetricMeanWidget) so the component names survive future source changes and don't mislead (the widgets talk to Grafana now, not Prometheus directly). - The
prometheusservice type remains the sole metric surface — nografanaservice type is reintroduced.
4. Scope Boundaries and Non-Goals
In scope
prometheusservice config schema changes: drop the direct-Prombase_url; addgrafana_url(ServiceBaseUrl),datasource_uid(default"prometheus"), keeptimeout_seconds.prometheusservice secret schema changes: addgrafana_api_key(encrypted, required).MetricSourceadapter (rename ofPrometheusWidgetSource): thechart/gauge/mean/metricdispatch paths issuePOST {grafana_url}/api/ds/querywith the API key + datasource UID + PromQL expr, and normalize Grafana's frames response into the existing{series}/{value}shapes.- Frames→series normalization restored (recovered from git history at commit
65bae95, the pre-Change-AGrafanaWidgetSource._fetch_chart), refactored into the existingwidgets/prometheus_range.pyhelper module alongsidenormalize_prometheus_matrix. - Widget rename:
PrometheusChartWidget→MetricChartWidget,PrometheusGaugeWidget→MetricGaugeWidget,PrometheusMeanWidget→MetricMeanWidget(viagit mv, preserving history). Bindings inregistry.tsmove to the new names; theprometheusservice's widget kinds staychart/gauge/mean/metric(unchanged). - Status check update:
get_prometheus_statusnow runs a trivial query (up) through the Grafana gateway rather than hitting Prom/api/v1/status/buildinfodirectly. This actually validates the full path (Grafana up + datasource reachable + Prom responding) — a stronger signal than the pre-change check. service-credential-testerproposal update: the Prom test routine inopenspec/changes/service-credential-tester/proposal.mdmust be updated to test the Grafana gateway path (POST/api/ds/querywithup), not direct Prom. Dependency, not a blocker.- CHANGELOG migration note: existing
prometheusservice instances must be reconfigured (replacebase_urlwithgrafana_url+ addgrafana_api_keysecret + optionallydatasource_uid).
Non-goals (explicitly out of scope)
- Reintroducing the
grafanaservice type. Grafana is the transport, not a first-class service. Nografanaregistry entry, noGrafanaLinkWidget, noLinksTab, noget_grafana_status. (Those were intentionally removed; this change does not restore them.) - Grafana deep-link widgets (dashboard UID + panel ID links). Out of scope; the user explicitly wanted only the query gateway.
- A pluggable "data source middleware" framework. Per-widget-kind direct wiring only; no adapter registry abstraction. (Same discipline as Change A.)
- Touching the qBittorrent widgets, the storage harness, or MediaIndex. Unaffected.
- Renaming the
prometheusservice type itself. It staysprometheus(per operator decision — "Prometheus is the concept, Grafana is how I reach it"). - Exposing Prometheus directly as an alternative. Direct-Prom is removed, not kept-as-fallback. If a future topology can reach Prom directly, that's a separate change.
- Grafana status / health as a separate surface. Grafana reachability is folded into the Prom status check (it validates the gateway path).
5. High-Level Approach
5.1 Backend
-
integrations/prometheus.py— updatePrometheusConfig:- Remove
base_url. - Add
grafana_url: ServiceBaseUrl(reuses the existing schema-enforced http(s) validator). - Add
datasource_uid: str = "prometheus". - Keep
timeout_seconds: int = 10. - Secret schema: replace any existing secret with
grafana_api_key(required, encrypted).
- Remove
-
widgets/prometheus_range.py— addnormalize_grafana_frames(raw) -> list[series]alongside the existingnormalize_prometheus_matrix. Recovered from65bae95, refactored to share the label-dedup rule with the matrix normalizer. -
widgets/sources.py— renamePrometheusWidgetSource→MetricSource(internal class; service-type key stays"prometheus"inSERVICE_ADAPTERS). Each of_fetch_chart,_fetch_gauge,_fetch_mean, and the instant-metricpath switches from/api/v1/query[_range]toPOST {grafana_url}/api/ds/querywith body:{"queries": [{"datasource": {"uid": "<datasource_uid>", "type": "prometheus"}, "expr": "<promql>", "format": "time_series", "intervalMs": <step_ms>, "maxDataPoints": <pts>, "refId": "A"}], "from": "<window_start>", "to": "now"}Then normalize via
normalize_grafana_frames. Window presets (1h/6h/24h/7d) and step derivation (Change A'sstep_for_window) map to Grafana'sfrom/intervalMs. -
routers/monitoring.py—get_prometheus_statusrunsupthrough the gateway; success →{up: true, version: <grafana-prom-datasource-implication>}; failure → specific message (auth, unreachable, datasource not found). -
Tests — update existing tests: the chart/gauge/mean adapter tests now assert a
POST /api/ds/querycall (mocked) instead of a direct Prom GET. Reuse the same{series}/{value}assertions.
5.2 Frontend
git mvwidget files (history-preserving):PrometheusChartWidget.tsx→MetricChartWidget.tsx,PrometheusGaugeWidget.tsx→MetricGaugeWidget.tsx,PrometheusMeanWidget.tsx→MetricMeanWidget.tsx, plus their test files. Rename the exported components. The recharts/LineSeriesChart/gauge-band rendering is preserved unchanged.integrations/registry.ts— theprometheusservice binding keeps widget kindschart/gauge/mean/metricbut points at the renamedMetric*components. No user-visible change.- Service config form (
ServiceConfigFields) — automatically reflects the new schema (grafana_url, datasource_uid) since it renders from the backend-providedconfig_schema. No bespoke UI work beyond relabeling the secret field. - Types —
PrometheusStatustype stays; possibly gains agatewayfield noting the path. Minimal change.
5.3 Canonical spec impact — first non-additive sync
The prometheus-charting canonical domain (openspec/specs/prometheus-charting/spec.md) was created by Change A with requirements stating direct Prom querying (SC-101, SC-102, SC-104, etc.). This change MODIFIES those requirements: the data source becomes Grafana's gateway, not direct Prom. This is the first non-additive canonical sync in the project — sdd-sync will need a ## MODIFIED Requirements delta rather than ## ADDED. Flagged for the sync phase; the requirements' intent (multi-series chart, gauge bands, mean over window) is unchanged, only the mechanism (transport) changes.
6. Success Criteria / Acceptance Criteria
- A
prometheusservice configured with Grafana gateway fields (URL + API key + datasource UID) successfully powerschart/gauge/mean/metricwidgets — no direct Prom network call is made. - The chart widget renders multi-series line charts with the same look as today (recharts via
LineSeriesChart). - The gauge widget renders threshold bands; the mean widget renders the windowed mean — both sourced via Grafana.
- The instant
metricwidget returns a scalar via Grafana. get_prometheus_statusvalidates the full gateway path; a broken Grafana or unreachable datasource produces a specific error message.- Widgets are renamed to
MetricChartWidget/MetricGaugeWidget/MetricMeanWidget; noPrometheus*Widgetnames remain in frontend source; tests renamed consistently. - The
prometheus-chartingcanonical domain's transport-related requirements are MODIFIED to reflect the Grafana gateway; durable requirements (chart/gauge/mean behavior) are preserved. - The
service-credential-testerproposal's Prom test routine is updated to test the gateway path. - CHANGELOG documents the operator migration (reconfigure
prometheusservices with Grafana gateway fields). pytest,npm run build,npm run lint,npm run testall green.
7. Risks and Mitigations
| Risk | Mitigation |
|---|---|
| Reintroducing complexity that Change A removed. The frames→series normalization was deleted for a reason. | Recover the known-good implementation from 65bae95 rather than rewriting; refactor into prometheus_range.py so matrix + frames normalizers share the label-dedup rule. |
Grafana /api/ds/query response shape varies across Grafana versions. |
The recovered normalizer already handles the frames schema defensively (displayName → labels → field name fallback). Add a test fixture from a current Grafana version. |
Existing prometheus service instances break silently (their base_url config no longer exists). |
SC-9 CHANGELOG note; startup config validation rejects the old shape with a clear migration message. |
First non-additive canonical sync — sdd-sync MODIFIED delta could mismatch. |
Author the delta carefully; match requirement IDs exactly; verify with sdd-status before archive. |
| Credential-tester proposal dependency. | Update it in the same change (or sequence this change before the credential tester's apply). |
| Operators with a directly-reachable Prom lose that option. | Explicit non-goal; documented. If needed later, add a direct_url optional field as a separate change. |
| Review budget (>400 lines). | Likely 2 slices: (S1) backend config + adapter + frames normalizer + tests; (S2) frontend widget renames + registry + status check. |
8. Resolved Questions (no question round needed)
- Q1 — Keep
prometheusservice type? Yes. It remains the user-facing logical service; only its config/secret schema and adapter internals change. - Q2 — Restore
grafanaservice type? No. Grafana is the transport, not a service. No deep-link widgets, no status surface. - Q3 — Widget naming? Neutral
Metric*(MetricChartWidget / MetricGaugeWidget / MetricMeanWidget). Decouples widget identity from transport; survives future source changes. - Q4 — Direct Prom path kept as fallback? No. Removed. If a future topology needs it, separate change.
- Q5 — Status check approach? Run
upthrough the gateway; validates the full path in one call.
9. Future Phases
- Optional
direct_urlfallback on theprometheusservice for topologies where Prom IS directly reachable (skip the Grafana hop). - Multi-datasource support — one Grafana gateway, many datasources (Loki, InfluxDB) selectable per widget.
- Datasource health breakdown — status check distinguishes "Grafana down" vs "Prom datasource down" via Grafana's
/api/datasources/health.
Notes for downstream phases
- Dependency: the
service-credential-testerproposal must be updated when this change's design is firm (its Prom test routine changes from direct Prom to gateway query). - Canonical first: this is the project's first non-additive canonical sync (
prometheus-chartingMODIFIED requirements). The sync phase should be deliberate. - Recovery source: the frames→series normalizer is known-good at git commit
65bae95(backend/src/media_library_viewer_api/widgets/sources.py,_fetch_chart). Restore from there, don't rewrite. - Honest scope note: this partially reverts
prometheus-direct-charting(2026-07-08). That change's direct-Prom path and Grafana removal are undone; its gauge/mean/LineSeriesChart/rendering work is preserved. The reversal is justified by the new network constraint (Q1, §1).