Merge pull request 'feat(widgets): add backend source adapters and per-widget data endpoint' (#3) from feat/dashboard-widgets-backend-adapters into main
This commit is contained in:
@@ -23,6 +23,8 @@ PROMETHEUS_ENABLED=true
|
|||||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||||
ALERTMANAGER_URL=http://alertmanager:9093
|
ALERTMANAGER_URL=http://alertmanager:9093
|
||||||
ALERTMANAGER_WEBHOOK_URL=
|
ALERTMANAGER_WEBHOOK_URL=
|
||||||
|
GRAFANA_URL=http://grafana:3000
|
||||||
|
PROMETHEUS_URL=http://prometheus:9090
|
||||||
BACKEND_CACHE_DIR=./backend-cache
|
BACKEND_CACHE_DIR=./backend-cache
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
|
|||||||
@@ -57,6 +57,8 @@ class Settings(BaseSettings):
|
|||||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
||||||
alertmanager_url: str = "http://alertmanager:9093"
|
alertmanager_url: str = "http://alertmanager:9093"
|
||||||
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
|
||||||
|
grafana_url: str = "http://grafana:3000"
|
||||||
|
prometheus_url: str = "http://prometheus:9090"
|
||||||
|
|
||||||
# Remote paths
|
# Remote paths
|
||||||
remote_media_root: str = ""
|
remote_media_root: str = ""
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Dashboard domain helpers shared between routers and widget adapters."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
|
||||||
|
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
for session in sessions:
|
||||||
|
item = session.get("NowPlayingItem") or {}
|
||||||
|
play_state = session.get("PlayState") or {}
|
||||||
|
transcoding = session.get("TranscodingInfo") or {}
|
||||||
|
|
||||||
|
has_item = bool(item)
|
||||||
|
series = item.get("SeriesName") or ""
|
||||||
|
title = (
|
||||||
|
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown"))
|
||||||
|
if has_item
|
||||||
|
else "(idle)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not has_item:
|
||||||
|
state_label = "idle"
|
||||||
|
else:
|
||||||
|
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||||
|
|
||||||
|
is_transcoding = bool(transcoding)
|
||||||
|
transcode_type: list[str] = []
|
||||||
|
if is_transcoding:
|
||||||
|
if transcoding.get("IsVideoDirect") is False:
|
||||||
|
transcode_type.append("video")
|
||||||
|
if transcoding.get("IsAudioDirect") is False:
|
||||||
|
transcode_type.append("audio")
|
||||||
|
if not transcode_type:
|
||||||
|
transcode_type.append("active")
|
||||||
|
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"user": session.get("UserName") or "Unknown",
|
||||||
|
"title": title,
|
||||||
|
"type": item.get("Type", "") if has_item else "",
|
||||||
|
"state": state_label,
|
||||||
|
"transcoding": "yes" if is_transcoding else "no",
|
||||||
|
"transcoding_type": ", ".join(transcode_type),
|
||||||
|
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||||
|
"session_id": session.get("Id") or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary:
|
||||||
|
"""Compute the backup summary shown on the dashboard."""
|
||||||
|
jobs = store.list_backup_jobs()
|
||||||
|
total_jobs = len(jobs)
|
||||||
|
|
||||||
|
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||||
|
recent_runs = []
|
||||||
|
for job in jobs:
|
||||||
|
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||||
|
if runs and runs[0]["started_at"] >= cutoff:
|
||||||
|
recent_runs.append(runs[0])
|
||||||
|
|
||||||
|
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||||
|
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||||
|
|
||||||
|
alerts = store.list_backup_alerts(acknowledged=False)
|
||||||
|
active_alerts = len(alerts)
|
||||||
|
|
||||||
|
failed_runs = []
|
||||||
|
for job in jobs:
|
||||||
|
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||||
|
if runs:
|
||||||
|
failed_runs.append(runs[0])
|
||||||
|
|
||||||
|
last_failed_at = None
|
||||||
|
if failed_runs:
|
||||||
|
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||||
|
|
||||||
|
return BackupDashboardSummary(
|
||||||
|
total_jobs=total_jobs,
|
||||||
|
success_rate_24h=round(success_rate, 1),
|
||||||
|
active_alerts=active_alerts,
|
||||||
|
last_failed_at=last_failed_at,
|
||||||
|
)
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
|
|||||||
get_settings_store,
|
get_settings_store,
|
||||||
get_user_id,
|
get_user_id,
|
||||||
)
|
)
|
||||||
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
|
_map_sessions_to_activity_rows,
|
||||||
|
build_backup_dashboard_summary,
|
||||||
|
)
|
||||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
@@ -85,50 +88,6 @@ def delete_shortcut(
|
|||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
||||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
|
||||||
results: list[dict[str, Any]] = []
|
|
||||||
for session in sessions:
|
|
||||||
item = session.get("NowPlayingItem") or {}
|
|
||||||
play_state = session.get("PlayState") or {}
|
|
||||||
transcoding = session.get("TranscodingInfo") or {}
|
|
||||||
|
|
||||||
has_item = bool(item)
|
|
||||||
series = item.get("SeriesName") or ""
|
|
||||||
title = (
|
|
||||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not has_item:
|
|
||||||
state_label = "idle"
|
|
||||||
else:
|
|
||||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
|
||||||
|
|
||||||
is_transcoding = bool(transcoding)
|
|
||||||
transcode_type: list[str] = []
|
|
||||||
if is_transcoding:
|
|
||||||
if transcoding.get("IsVideoDirect") is False:
|
|
||||||
transcode_type.append("video")
|
|
||||||
if transcoding.get("IsAudioDirect") is False:
|
|
||||||
transcode_type.append("audio")
|
|
||||||
if not transcode_type:
|
|
||||||
transcode_type.append("active")
|
|
||||||
|
|
||||||
results.append(
|
|
||||||
{
|
|
||||||
"user": session.get("UserName") or "Unknown",
|
|
||||||
"title": title,
|
|
||||||
"type": item.get("Type", "") if has_item else "",
|
|
||||||
"state": state_label,
|
|
||||||
"transcoding": "yes" if is_transcoding else "no",
|
|
||||||
"transcoding_type": ", ".join(transcode_type),
|
|
||||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
|
||||||
"session_id": session.get("Id") or "",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return results
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/activity")
|
@router.get("/activity")
|
||||||
def get_activity(
|
def get_activity(
|
||||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||||
@@ -154,38 +113,4 @@ def get_now_playing(
|
|||||||
def get_backup_dashboard(
|
def get_backup_dashboard(
|
||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> BackupDashboardSummary:
|
) -> BackupDashboardSummary:
|
||||||
jobs = store.list_backup_jobs()
|
return build_backup_dashboard_summary(store)
|
||||||
total_jobs = len(jobs)
|
|
||||||
|
|
||||||
# Calculate 24h success rate
|
|
||||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
|
||||||
recent_runs = []
|
|
||||||
for job in jobs:
|
|
||||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
|
||||||
if runs and runs[0]["started_at"] >= cutoff:
|
|
||||||
recent_runs.append(runs[0])
|
|
||||||
|
|
||||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
|
||||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
|
||||||
|
|
||||||
# Active alerts
|
|
||||||
alerts = store.list_backup_alerts(acknowledged=False)
|
|
||||||
active_alerts = len(alerts)
|
|
||||||
|
|
||||||
# Last failed
|
|
||||||
failed_runs = []
|
|
||||||
for job in jobs:
|
|
||||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
|
||||||
if runs:
|
|
||||||
failed_runs.append(runs[0])
|
|
||||||
|
|
||||||
last_failed_at = None
|
|
||||||
if failed_runs:
|
|
||||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
|
||||||
|
|
||||||
return BackupDashboardSummary(
|
|
||||||
total_jobs=total_jobs,
|
|
||||||
success_rate_24h=round(success_rate, 1),
|
|
||||||
active_alerts=active_alerts,
|
|
||||||
last_failed_at=last_failed_at,
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
"""REST API for dashboard widget instances and registry metadata."""
|
"""REST API for dashboard widget instances and registry metadata."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
from media_library_viewer_api.dependencies import get_settings_store
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.models.widgets import WidgetInstance, WidgetInstanceInput, WidgetTypeInfo
|
from media_library_viewer_api.models.widgets import (
|
||||||
|
WidgetDataResponse,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetInstanceInput,
|
||||||
|
)
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
from media_library_viewer_api.widgets.registry import (
|
from media_library_viewer_api.widgets.registry import (
|
||||||
|
get_widget_info,
|
||||||
list_source_types,
|
list_source_types,
|
||||||
list_widget_types,
|
list_widget_types,
|
||||||
validate_config,
|
validate_config,
|
||||||
)
|
)
|
||||||
|
from media_library_viewer_api.widgets.sources import get_source_adapter
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
||||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||||
@@ -56,7 +66,7 @@ def list_sources() -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/types")
|
@router.get("/types")
|
||||||
def list_types() -> list[WidgetTypeInfo]:
|
def list_types() -> list[dict[str, Any]]:
|
||||||
"""Return metadata for all registered widget types."""
|
"""Return metadata for all registered widget types."""
|
||||||
return [info.model_dump() for info in list_widget_types()]
|
return [info.model_dump() for info in list_widget_types()]
|
||||||
|
|
||||||
@@ -111,3 +121,53 @@ def delete_instance(
|
|||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||||
store.delete_widget(widget_id)
|
store.delete_widget(widget_id)
|
||||||
return {"status": "deleted"}
|
return {"status": "deleted"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances/{widget_id}/data")
|
||||||
|
async def fetch_data(
|
||||||
|
widget_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Fetch widget data through the registered source adapter."""
|
||||||
|
widget = store.get_widget(widget_id)
|
||||||
|
if not widget:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||||
|
|
||||||
|
widget_type = widget["widget_type"]
|
||||||
|
info = get_widget_info(widget_type)
|
||||||
|
if info is None:
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
widget_type=widget_type,
|
||||||
|
data=None,
|
||||||
|
error=f"Unknown widget type: {widget_type}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
adapter = get_source_adapter(info.source_type)
|
||||||
|
if adapter is None:
|
||||||
|
# Defensive: registry should prevent this, but return a safe error.
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
widget_type=widget_type,
|
||||||
|
data=None,
|
||||||
|
error=f"No adapter registered for source type: {info.source_type}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = await adapter.fetch(widget["config"])
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
detail="Widget data fetch failed",
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
widget_type=widget_type,
|
||||||
|
data=data if "error" not in data else None,
|
||||||
|
error=data.get("error"),
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
"""Widget source adapters.
|
||||||
|
|
||||||
|
Each adapter implements a uniform async interface and translates widget
|
||||||
|
configuration into data for the dashboard. Adapters reuse existing clients,
|
||||||
|
machine registries, and environment settings; they never accept arbitrary
|
||||||
|
commands or store credentials.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import shlex
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from starlette.requests import Request
|
||||||
|
|
||||||
|
from media_library_viewer_api.config import get_settings
|
||||||
|
from media_library_viewer_api.dependencies import get_jellyfin_client
|
||||||
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
|
_map_sessions_to_activity_rows,
|
||||||
|
build_backup_dashboard_summary,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.routers.tasks import _client_for_machine, _resolve_machine_for_task
|
||||||
|
from media_library_viewer_api.services.settings_store import get_settings_store
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _request_with_machine_id(machine_id: str | None = None) -> Request:
|
||||||
|
"""Build a minimal Starlette Request carrying a machine_id query param."""
|
||||||
|
query = f"machine_id={machine_id}".encode() if machine_id else b""
|
||||||
|
return Request({"type": "http", "query_string": query})
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetSource(Protocol):
|
||||||
|
"""Protocol for widget source adapters."""
|
||||||
|
|
||||||
|
source_type: str
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinWidgetSource:
|
||||||
|
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||||
|
|
||||||
|
source_type = "jellyfin"
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
request = _request_with_machine_id(config.get("machine_id") or None)
|
||||||
|
client = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(get_jellyfin_client, request),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
sessions = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.sessions),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
rows = _map_sessions_to_activity_rows(sessions)
|
||||||
|
return {"sessions": rows}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("jellyfin adapter failed")
|
||||||
|
return {"error": f"Jellyfin data fetch failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class BackupsWidgetSource:
|
||||||
|
"""Compute the backup dashboard summary."""
|
||||||
|
|
||||||
|
source_type = "backups"
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
|
summary = build_backup_dashboard_summary(store)
|
||||||
|
return summary.model_dump()
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("backups adapter failed")
|
||||||
|
return {"error": f"Backup summary failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaWidgetSource:
|
||||||
|
"""Build a Grafana deep-link (no embedding)."""
|
||||||
|
|
||||||
|
source_type = "grafana"
|
||||||
|
timeout = 5
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
dashboard_uid = config.get("dashboard_uid")
|
||||||
|
if not dashboard_uid:
|
||||||
|
return {"error": "dashboard_uid is required"}
|
||||||
|
url = f"{settings.grafana_url.rstrip('/')}/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}"}
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusWidgetSource:
|
||||||
|
"""Run a PromQL instant query against Prometheus."""
|
||||||
|
|
||||||
|
source_type = "prometheus"
|
||||||
|
timeout = 10
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
settings = get_settings()
|
||||||
|
promql = config.get("promql")
|
||||||
|
if not promql:
|
||||||
|
return {"error": "promql is required"}
|
||||||
|
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
|
||||||
|
response = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(
|
||||||
|
requests.get,
|
||||||
|
url,
|
||||||
|
params={"query": promql},
|
||||||
|
timeout=self.timeout,
|
||||||
|
),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
payload = response.json()
|
||||||
|
return {"result": payload.get("data", {})}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.exception("prometheus adapter failed")
|
||||||
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("prometheus adapter failed")
|
||||||
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class SshTaskWidgetSource:
|
||||||
|
"""Run a saved task from the registry and return its output."""
|
||||||
|
|
||||||
|
source_type = "ssh_task"
|
||||||
|
timeout = 30
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
|
task_id = config.get("task_id")
|
||||||
|
if not task_id:
|
||||||
|
return {"error": "task_id is required"}
|
||||||
|
task = store.get_task(task_id)
|
||||||
|
if not task:
|
||||||
|
return {"error": f"Task {task_id} not found"}
|
||||||
|
if not task.get("enabled", True):
|
||||||
|
return {"error": "Task is disabled"}
|
||||||
|
|
||||||
|
machine = _resolve_machine_for_task(store, task, None)
|
||||||
|
if not machine:
|
||||||
|
return {"error": "No machine available for this task"}
|
||||||
|
|
||||||
|
client = _client_for_machine(store, machine)
|
||||||
|
task_type = str(task.get("task_type") or "shell").lower()
|
||||||
|
command = str(task.get("content") or "")
|
||||||
|
if task_type == "python":
|
||||||
|
command = f"python3 -c {shlex.quote(command)}"
|
||||||
|
elif task_type != "shell":
|
||||||
|
return {"error": f"Unknown task type: {task_type}"}
|
||||||
|
|
||||||
|
result = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.run, command, timeout=self.timeout),
|
||||||
|
timeout=self.timeout,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"exit_status": result.exit_status,
|
||||||
|
"stdout": result.stdout or "",
|
||||||
|
"stderr": result.stderr or "",
|
||||||
|
}
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
return {"error": "Widget data fetch timed out"}
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("ssh_task adapter failed")
|
||||||
|
return {"error": f"SSH task failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class StaticWidgetSource:
|
||||||
|
"""Return static text/markdown unchanged."""
|
||||||
|
|
||||||
|
source_type = "static"
|
||||||
|
|
||||||
|
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"text": config.get("text", "")}
|
||||||
|
|
||||||
|
|
||||||
|
SOURCE_REGISTRY: dict[str, WidgetSource] = {
|
||||||
|
"jellyfin": JellyfinWidgetSource(),
|
||||||
|
"backups": BackupsWidgetSource(),
|
||||||
|
"grafana": GrafanaWidgetSource(),
|
||||||
|
"prometheus": PrometheusWidgetSource(),
|
||||||
|
"ssh_task": SshTaskWidgetSource(),
|
||||||
|
"static": StaticWidgetSource(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_source_adapter(source_type: str) -> WidgetSource | None:
|
||||||
|
"""Return the adapter for a source type, or None if unknown."""
|
||||||
|
return SOURCE_REGISTRY.get(source_type)
|
||||||
@@ -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 types import SimpleNamespace
|
||||||
from unittest.mock import patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
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.dependencies import get_settings_store
|
||||||
from media_library_viewer_api.main import app
|
from media_library_viewer_api.main import app
|
||||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
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
|
@pytest.fixture
|
||||||
@@ -289,3 +296,180 @@ def test_enabled_round_trip(client):
|
|||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json()["enabled"] is True
|
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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
||||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||||
|
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||||
|
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ services:
|
|||||||
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
||||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||||
|
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||||
|
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
||||||
volumes:
|
volumes:
|
||||||
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
@@ -51,9 +51,50 @@ Focused widget test output: `12 passed`.
|
|||||||
- None significant for Slice 1. The implementation follows the design's backend CRUD layout.
|
- None significant for Slice 1. The implementation follows the design's backend CRUD layout.
|
||||||
- Used `HTTP_422_UNPROCESSABLE_CONTENT` instead of the deprecated `HTTP_422_UNPROCESSABLE_ENTITY`.
|
- Used `HTTP_422_UNPROCESSABLE_CONTENT` instead of the deprecated `HTTP_422_UNPROCESSABLE_ENTITY`.
|
||||||
|
|
||||||
|
## Completed tasks (Slice 2)
|
||||||
|
|
||||||
|
All Slice 2 tasks are marked `- [x]` in `tasks.md`:
|
||||||
|
|
||||||
|
- [x] 2.1 Add observability URL settings (`grafana_url`, `prometheus_url`)
|
||||||
|
- [x] 2.2 Create source adapters (`jellyfin`, `backups`, `grafana`, `prometheus`, `ssh_task`, `static`)
|
||||||
|
- [x] 2.3 Add per-widget data endpoint (`GET /api/widgets/instances/{id}/data`)
|
||||||
|
- [x] 2.4 Extract shared backup/Jellyfin dashboard helpers into `domain/dashboard.py`
|
||||||
|
- [x] 2.5 Add adapter + data endpoint tests
|
||||||
|
|
||||||
|
## Files changed (Slice 2)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `backend/src/media_library_viewer_api/widgets/sources.py` — `WidgetSource` protocol and six source adapters.
|
||||||
|
- `backend/src/media_library_viewer_api/domain/dashboard.py` — Shared dashboard helpers (`_map_sessions_to_activity_rows`, `build_backup_dashboard_summary`).
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `backend/src/media_library_viewer_api/config.py` — Added `grafana_url` and `prometheus_url` settings.
|
||||||
|
- `backend/src/media_library_viewer_api/routers/widgets.py` — Added `GET /api/widgets/instances/{id}/data`.
|
||||||
|
- `backend/src/media_library_viewer_api/routers/dashboard.py` — Delegated to shared `domain/dashboard.py` helpers.
|
||||||
|
- `backend/tests/test_widgets.py` — Added adapter and data endpoint tests.
|
||||||
|
- `docker-compose.yml`, `docker-compose.dev.yml`, `.env.example` — Wired `GRAFANA_URL` and `PROMETHEUS_URL` for the new adapters.
|
||||||
|
|
||||||
|
## Verification (Slice 2)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
.venv/bin/python -m ruff check . # All checks passed
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest # 200 passed, 2 warnings
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint # 2 pre-existing warnings, 0 errors
|
||||||
|
npm run build # Built successfully
|
||||||
|
```
|
||||||
|
|
||||||
|
Focused widget test output: `27 passed`.
|
||||||
|
|
||||||
|
## Deviations from design (Slice 2)
|
||||||
|
|
||||||
|
- Adapters currently call `get_settings_store()` internally for `backups`/`ssh_task` sources. The router-level endpoint uses FastAPI DI, but adapter unit tests patch `get_settings_store` to inject a test store. A future refactor can pass `store` and `settings` explicitly into `adapter.fetch()` for cleaner testability.
|
||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
- Slice 2: Backend source adapters + `GET /api/widgets/instances/{id}/data`
|
|
||||||
- Slice 3: Frontend types/API/hooks/registry/components
|
- Slice 3: Frontend types/API/hooks/registry/components
|
||||||
- Slice 4: Dashboard loop + configuration UI + addon pages
|
- Slice 4: Dashboard loop + configuration UI + addon pages
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user