Files
manage/backend/tests/test_widgets.py
T
Developer 94bf830955 Fix invisible chart lines + extract Prometheus labels for multi-series
Two fixes for the Grafana chart widget:

1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the
   CSS variable is named '--color-chart-1' and already contains a hex
   color (#4f8cff). The hsl() wrapper produced invalid CSS, making
   every stroke invisible. Fixed to var(--color-chart-1).

2. Multiple series collision: the backend labeled all Prometheus series
   with the value field name (often just 'Value'), so multiple time
   series collided on the same recharts dataKey and overwrote each
   other. Now extracts meaningful labels from the Grafana frame metadata:
   prefers displayName, then Prometheus metric labels (e.g.
   'instance=server1:9100 mode=iowait'), then falls back to the field
   name. Duplicate labels get a numeric suffix for uniqueness.

Multi-series queries now render correctly: each Prometheus time series
gets its own colored line with a unique label in the legend/tooltip.

280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/
eslint clean.
2026-07-06 10:59:16 +00:00

727 lines
24 KiB
Python

"""Tests for the dashboard widget system: service-bound + built-in widgets."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import (
AlertmanagerWidgetSource,
BackupsWidgetSource,
GrafanaWidgetSource,
JellyfinWidgetSource,
ServiceRecord,
StaticWidgetSource,
)
TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
yield
@pytest.fixture
def client(tmp_path):
"""FastAPI test client with a fresh settings store and auth disabled."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: store
auth_settings = SimpleNamespace(auth_enabled=False)
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
yield TestClient(app)
app.dependency_overrides.clear()
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
config = {"base_url": "https://grafana.example.com"}
config.update(config_overrides)
return client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
).json()
# ---------------------------------------------------------------------------
# Built-in kinds + built-in widget CRUD
# ---------------------------------------------------------------------------
def test_list_builtin_kinds(client):
response = client.get("/api/widgets/builtin")
assert response.status_code == 200
kinds = {item["kind"] for item in response.json()}
assert kinds == {"backups", "static"}
def test_create_and_read_static_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"widget_kind": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
assert response.status_code == 201
created = response.json()
assert created["widget_kind"] == "static"
assert created["service_id"] is None
assert created["config"]["text"] == "hello"
listed = client.get("/api/widgets/instances").json()
assert len(listed) == 1
assert listed[0]["id"] == created["id"]
def test_create_backups_widget(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
)
assert response.status_code == 201
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)
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
)
client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dash",
"config": {"dashboard_uid": "o"},
},
)
# No filter: both widgets.
all_widgets = client.get("/api/widgets/instances").json()
assert len(all_widgets) == 2
# Filter by service_id: only the service-scoped one.
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
assert len(by_service) == 1
assert by_service[0]["service_id"] == service["id"]
# scope=dashboard: only the built-in (NULL service_id).
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
assert len(dashboard_scope) == 1
assert dashboard_scope[0]["service_id"] is None
# scope=service: only the non-null service_id widget.
service_scope = client.get("/api/widgets/instances?scope=service").json()
assert len(service_scope) == 1
assert service_scope[0]["service_id"] == service["id"]
def test_unknown_builtin_kind_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "bogus", "title": "x", "config": {}},
)
assert response.status_code == 422
def test_credential_key_in_config_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Service-bound widget CRUD
# ---------------------------------------------------------------------------
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)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {},
},
)
assert response.status_code == 422
def test_service_bound_widget_service_not_found_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"service_id": "missing",
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
)
assert response.status_code == 422
def test_service_bound_widget_invalid_config_rejected(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"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},
},
)
assert response.status_code == 422
def test_update_and_delete_widget(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
).json()
updated = client.put(
f"/api/widgets/instances/{created['id']}",
json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
).json()
assert updated["title"] == "Note2"
assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
assert client.get("/api/widgets/instances").json() == []
def test_update_nonexistent_returns_404(client):
response = client.put(
"/api/widgets/instances/missing",
json={"widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 404
def test_update_id_mismatch_returns_400(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {}},
).json()
response = client.put(
f"/api/widgets/instances/{created['id']}",
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 400
# ---------------------------------------------------------------------------
# Data endpoint
# ---------------------------------------------------------------------------
def test_fetch_static_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
body = response.json()
assert body["data"]["text"] == "hello"
assert body["error"] is None
def test_fetch_backups_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
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)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
# Deleting the service cascade-deletes its widgets, so the widget is gone.
client.delete(f"/api/services/instances/{service['id']}")
assert client.get("/api/widgets/instances").json() == []
assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404
def test_fetch_widget_service_disabled(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
client.put(
f"/api/services/instances/{service['id']}",
json={
"service_type": "grafana",
"name": service["name"],
"config": {"base_url": "https://grafana.example.com"},
"enabled": False,
},
)
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
assert "disabled" in response.json()["error"]
def test_fetch_widget_not_found(client):
assert client.get("/api/widgets/instances/missing/data").status_code == 404
# ---------------------------------------------------------------------------
# Adapter unit tests
# ---------------------------------------------------------------------------
@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()
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"status": "success",
"data": [
{
"labels": {"alertname": "DiskFull", "severity": "critical"},
"annotations": {"summary": "disk full"},
"startsAt": "2026-06-23T00:00:00Z",
"status": "firing",
}
],
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "active_alerts", {})
assert result["total"] == 1
assert result["by_severity"]["critical"] == 1
assert result["alerts"][0]["name"] == "DiskFull"
@pytest.mark.asyncio
async def test_alertmanager_adapter_applies_severity_filter():
adapter = AlertmanagerWidgetSource()
service = ServiceRecord(id="s", service_type="alertmanager", name="am", config={"base_url": "http://am:9093"})
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"status": "success",
"data": [
{"labels": {"alertname": "A", "severity": "critical"}, "annotations": {}, "status": "firing"},
{"labels": {"alertname": "B", "severity": "warning"}, "annotations": {}, "status": "firing"},
],
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "active_alerts", {"severity_filter": "critical"})
assert result["total"] == 1
assert result["alerts"][0]["name"] == "A"
@pytest.mark.asyncio
async def test_alertmanager_adapter_sends_bearer_token():
adapter = AlertmanagerWidgetSource()
service = ServiceRecord(
id="s",
service_type="alertmanager",
name="am",
config={"base_url": "http://am:9093"},
secrets={"api_key": "tok"},
)
payload = SimpleNamespace(raise_for_status=lambda: None, json=lambda: {"status": "success", "data": []})
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
result = await adapter.fetch(service, "active_alerts", {})
assert result["total"] == 0
_, kwargs = mock_get.call_args
assert kwargs["headers"]["Authorization"] == "Bearer tok"
@pytest.mark.asyncio
async def test_alertmanager_adapter_missing_service():
adapter = AlertmanagerWidgetSource()
result = await adapter.fetch(None, "active_alerts", {})
assert "error" in result
@pytest.mark.asyncio
async def test_static_adapter():
adapter = StaticWidgetSource()
result = await adapter.fetch(None, "static", {"text": "hi"})
assert result == {"text": "hi"}
@pytest.mark.asyncio
async def test_backups_adapter(client):
store = app.dependency_overrides[get_settings_store]()
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
adapter = BackupsWidgetSource()
result = await adapter.fetch(None, "backups", {})
assert "total_jobs" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_missing_service():
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
assert "error" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_records_history_on_run(client):
store = app.dependency_overrides[get_settings_store]()
# Save a task and an ssh_tasks service instance.
task = store.upsert_task(
{
"name": "echo",
"task_type": "shell",
"content": "echo hi",
"enabled": True,
"default_service_id": "",
}
)
service = store.upsert_service(
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
)
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
service_record = ServiceRecord(
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
)
with (
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
patch("media_library_viewer_api.services.task_runner.build_ssh_client", return_value=fake_client),
):
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
assert result["exit_status"] == 0
runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1
assert runs[0]["status"] == "success"
# ---------------------------------------------------------------------------
# New widget kind tests (jellyfin now_playing + grafana panel)
# ---------------------------------------------------------------------------
def test_jellyfin_definition_has_now_playing_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("jellyfin")
kinds = {wk.kind for wk in definition.widget_kinds}
assert "now_playing" in kinds
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_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
playing_session = {
"UserName": "alice",
"NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
"PlayState": {"IsPaused": False},
"DeviceName": "Web",
}
paused_session = {
"UserName": "bob",
"NowPlayingItem": {"Name": "Show", "Type": "Episode"},
"PlayState": {"IsPaused": True},
"DeviceName": "TV",
}
idle_session = {
"UserName": "carol",
"PlayState": {"IsPaused": False},
"DeviceName": "Phone",
}
mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "now_playing", {})
sessions = result["sessions"]
assert len(sessions) == 1
assert sessions[0]["user"] == "alice"
assert sessions[0]["state"] == "playing"
@pytest.mark.asyncio
async def test_jellyfin_activity_shows_all_sessions():
"""activity (default) should include idle and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
mock_client = SimpleNamespace(
sessions=lambda: [
{"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
{"UserName": "bob", "PlayState": {"IsPaused": False}},
]
)
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "activity", {})
assert len(result["sessions"]) == 2