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:
Developer
2026-07-08 22:33:44 +00:00
parent 65bae95e3c
commit 67ca0fc3bc
27 changed files with 134 additions and 937 deletions
@@ -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(),
+1 -43
View File
@@ -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")
+45 -50
View File
@@ -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
View File
@@ -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.