1cd8e926de
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.
214 lines
7.3 KiB
Python
214 lines
7.3 KiB
Python
"""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)
|