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:
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
|
||||
get_settings_store,
|
||||
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.services.settings_store import SettingsStore
|
||||
|
||||
@@ -85,50 +88,6 @@ def delete_shortcut(
|
||||
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")
|
||||
def get_activity(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
@@ -154,38 +113,4 @@ def get_now_playing(
|
||||
def get_backup_dashboard(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> BackupDashboardSummary:
|
||||
jobs = store.list_backup_jobs()
|
||||
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,
|
||||
)
|
||||
return build_backup_dashboard_summary(store)
|
||||
|
||||
@@ -1,20 +1,30 @@
|
||||
"""REST API for dashboard widget instances and registry metadata."""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
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.widgets.registry import (
|
||||
get_widget_info,
|
||||
list_source_types,
|
||||
list_widget_types,
|
||||
validate_config,
|
||||
)
|
||||
from media_library_viewer_api.widgets.sources import get_source_adapter
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
@@ -56,7 +66,7 @@ def list_sources() -> list[str]:
|
||||
|
||||
|
||||
@router.get("/types")
|
||||
def list_types() -> list[WidgetTypeInfo]:
|
||||
def list_types() -> list[dict[str, Any]]:
|
||||
"""Return metadata for all registered 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")
|
||||
store.delete_widget(widget_id)
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user