feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog
Remove the entire Grafana surface: integrations/grafana.py, GrafanaWidgetSource (+ _fetch_chart, now redundant since prometheus chart exists), GrafanaLinkWidget, LinksTab, get_grafana_status endpoint, useGrafanaStatus hook, GrafanaStatus type, fetchGrafanaStatus client fn, registry/nav/tab entries (FE+BE). Rewrite config.yaml thin-dashboard rule to match reality (recharts is sanctioned for Prometheus-backed series). CHANGELOG migration note added. Backend: 293 pytest pass, ruff clean. Frontend: build+lint green (0 errors). SC-115/116 grep-clean (only prometheus_range.py migration comments + Dashboard.test shortcut fixture remain — both spec-allowed).
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Base classes for service integrations.
|
||||
|
||||
A *service definition* is a closed, compile-time description of an external service
|
||||
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||
the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
|
||||
|
||||
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||
* the secret fields it accepts (API keys / tokens),
|
||||
@@ -24,7 +24,7 @@ from pydantic import BaseModel, BeforeValidator, Field
|
||||
def _validate_service_base_url(value: Any) -> str:
|
||||
"""Require an absolute http(s) URL for service ``base_url`` fields.
|
||||
|
||||
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
|
||||
Relative hosts (e.g. ``example.com``) break downstream HTTP clients
|
||||
because ``requests`` treats them as relative paths, so we fail fast with a
|
||||
clear error instead of letting the call silently malfunction.
|
||||
"""
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
"""Grafana service definition."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from media_library_viewer_api.integrations.base import (
|
||||
SecretField,
|
||||
ServiceBaseUrl,
|
||||
ServiceConfigBase,
|
||||
ServiceDefinition,
|
||||
WidgetConfigBase,
|
||||
widget_kind,
|
||||
)
|
||||
|
||||
|
||||
class GrafanaConfig(ServiceConfigBase):
|
||||
"""Non-secret Grafana connection config."""
|
||||
|
||||
base_url: ServiceBaseUrl
|
||||
timeout_seconds: int = 5
|
||||
|
||||
|
||||
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||
"""Deep-link to a Grafana dashboard or panel."""
|
||||
|
||||
dashboard_uid: str
|
||||
panel_id: int | None = None
|
||||
|
||||
|
||||
class GrafanaChartWidgetConfig(WidgetConfigBase):
|
||||
"""Render a time-series chart from a Grafana datasource query."""
|
||||
|
||||
datasource_uid: str = "prometheus"
|
||||
query: str = ""
|
||||
from_ts: str = "now-1h"
|
||||
to_ts: str = "now"
|
||||
interval_ms: int = 30_000
|
||||
max_data_points: int = 100
|
||||
|
||||
|
||||
DEFINITION = ServiceDefinition(
|
||||
service_type="grafana",
|
||||
name="Grafana",
|
||||
description="Dashboards, metrics, and logs.",
|
||||
config_model=GrafanaConfig,
|
||||
secret_fields=[
|
||||
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||
],
|
||||
widget_kinds=[
|
||||
widget_kind(
|
||||
kind="link",
|
||||
name="Dashboard link",
|
||||
description="Deep-link to a Grafana dashboard or panel.",
|
||||
model_cls=GrafanaLinkWidgetConfig,
|
||||
default_config={"dashboard_uid": ""},
|
||||
refresh_interval_ms=0,
|
||||
),
|
||||
widget_kind(
|
||||
kind="chart",
|
||||
name="Chart",
|
||||
description="Live time-series chart from a Grafana datasource query.",
|
||||
model_cls=GrafanaChartWidgetConfig,
|
||||
default_config={
|
||||
"datasource_uid": "prometheus",
|
||||
"query": "",
|
||||
"from_ts": "now-1h",
|
||||
"to_ts": "now",
|
||||
"interval_ms": 30_000,
|
||||
"max_data_points": 100,
|
||||
},
|
||||
refresh_interval_ms=60_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -10,14 +10,12 @@ from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALE
|
||||
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
|
||||
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
|
||||
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||
|
||||
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||
GRAFANA.service_type: GRAFANA,
|
||||
PROMETHEUS.service_type: PROMETHEUS,
|
||||
ALERTMANAGER.service_type: ALERTMANAGER,
|
||||
JELLYFIN.service_type: JELLYFIN,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
Widgets are either:
|
||||
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||
by that service's definition (Prometheus metric, Jellyfin
|
||||
activity, SSH task output); or
|
||||
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||
service-less kinds (backups, static).
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Monitoring router — observability service status.
|
||||
|
||||
Observability components (Alertmanager, Grafana, Prometheus) are resolved from
|
||||
Observability components (Alertmanager, Prometheus) are resolved from
|
||||
the service registry, not environment variables. The endpoints pick the first
|
||||
enabled instance of a type when no ``service_id`` is given, and return graceful
|
||||
"not configured" / "unreachable" payloads so the UI always renders a health card.
|
||||
@@ -172,29 +172,6 @@ def get_alertmanager_status(
|
||||
}
|
||||
|
||||
|
||||
@router.get("/grafana-status")
|
||||
def get_grafana_status(
|
||||
service_id: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
|
||||
service = resolve_service_record(store, "grafana", service_id)
|
||||
if service is None:
|
||||
return _status_response(None, error="no_service_configured")
|
||||
try:
|
||||
response = requests.get(
|
||||
f"{_base_url(service)}/api/health",
|
||||
headers=_auth_headers(service),
|
||||
timeout=_timeout(service, 5),
|
||||
)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
except Exception:
|
||||
logger.exception("Failed to fetch Grafana status")
|
||||
return _status_response(service, error="grafana_unreachable")
|
||||
return _status_response(service, version=data.get("version", ""))
|
||||
|
||||
|
||||
@router.get("/prometheus-status")
|
||||
def get_prometheus_status(
|
||||
service_id: str | None = None,
|
||||
|
||||
@@ -81,7 +81,7 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||
# metrics now live in Prometheus/node_exporter. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
|
||||
@@ -102,118 +102,6 @@ class StaticWidgetSource:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class GrafanaWidgetSource:
|
||||
"""Build a Grafana deep-link or query datasource for a chart."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
if service is None:
|
||||
return {"error": "Grafana widget is missing its service"}
|
||||
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||
api_key = str(service.secrets.get("api_key") or "")
|
||||
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||
|
||||
if widget_kind == "chart":
|
||||
return await self._fetch_chart(base_url, api_key, timeout, config)
|
||||
|
||||
# Default: deep-link
|
||||
dashboard_uid = config.get("dashboard_uid")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
async def _fetch_chart(self, base_url: str, api_key: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Query Grafana /api/ds/query and normalize to {series: [...]}."""
|
||||
if not api_key:
|
||||
return {"error": "Grafana api_key is required for chart queries"}
|
||||
query = config.get("query", "")
|
||||
if not query:
|
||||
return {"error": "query is required"}
|
||||
|
||||
datasource_uid = config.get("datasource_uid", "prometheus")
|
||||
body = {
|
||||
"queries": [
|
||||
{
|
||||
"datasource": {"uid": datasource_uid, "type": "prometheus"},
|
||||
"expr": query,
|
||||
"format": "time_series",
|
||||
"intervalMs": int(config.get("interval_ms", 30_000)),
|
||||
"maxDataPoints": int(config.get("max_data_points", 100)),
|
||||
"refId": "A",
|
||||
}
|
||||
],
|
||||
"from": config.get("from_ts", "now-1h"),
|
||||
"to": config.get("to_ts", "now"),
|
||||
}
|
||||
|
||||
def _do_post() -> dict[str, Any]:
|
||||
resp = requests.post(
|
||||
f"{base_url}/api/ds/query",
|
||||
json=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {api_key}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=timeout,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
try:
|
||||
raw = await asyncio.wait_for(asyncio.to_thread(_do_post), timeout=timeout)
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Grafana query timed out"}
|
||||
except requests.RequestException as exc:
|
||||
return {"error": f"Grafana query failed: {exc}"}
|
||||
|
||||
# Normalize Grafana's /api/ds/query response into series.
|
||||
series: list[dict[str, Any]] = []
|
||||
results = raw.get("results", {})
|
||||
seen_labels: dict[str, int] = {}
|
||||
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.
|
||||
# Prometheus frames carry metric labels in schema.fields[-1].labels.
|
||||
fields = frame.get("schema", {}).get("fields", [])
|
||||
value_field = fields[-1] if fields else {}
|
||||
# Prefer displayName (explicitly set in Grafana), then Prometheus
|
||||
# labels (e.g. {instance: "server:9100", mode: "iowait"}), then
|
||||
# the field name as a last resort.
|
||||
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:
|
||||
# Build a readable label from the Prometheus labels, excluding
|
||||
# redundant ones like __name__.
|
||||
parts = [f"{k}={v}" for k, v in sorted(frame_labels.items()) if not k.startswith("__")]
|
||||
label = " ".join(parts) if parts else "value"
|
||||
else:
|
||||
label = value_field.get("name", "value")
|
||||
# Ensure unique labels when multiple series share the same name.
|
||||
if label in seen_labels:
|
||||
seen_labels[label] += 1
|
||||
label = f"{label} ({seen_labels[label]})"
|
||||
else:
|
||||
seen_labels[label] = 0
|
||||
points = [{"t": int(t), "v": float(v) if v is not None else None} for t, v in zip(timestamps, vals)]
|
||||
series.append({"label": label, "points": points})
|
||||
|
||||
return {"series": series}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run PromQL queries against a Prometheus service (instant + range)."""
|
||||
|
||||
@@ -485,7 +373,6 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"grafana": GrafanaWidgetSource(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"alertmanager": AlertmanagerWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
|
||||
@@ -522,7 +522,7 @@ class TestResolveServiceRecord:
|
||||
def test_service_id_type_mismatch_returns_none(self):
|
||||
from media_library_viewer_api.services.service_resolution import resolve_service_record
|
||||
|
||||
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}}
|
||||
row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
|
||||
store = self._store([row])
|
||||
assert resolve_service_record(store, "alertmanager", "x1") is None
|
||||
|
||||
@@ -741,48 +741,6 @@ class TestAlertmanagerWebhook:
|
||||
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
|
||||
|
||||
|
||||
class TestGrafanaStatus:
|
||||
def test_grafana_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "no_service_configured"
|
||||
|
||||
def test_grafana_status_when_unreachable(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
with (
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is False
|
||||
assert data["error"] == "grafana_unreachable"
|
||||
assert data["name"] == "Grafana"
|
||||
|
||||
def test_grafana_status_returns_version(self, test_client):
|
||||
service = ServiceRecord(
|
||||
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
|
||||
)
|
||||
resp = MagicMock()
|
||||
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
|
||||
resp.raise_for_status = MagicMock()
|
||||
with (
|
||||
patch(f"{_MON}.resolve_service_record", return_value=service),
|
||||
patch(f"{_MON}.requests.get", return_value=resp),
|
||||
):
|
||||
response = test_client.get("/api/monitoring/grafana-status")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["up"] is True
|
||||
assert data["version"] == "11.3.1"
|
||||
assert data["service_id"] == "g1"
|
||||
|
||||
|
||||
class TestPrometheusStatus:
|
||||
def test_prometheus_status_when_not_configured(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-status")
|
||||
|
||||
@@ -57,9 +57,8 @@ def client(tmp_path):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_registry_contains_eight_service_types():
|
||||
def test_registry_contains_seven_service_types():
|
||||
assert set(SERVICE_DEFINITIONS) == {
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"alertmanager",
|
||||
"jellyfin",
|
||||
@@ -99,7 +98,6 @@ def test_authentik_service_definition():
|
||||
|
||||
|
||||
def test_definitions_declare_widget_kinds():
|
||||
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link", "chart"}
|
||||
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric", "chart", "gauge", "mean"}
|
||||
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
|
||||
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
|
||||
@@ -110,13 +108,13 @@ def test_definitions_declare_widget_kinds():
|
||||
|
||||
|
||||
def test_widget_kind_lookup():
|
||||
assert get_widget_kind("grafana", "link") is not None
|
||||
assert get_widget_kind("grafana", "missing") is None
|
||||
assert get_widget_kind("unknown", "link") is None
|
||||
assert get_widget_kind("prometheus", "metric") is not None
|
||||
assert get_widget_kind("prometheus", "missing") is None
|
||||
assert get_widget_kind("unknown", "metric") is None
|
||||
|
||||
|
||||
def test_service_config_schema_is_json_schema():
|
||||
schema = get_service_definition("grafana").config_schema
|
||||
schema = get_service_definition("prometheus").config_schema
|
||||
assert schema["type"] == "object"
|
||||
assert "base_url" in schema["properties"]
|
||||
|
||||
@@ -172,7 +170,6 @@ def test_list_service_types(client):
|
||||
"alertmanager",
|
||||
"authentik",
|
||||
"backups",
|
||||
"grafana",
|
||||
"jellyfin",
|
||||
"nextcloud",
|
||||
"prometheus",
|
||||
@@ -182,9 +179,9 @@ def test_list_service_types(client):
|
||||
|
||||
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
response = client.get("/api/services/types")
|
||||
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link", "chart"]
|
||||
prom = next(item for item in response.json() if item["service_type"] == "prometheus")
|
||||
assert [sf["key"] for sf in prom["secret_fields"]] == ["api_key"]
|
||||
assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -192,11 +189,11 @@ def test_service_type_includes_secret_and_widget_metadata(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _grafana_payload(**overrides):
|
||||
def _prometheus_payload(**overrides):
|
||||
payload = {
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "secret-token"},
|
||||
"enabled": True,
|
||||
}
|
||||
@@ -205,11 +202,11 @@ def _grafana_payload(**overrides):
|
||||
|
||||
|
||||
def test_create_and_list_service(client):
|
||||
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||
response = client.post("/api/services/instances", json=_prometheus_payload())
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_type"] == "grafana"
|
||||
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||
assert created["service_type"] == "prometheus"
|
||||
assert created["config"]["base_url"] == "https://prometheus.example.com"
|
||||
# Plaintext secrets are never returned.
|
||||
assert "secrets" not in created
|
||||
assert created["secrets_set"] == {"api_key": True}
|
||||
@@ -220,44 +217,44 @@ def test_create_and_list_service(client):
|
||||
|
||||
|
||||
def test_list_instances_filters_by_type(client):
|
||||
client.post("/api/services/instances", json=_grafana_payload())
|
||||
client.post("/api/services/instances", json=_prometheus_payload())
|
||||
client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "prometheus",
|
||||
"name": "Prom",
|
||||
"config": {"base_url": "http://prometheus:9090"},
|
||||
"service_type": "alertmanager",
|
||||
"name": "AM",
|
||||
"config": {"base_url": "http://am:9093"},
|
||||
},
|
||||
)
|
||||
response = client.get("/api/services/instances?service_type=grafana")
|
||||
response = client.get("/api/services/instances?service_type=prometheus")
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()) == 1
|
||||
assert response.json()[0]["service_type"] == "grafana"
|
||||
assert response.json()[0]["service_type"] == "prometheus"
|
||||
|
||||
|
||||
def test_update_service_preserves_unsent_secrets(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
# Update without sending secrets; the existing key should remain set.
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Renamed Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||
"service_type": "prometheus",
|
||||
"name": "Renamed Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com", "timeout_seconds": 10},
|
||||
},
|
||||
).json()
|
||||
assert updated["name"] == "Renamed Grafana"
|
||||
assert updated["name"] == "Renamed Prometheus"
|
||||
assert updated["secrets_set"] == {"api_key": True}
|
||||
|
||||
|
||||
def test_update_service_can_clear_secret(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
updated = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"name": "Production Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Production Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": ""},
|
||||
},
|
||||
).json()
|
||||
@@ -275,30 +272,28 @@ def test_unknown_service_type_rejected(client):
|
||||
def test_invalid_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"base_url": ""}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
# Force a real validation error via bad type.
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
json={"service_type": "prometheus", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
|
||||
"bad_url", ["prometheus.example.com", "localhost:3000", "//bad.example.com", "ftp://bad.example.com"]
|
||||
)
|
||||
def test_service_base_url_requires_http_schema(bad_url):
|
||||
"""Every service base_url must include an http:// or https:// schema."""
|
||||
model = get_service_definition("grafana").config_model
|
||||
model = get_service_definition("prometheus").config_model
|
||||
with pytest.raises(ValidationError):
|
||||
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
|
||||
)
|
||||
@pytest.mark.parametrize("service_type", ["prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"])
|
||||
def test_service_base_url_accepts_absolute_urls(service_type):
|
||||
model = get_service_definition(service_type).config_model
|
||||
instance = model.model_validate({"base_url": "https://example.com"})
|
||||
@@ -309,9 +304,9 @@ def test_unknown_secret_field_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"password": "leak"},
|
||||
},
|
||||
)
|
||||
@@ -322,9 +317,9 @@ def test_credential_key_in_config_rejected(client):
|
||||
response = client.post(
|
||||
"/api/services/instances",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": "x",
|
||||
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||
"config": {"base_url": "https://prometheus.example.com", "api_key": "leak"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -333,22 +328,22 @@ def test_credential_key_in_config_rejected(client):
|
||||
def test_update_nonexistent_returns_404(client):
|
||||
response = client.put(
|
||||
"/api/services/instances/missing",
|
||||
json=_grafana_payload(id="missing"),
|
||||
json=_prometheus_payload(id="missing"),
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_id_mismatch_returns_400(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.put(
|
||||
f"/api/services/instances/{created['id']}",
|
||||
json=_grafana_payload(id="other-id"),
|
||||
json=_prometheus_payload(id="other-id"),
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_delete_service(client):
|
||||
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||
created = client.post("/api/services/instances", json=_prometheus_payload()).json()
|
||||
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||
assert response.status_code == 200
|
||||
assert client.get("/api/services/instances").json() == []
|
||||
@@ -372,7 +367,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
"""
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
service = store.upsert_service(
|
||||
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||
{"service_type": "prometheus", "name": "Prometheus", "config": {"base_url": "u"}, "enabled": True}
|
||||
)
|
||||
|
||||
# Ensure the service_id column exists and seed a referencing widget.
|
||||
@@ -386,7 +381,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||
enabled, sort_order, created_at, updated_at, service_id)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
("w1", "prometheus", "prometheus.metric", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||
)
|
||||
|
||||
store.delete_service(service["id"])
|
||||
|
||||
+50
-250
@@ -15,7 +15,6 @@ from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
AlertmanagerWidgetSource,
|
||||
BackupsWidgetSource,
|
||||
GrafanaWidgetSource,
|
||||
JellyfinWidgetSource,
|
||||
ServiceRecord,
|
||||
StaticWidgetSource,
|
||||
@@ -42,12 +41,12 @@ def client(tmp_path):
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||
config = {"base_url": "https://grafana.example.com"}
|
||||
def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
|
||||
config = {"base_url": "https://prometheus.example.com"}
|
||||
config.update(config_overrides)
|
||||
return client.post(
|
||||
"/api/services/instances",
|
||||
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
|
||||
json={"service_type": "prometheus", "name": name, "config": config, "enabled": True},
|
||||
).json()
|
||||
|
||||
|
||||
@@ -93,7 +92,7 @@ def test_create_backups_widget(client):
|
||||
|
||||
def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
|
||||
client.post(
|
||||
"/api/widgets/instances",
|
||||
@@ -103,9 +102,9 @@ def test_widget_filtering_by_service_id_and_scope(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "Dash",
|
||||
"config": {"dashboard_uid": "o"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
|
||||
@@ -151,29 +150,29 @@ def test_credential_key_in_config_rejected(client):
|
||||
|
||||
|
||||
def test_create_service_bound_widget(client):
|
||||
service = _make_grafana_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "link"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "metric",
|
||||
"title": "Metrics",
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "metric"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "nonexistent_kind",
|
||||
"title": "x",
|
||||
"config": {},
|
||||
},
|
||||
@@ -186,33 +185,23 @@ def test_service_bound_widget_service_not_found_rejected(client):
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": "missing",
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_service_bound_widget_invalid_config_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
|
||||
},
|
||||
)
|
||||
# Empty string passes Pydantic; force a real failure with a bad type.
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": 123},
|
||||
"config": {"promql": 123}, # bad type: promql must be a string
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -280,31 +269,15 @@ def test_fetch_backups_widget_data(client):
|
||||
assert "total_jobs" in response.json()["data"]
|
||||
|
||||
|
||||
def test_fetch_grafana_link_widget_data(client):
|
||||
service = _make_grafana_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||
},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||
|
||||
|
||||
def test_fetch_widget_service_not_found(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
# Deleting the service cascade-deletes its widgets, so the widget is gone.
|
||||
@@ -314,22 +287,22 @@ def test_fetch_widget_service_not_found(client):
|
||||
|
||||
|
||||
def test_fetch_widget_service_disabled(client):
|
||||
service = _make_grafana_service(client)
|
||||
service = _make_prometheus_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
"config": {"promql": "up"},
|
||||
},
|
||||
).json()
|
||||
client.put(
|
||||
f"/api/services/instances/{service['id']}",
|
||||
json={
|
||||
"service_type": "grafana",
|
||||
"service_type": "prometheus",
|
||||
"name": service["name"],
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"enabled": False,
|
||||
},
|
||||
)
|
||||
@@ -347,23 +320,6 @@ def test_fetch_widget_not_found(client):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_builds_url():
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
|
||||
assert result["url"] == "http://g:3000/d/ov"
|
||||
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
|
||||
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_missing_service():
|
||||
adapter = GrafanaWidgetSource()
|
||||
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_alertmanager_adapter_summarizes_alerts():
|
||||
adapter = AlertmanagerWidgetSource()
|
||||
@@ -498,7 +454,7 @@ async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# New widget kind tests (jellyfin now_playing + grafana panel)
|
||||
# New widget kind tests (jellyfin now_playing)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -511,162 +467,6 @@ def test_jellyfin_definition_has_now_playing_widget():
|
||||
assert "activity" in kinds
|
||||
|
||||
|
||||
def test_grafana_definition_has_chart_widget():
|
||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||
|
||||
definition = get_service_definition("grafana")
|
||||
kinds = {wk.kind for wk in definition.widget_kinds}
|
||||
assert "chart" in kinds
|
||||
assert "link" in kinds
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_queries_datasource():
|
||||
"""Chart widget should POST to /api/ds/query and normalize the response."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000", "timeout_seconds": 5},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": {
|
||||
"A": {
|
||||
"frames": [
|
||||
{
|
||||
"data": {"values": [[1000, 2000], [0.5, 0.8]]},
|
||||
"schema": {"fields": [{"name": "Time"}, {"name": "cpu_usage"}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
|
||||
result = await adapter.fetch(
|
||||
service,
|
||||
"chart",
|
||||
{"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||
)
|
||||
|
||||
assert "series" in result
|
||||
assert len(result["series"]) == 1
|
||||
assert result["series"][0]["label"] == "cpu_usage"
|
||||
assert result["series"][0]["points"] == [
|
||||
{"t": 1000, "v": 0.5},
|
||||
{"t": 2000, "v": 0.8},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_extracts_prometheus_labels():
|
||||
"""Multiple Prometheus series should get unique labels from frame metadata."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000", "timeout_seconds": 5},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"results": {
|
||||
"A": {
|
||||
"frames": [
|
||||
{
|
||||
"data": {"values": [[1000], [0.5]]},
|
||||
"schema": {
|
||||
"fields": [
|
||||
{"name": "Time"},
|
||||
{
|
||||
"name": "Value",
|
||||
"labels": {
|
||||
"instance": "server1:9100",
|
||||
"mode": "iowait",
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
{
|
||||
"data": {"values": [[1000], [0.3]]},
|
||||
"schema": {
|
||||
"fields": [
|
||||
{"name": "Time"},
|
||||
{
|
||||
"name": "Value",
|
||||
"labels": {
|
||||
"instance": "server2:9100",
|
||||
"mode": "iowait",
|
||||
},
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.post", return_value=mock_resp):
|
||||
result = await adapter.fetch(service, "chart", {"query": "rate(cpu[5m])"})
|
||||
|
||||
assert len(result["series"]) == 2
|
||||
assert result["series"][0]["label"] == "instance=server1:9100 mode=iowait"
|
||||
assert result["series"][1]["label"] == "instance=server2:9100 mode=iowait"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_requires_api_key():
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000"},
|
||||
)
|
||||
result = await adapter.fetch(service, "chart", {"query": "up"})
|
||||
assert "error" in result
|
||||
assert "api_key" in result["error"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter_chart_handles_http_failure():
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests as req_mod
|
||||
|
||||
adapter = GrafanaWidgetSource()
|
||||
service = ServiceRecord(
|
||||
id="s",
|
||||
service_type="grafana",
|
||||
name="g",
|
||||
config={"base_url": "http://g:3000", "timeout_seconds": 2},
|
||||
secrets={"api_key": "tok"},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"media_library_viewer_api.widgets.sources.requests.post",
|
||||
side_effect=req_mod.ConnectionError("refused"),
|
||||
):
|
||||
result = await adapter.fetch(service, "chart", {"query": "up"})
|
||||
|
||||
assert "error" in result
|
||||
assert "failed" in result["error"].lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prometheus_chart_adapter_runs_range_query():
|
||||
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
|
||||
@@ -827,23 +627,23 @@ def test_widget_reference_lifecycle(widget_ref_client):
|
||||
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
|
||||
client, store = widget_ref_client
|
||||
|
||||
# Create a service-bound widget (simulating one on a Grafana Overview).
|
||||
# Create a service-bound widget (simulating one on a Prometheus service).
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "grafana",
|
||||
"name": "Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("grafana")[0]
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "CPU IOWait",
|
||||
"config": {"query": "rate(cpu[5m])", "datasource_uid": "prometheus"},
|
||||
"config": {"promql": "rate(cpu[5m])", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
@@ -888,20 +688,20 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
|
||||
store.upsert_service(
|
||||
{
|
||||
"service_type": "grafana",
|
||||
"name": "Grafana",
|
||||
"config": {"base_url": "https://grafana.example.com"},
|
||||
"service_type": "prometheus",
|
||||
"name": "Prometheus",
|
||||
"config": {"base_url": "https://prometheus.example.com"},
|
||||
"secrets": {"api_key": "tok"},
|
||||
"enabled": True,
|
||||
},
|
||||
)
|
||||
service = store.list_services("grafana")[0]
|
||||
service = store.list_services("prometheus")[0]
|
||||
widget = store.upsert_widget(
|
||||
{
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "chart",
|
||||
"title": "Memory",
|
||||
"config": {"query": "mem", "datasource_uid": "prometheus"},
|
||||
"config": {"promql": "mem", "window": "1h"},
|
||||
"enabled": True,
|
||||
"sort_order": 0,
|
||||
}
|
||||
@@ -924,7 +724,7 @@ def test_widget_reference_detach(widget_ref_client):
|
||||
assert cloned["title"] == "Memory"
|
||||
assert cloned["widget_kind"] == "chart"
|
||||
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
|
||||
assert cloned["config"]["query"] == "mem"
|
||||
assert cloned["config"]["promql"] == "mem"
|
||||
assert cloned["id"] != widget["id"] # new independent widget
|
||||
|
||||
# Reference is gone.
|
||||
|
||||
Reference in New Issue
Block a user