feat(widgets): add backend source adapters and per-widget data endpoint
PR 2 of 4 for configurable dashboard widgets.
- Add grafana_url and prometheus_url settings (config.py + compose/env).
- Create WidgetSource protocol and adapters for jellyfin, backups, grafana,
prometheus, ssh_task, and static sources.
- Add GET /api/widgets/instances/{id}/data endpoint.
- Extract shared dashboard helpers into domain/dashboard.py so widgets and
the dashboard router reuse the same logic.
- Add adapter and data-endpoint tests.
- Update apply-progress.md.
Verification: ruff clean; backend pytest 200 passed; frontend lint/build green.
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding."""
|
||||
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters."""
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
@@ -9,6 +10,12 @@ 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 (
|
||||
SOURCE_REGISTRY,
|
||||
GrafanaWidgetSource,
|
||||
SshTaskWidgetSource,
|
||||
StaticWidgetSource,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@@ -289,3 +296,180 @@ def test_enabled_round_trip(client):
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert response.json()["enabled"] is True
|
||||
|
||||
|
||||
def test_fetch_static_widget_data(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Note",
|
||||
"config": {"text": "hello world"},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["widget_id"] == widget_id
|
||||
assert data["widget_type"] == "static"
|
||||
assert data["data"] == {"text": "hello world"}
|
||||
assert data["error"] is None
|
||||
assert isinstance(data["fetched_at"], int)
|
||||
|
||||
|
||||
def test_fetch_grafana_widget_data(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "grafana",
|
||||
"widget_type": "grafana-link",
|
||||
"title": "Grafana",
|
||||
"config": {"dashboard_uid": "overview", "panel_id": 3},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["widget_type"] == "grafana-link"
|
||||
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
|
||||
|
||||
|
||||
def test_fetch_prometheus_widget_data(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "prometheus",
|
||||
"widget_type": "prometheus-metric",
|
||||
"title": "CPU",
|
||||
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}}
|
||||
with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get:
|
||||
mock_response = MagicMock()
|
||||
mock_response.raise_for_status.return_value = None
|
||||
mock_response.json.return_value = fake_payload
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["widget_type"] == "prometheus-metric"
|
||||
assert data["data"]["result"]["resultType"] == "scalar"
|
||||
|
||||
|
||||
def test_fetch_jellyfin_widget_data_error(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "jellyfin",
|
||||
"title": "Activity",
|
||||
"config": {"machine_id": ""},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["widget_type"] == "jellyfin"
|
||||
assert data["data"] is None
|
||||
assert data["error"] is not None
|
||||
assert "Jellyfin" in data["error"] or "machine" in data["error"].lower()
|
||||
|
||||
|
||||
def test_fetch_widget_data_not_found(client):
|
||||
response = client.get("/api/widgets/instances/does-not-exist/data")
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_fetch_widget_data_unhandled_exception_returns_500(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"addon_id": "core",
|
||||
"widget_type": "static",
|
||||
"title": "Note",
|
||||
"config": {"text": "x"},
|
||||
},
|
||||
)
|
||||
widget_id = response.json()["id"]
|
||||
|
||||
class _ExplodingAdapter:
|
||||
source_type = "static"
|
||||
|
||||
async def fetch(self, config):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()):
|
||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
||||
|
||||
assert response.status_code == 500
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_static_adapter():
|
||||
adapter = StaticWidgetSource()
|
||||
result = await adapter.fetch({"text": "hello"})
|
||||
assert result == {"text": "hello"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_grafana_adapter():
|
||||
adapter = GrafanaWidgetSource()
|
||||
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2})
|
||||
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2"
|
||||
|
||||
result = await adapter.fetch({"dashboard_uid": "overview"})
|
||||
assert result["url"] == "http://grafana:3000/d/overview"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_task_adapter_timeout(tmp_path):
|
||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||
store.ensure_defaults()
|
||||
|
||||
# Create a local machine and a simple shell task.
|
||||
machine = store.list_machines()[0]
|
||||
task = store.upsert_task(
|
||||
{
|
||||
"name": "slow-task",
|
||||
"task_type": "shell",
|
||||
"content": "echo hello",
|
||||
"enabled": True,
|
||||
"default_machine_id": machine["id"],
|
||||
}
|
||||
)
|
||||
|
||||
adapter = SshTaskWidgetSource()
|
||||
with patch(
|
||||
"media_library_viewer_api.widgets.sources.get_settings_store",
|
||||
return_value=store,
|
||||
), patch(
|
||||
"media_library_viewer_api.widgets.sources.asyncio.wait_for",
|
||||
side_effect=asyncio.TimeoutError,
|
||||
):
|
||||
result = await adapter.fetch({"task_id": task["id"]})
|
||||
|
||||
assert "error" in result
|
||||
assert "timed out" in result["error"].lower()
|
||||
|
||||
|
||||
def test_source_registry_closed():
|
||||
assert set(SOURCE_REGISTRY.keys()) == {
|
||||
"jellyfin",
|
||||
"backups",
|
||||
"grafana",
|
||||
"prometheus",
|
||||
"ssh_task",
|
||||
"static",
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user