feat(grafana-metric-gateway): slice 1 — backend gateway transport
Route all prometheus widget queries through Grafana /api/ds/query instead of
direct Prom HTTP. PrometheusConfig: drop base_url, add grafana_url +
datasource_uid; secret grafana_api_key (required). PrometheusWidgetSource →
MetricSource with _gateway_query POST method. normalize_grafana_frames
recovered from 65bae95 + shared _dedup_label helper. Gateway-path status
check. Startup old-config validation. CHANGELOG migration note. All adapter
tests rewritten for POST /api/ds/query + Grafana frames mock. Backend: 331
pytest pass, ruff clean. Frontend: build green (unchanged in S1).
This commit is contained in:
@@ -13,9 +13,10 @@ from media_library_viewer_api.integrations.base import (
|
||||
|
||||
|
||||
class PrometheusConfig(ServiceConfigBase):
|
||||
"""Non-secret Prometheus connection config."""
|
||||
"""Non-secret Prometheus-via-Grafana gateway config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
grafana_url: ServiceBaseUrl
|
||||
datasource_uid: str = "prometheus"
|
||||
timeout_seconds: int = 10
|
||||
|
||||
|
||||
@@ -57,7 +58,12 @@ DEFINITION = ServiceDefinition(
|
||||
description="Metrics storage and PromQL queries.",
|
||||
config_model=PrometheusConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||
SecretField(
|
||||
key="grafana_api_key",
|
||||
label="Grafana API key",
|
||||
required=True,
|
||||
helper="Service account token or API key for the Grafana gateway",
|
||||
),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
|
||||
@@ -37,6 +37,23 @@ from .version import get_backend_version, get_version_info
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_prometheus_gateway_config() -> None:
|
||||
"""Warn (not crash) about old-shape prometheus services needing migration (GM-113)."""
|
||||
try:
|
||||
store = get_settings_store()
|
||||
for service in store.list_services("prometheus"):
|
||||
config = service.get("config") or {}
|
||||
if "base_url" in config and "grafana_url" not in config:
|
||||
logger.warning(
|
||||
"Prometheus service '%s' (id=%s) uses the old 'base_url' config shape. "
|
||||
"Reconfigure with grafana_url + grafana_api_key (see CHANGELOG).",
|
||||
service.get("name"),
|
||||
service.get("id"),
|
||||
)
|
||||
except Exception: # pragma: no cover - startup best-effort
|
||||
logger.exception("Failed to validate prometheus gateway config during startup")
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Application lifespan — startup/shutdown."""
|
||||
@@ -58,6 +75,7 @@ async def lifespan(app: FastAPI):
|
||||
get_service_data_harness()
|
||||
except Exception:
|
||||
logger.exception("Failed to initialize service data harness during startup")
|
||||
_validate_prometheus_gateway_config()
|
||||
mail_queue = get_mail_queue()
|
||||
backup_poller = get_backup_poller()
|
||||
mail_queue.start()
|
||||
|
||||
@@ -177,23 +177,52 @@ def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Prometheus service instance's health and build info."""
|
||||
"""Probe a Prometheus service's health via the Grafana gateway path (GM-110).
|
||||
|
||||
Issues a trivial ``up`` query through Grafana ``/api/ds/query``. Success
|
||||
validates the full path: Grafana is reachable, the API key works, and the
|
||||
Prometheus datasource responds.
|
||||
"""
|
||||
service = resolve_service_record(store, "prometheus", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
base = _base_url(service)
|
||||
timeout = _timeout(service, 10)
|
||||
headers = _auth_headers(service)
|
||||
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("grafana_api_key") or "")
|
||||
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
if not grafana_url or not api_key:
|
||||
return _status_response(service, error="gateway_not_configured")
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": "up",
|
||||
"format": "time_series",
|
||||
"intervalMs": 15_000,
|
||||
"maxDataPoints": 1,
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": "now-1m",
|
||||
"to": "now",
|
||||
}
|
||||
try:
|
||||
health = requests.get(f"{base}/-/healthy", headers=headers, timeout=timeout)
|
||||
health.raise_for_status()
|
||||
build_info = requests.get(f"{base}/api/v1/status/buildinfo", headers=headers, timeout=timeout)
|
||||
build_info.raise_for_status()
|
||||
version = build_info.json().get("data", {}).get("version", "")
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Prometheus status")
|
||||
resp = requests.post(
|
||||
f"{grafana_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
except requests.HTTPError as exc:
|
||||
status_code = exc.response.status_code if exc.response else 0
|
||||
if status_code in (401, 403):
|
||||
return _status_response(service, error="auth_failed")
|
||||
return _status_response(service, error="gateway_error")
|
||||
except requests.RequestException:
|
||||
logger.exception("Failed to fetch Prometheus status via gateway")
|
||||
return _status_response(service, error="prometheus_unreachable")
|
||||
return _status_response(service, version=version)
|
||||
return _status_response(service, version="ok")
|
||||
|
||||
|
||||
@router.post("/alertmanager-webhook")
|
||||
|
||||
@@ -43,6 +43,15 @@ def step_for_window(window_seconds: int, target_points: int = 200) -> int:
|
||||
return max(15, round(window_seconds / target_points))
|
||||
|
||||
|
||||
def _dedup_label(label: str, seen: dict[str, int]) -> str:
|
||||
"""Apply `` (n)`` suffix on collision. Mutates and reads from ``seen`` dict."""
|
||||
if label in seen:
|
||||
seen[label] += 1
|
||||
return f"{label} ({seen[label]})"
|
||||
seen[label] = 0
|
||||
return label
|
||||
|
||||
|
||||
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
|
||||
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
|
||||
@@ -62,12 +71,7 @@ def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str,
|
||||
metric = entry.get("metric") or {}
|
||||
values = entry.get("values") or []
|
||||
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(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
|
||||
label = _dedup_label(" ".join(parts) if parts else "value", seen)
|
||||
points: list[dict[str, Any]] = []
|
||||
for ts, raw in values:
|
||||
t = _safe_int(ts)
|
||||
@@ -79,6 +83,52 @@ def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str,
|
||||
return series
|
||||
|
||||
|
||||
def normalize_grafana_frames(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Turn a Grafana ``/api/ds/query`` response into the ``{label, points}`` series shape.
|
||||
|
||||
Parses ``results.<refId>.frames[]`` where each frame has:
|
||||
- ``data.values``: ``[[timestamps...], [values...]]``
|
||||
- ``schema.fields``: ``[{name, labels?, config?: {displayName?}}, ...]``
|
||||
|
||||
Label rule (same as ``normalize_prometheus_matrix``, shared via ``_dedup_label``):
|
||||
1. Prefer ``config.displayName`` (explicitly set in Grafana).
|
||||
2. Else use Prometheus metric labels (sorted ``k=v``, excluding ``__``-prefixed).
|
||||
3. Else fall back to the field name, or ``"value"``.
|
||||
4. Dedup collisions with `` (n)`` suffix.
|
||||
"""
|
||||
series: list[dict[str, Any]] = []
|
||||
seen: dict[str, int] = {}
|
||||
results = raw.get("results", {})
|
||||
for _ref_id, ref_data in results.items():
|
||||
for frame in ref_data.get("frames", []):
|
||||
values = frame.get("data", {}).get("values", [])
|
||||
if len(values) < 2:
|
||||
continue
|
||||
timestamps = values[0]
|
||||
vals = values[1]
|
||||
# Derive a meaningful series label from the frame metadata.
|
||||
fields = frame.get("schema", {}).get("fields", [])
|
||||
value_field = fields[-1] if fields else {}
|
||||
display_name = value_field.get("config", {}).get("displayName") or value_field.get("displayName")
|
||||
frame_labels = value_field.get("labels") or {}
|
||||
if display_name:
|
||||
label = str(display_name)
|
||||
elif frame_labels:
|
||||
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not str(k).startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
else:
|
||||
label = str(value_field.get("name", "value"))
|
||||
label = _dedup_label(label, seen)
|
||||
points = []
|
||||
for t, v in zip(timestamps, vals):
|
||||
safe_t = _safe_int(t)
|
||||
if safe_t is None:
|
||||
continue
|
||||
points.append({"t": safe_t, "v": _safe_float(v)})
|
||||
series.append({"label": label, "points": points})
|
||||
return series
|
||||
|
||||
|
||||
def _safe_float(raw: Any) -> float | None:
|
||||
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
|
||||
if raw in _NON_NUMERIC:
|
||||
|
||||
@@ -30,7 +30,8 @@ from media_library_viewer_api.services.settings_store import SettingsStore, get_
|
||||
from media_library_viewer_api.services.task_runner import run_saved_task
|
||||
from media_library_viewer_api.widgets.prometheus_range import (
|
||||
WINDOW_PRESETS,
|
||||
normalize_prometheus_matrix,
|
||||
normalize_grafana_frames,
|
||||
normalize_prometheus_matrix, # noqa: F401 — kept for future direct_url path (design decision 5)
|
||||
step_for_window,
|
||||
)
|
||||
|
||||
@@ -104,113 +105,118 @@ class StaticWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run PromQL queries against a Prometheus service (instant + range)."""
|
||||
class MetricSource:
|
||||
"""Run PromQL queries through a Grafana gateway (``/api/ds/query``)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Prometheus widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
grafana_url = str(service.config.get("grafana_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("grafana_api_key") or "")
|
||||
datasource_uid = str(service.config.get("datasource_uid") or "prometheus")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, timeout, config)
|
||||
return await self._fetch_chart(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
if widget_kind == "gauge":
|
||||
return await self._fetch_gauge(base_url, timeout, config)
|
||||
return await self._fetch_gauge(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
if widget_kind == "mean":
|
||||
return await self._fetch_mean(base_url, timeout, config)
|
||||
# Default: instant-query metric path (unchanged).
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
|
||||
return raw
|
||||
return await self._fetch_mean(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
# Default: instant-query metric path.
|
||||
return await self._fetch_metric(grafana_url, api_key, datasource_uid, timeout, config)
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
|
||||
"""Run a Prometheus ``/api/v1/query_range`` over a window (seconds).
|
||||
async def _gateway_query(
|
||||
self,
|
||||
grafana_url: str,
|
||||
api_key: str,
|
||||
datasource_uid: str,
|
||||
timeout: int,
|
||||
promql: str,
|
||||
window_seconds: int | None = None,
|
||||
max_data_points: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""POST ``{grafana_url}/api/ds/query``; return raw Grafana JSON or ``{error}``.
|
||||
|
||||
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
|
||||
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
|
||||
per SC-103).
|
||||
- ``window_seconds=None`` → instant mapping (``from=now-1m, maxDataPoints=1``).
|
||||
- ``window_seconds=<N>`` → range query (``from=now-Ns``, step derived).
|
||||
"""
|
||||
step = step_for_window(window)
|
||||
end = int(time.time())
|
||||
start = end - window
|
||||
try:
|
||||
response = 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,
|
||||
),
|
||||
if not grafana_url:
|
||||
return {"error": "grafana_url is required"}
|
||||
if not api_key:
|
||||
return {"error": "grafana_api_key is required"}
|
||||
step = step_for_window(window_seconds) if window_seconds else 15
|
||||
interval_ms = step * 1000
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": promql,
|
||||
"format": "time_series",
|
||||
"intervalMs": interval_ms,
|
||||
"maxDataPoints": 1 if window_seconds is None else max_data_points,
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": f"now-{window_seconds or 60}s" if window_seconds else "now-1m",
|
||||
"to": "now",
|
||||
}
|
||||
|
||||
def _do_post() -> dict[str, Any]:
|
||||
resp = requests.post(
|
||||
f"{grafana_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Prometheus query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus range query failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
result = payload.get("data", {}).get("result", [])
|
||||
return {"matrix": result}
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
|
||||
"""Run a Prometheus ``/api/v1/query`` instant query.
|
||||
|
||||
Shared by the ``metric`` and ``gauge`` widget kinds. Returns
|
||||
``{"result": data}`` on success or ``{"error": str}`` (never raises,
|
||||
per SC-103).
|
||||
"""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
try:
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
f"{base_url}/api/v1/query",
|
||||
params={"query": promql},
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Prometheus query timed out"}
|
||||
return {"error": "Grafana query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus instant query failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
return {"result": payload.get("data", {})}
|
||||
logger.exception("grafana gateway query failed")
|
||||
return {"error": f"Grafana query failed: {exc}"}
|
||||
|
||||
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
|
||||
async def _fetch_chart(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Range query → ``{series}`` for the chart widget (GM-106)."""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._range_query(base_url, timeout, promql, window)
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
return {"series": normalize_prometheus_matrix(raw["matrix"])}
|
||||
return {"series": normalize_grafana_frames(raw)}
|
||||
|
||||
async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).
|
||||
async def _fetch_gauge(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Instant query → scalar for the gauge widget (GM-107).
|
||||
|
||||
Scalar-only: a multi-series query returns an error (SC-111). Threshold
|
||||
config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
|
||||
through for the frontend renderer.
|
||||
Scalar-only: a multi-series query returns an error. Threshold config is
|
||||
passed through for the frontend renderer.
|
||||
"""
|
||||
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
|
||||
promql = config.get("promql") or ""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["result"].get("result", [])
|
||||
if len(result) != 1:
|
||||
series = normalize_grafana_frames(raw)
|
||||
if len(series) != 1:
|
||||
return {"error": "Gauge requires a single-series query; refine your PromQL"}
|
||||
try:
|
||||
value = float(result[0]["value"][1])
|
||||
except (KeyError, IndexError, ValueError, TypeError):
|
||||
points = series[0]["points"]
|
||||
if not points:
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
value = points[-1]["v"]
|
||||
if value is None:
|
||||
return {"error": "Gauge query returned no scalar value"}
|
||||
return {
|
||||
"value": value,
|
||||
@@ -221,36 +227,46 @@ class PrometheusWidgetSource:
|
||||
"unit": config.get("unit"),
|
||||
}
|
||||
|
||||
async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Range query → client-side mean for the mean widget (SC-112..SC-114).
|
||||
async def _fetch_mean(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Range query → client-side mean for the mean widget (GM-108).
|
||||
|
||||
Runs ``query_range`` over the configured window preset, averages all
|
||||
non-null numeric samples of the single series, and returns a scalar.
|
||||
Scalar-only: a multi-series query returns an error (SC-114).
|
||||
Runs a gateway range query over the configured window preset, averages
|
||||
all non-null numeric samples of the single series, and returns a scalar.
|
||||
Scalar-only: a multi-series query returns an error.
|
||||
"""
|
||||
promql = config.get("promql")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
|
||||
raw = await self._range_query(base_url, timeout, promql, window)
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=window)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
result = raw["matrix"]
|
||||
if len(result) != 1:
|
||||
series = normalize_grafana_frames(raw)
|
||||
if len(series) != 1:
|
||||
return {"error": "Mean requires a single-series query; refine your PromQL"}
|
||||
points = result[0].get("values") or []
|
||||
nums: list[float] = []
|
||||
for _, v in points:
|
||||
if v in (None, "NaN", "+Inf", "-Inf"):
|
||||
continue
|
||||
try:
|
||||
nums.append(float(v))
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
nums = [p["v"] for p in series[0]["points"] if p["v"] is not None]
|
||||
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")}
|
||||
return {"value": sum(nums) / len(nums), "unit": config.get("unit")}
|
||||
|
||||
async def _fetch_metric(
|
||||
self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Instant query → ``{result}`` for the metric widget (GM-109).
|
||||
|
||||
Returns ``{result: [{label, points}]}`` — the normalized series shape.
|
||||
The frontend ``PrometheusMetricWidget`` renders the last point of each
|
||||
series.
|
||||
"""
|
||||
promql = config.get("promql") or ""
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
raw = await self._gateway_query(grafana_url, api_key, datasource_uid, timeout, promql, window_seconds=None)
|
||||
if "error" in raw:
|
||||
return raw
|
||||
return {"result": normalize_grafana_frames(raw)}
|
||||
|
||||
|
||||
class AlertmanagerWidgetSource:
|
||||
@@ -437,7 +453,7 @@ class QbittorrentWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"prometheus": MetricSource(),
|
||||
"qbittorrent": QbittorrentWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
|
||||
Reference in New Issue
Block a user