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
+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.