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:
@@ -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