Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 | |||
| 2557185fb7 | |||
| e1356b20f1 | |||
| e6d333ef7b | |||
| 1cd8e926de | |||
| 1a52dfb087 | |||
| 9dfe62eb6f |
@@ -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
|
||||||
|
|||||||
@@ -256,6 +256,54 @@ fully removed (web-ui-rework; see decision log 2026-06-17).
|
|||||||
- Job templates should remain centralized in `jobs.py` for future extension.
|
- Job templates should remain centralized in `jobs.py` for future extension.
|
||||||
- Remote job template values must be shell-quoted before execution.
|
- Remote job template values must be shell-quoted before execution.
|
||||||
|
|
||||||
|
## Configurable Dashboard Widgets
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
The dashboard is composed of persisted widget instances stored in the backend SQLite
|
||||||
|
settings database. Each widget has a type, title, configuration, enabled flag, and
|
||||||
|
sort order. The frontend renders enabled widgets in sort order and fetches data
|
||||||
|
independently through the backend source adapters.
|
||||||
|
|
||||||
|
### Widget types
|
||||||
|
|
||||||
|
- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
|
||||||
|
- **Backups** — backup job summary and active alerts.
|
||||||
|
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
|
||||||
|
- **Prometheus metric** — result of a PromQL instant query.
|
||||||
|
- **SSH task output** — output of a saved task run on a machine.
|
||||||
|
- **Static text** — plain text or markdown note.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Widget `config` may not contain credential keys such as `password`, `token`,
|
||||||
|
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
|
||||||
|
secrets (e.g., base64 blobs, `sk-` prefixes).
|
||||||
|
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
|
||||||
|
Grafana/Prometheus URLs; no secrets are stored in widget configuration.
|
||||||
|
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
|
||||||
|
are not accepted.
|
||||||
|
|
||||||
|
### Addon pages
|
||||||
|
|
||||||
|
Each non-core addon gets a dedicated page at `/addons/:addonId`:
|
||||||
|
|
||||||
|
- `/addons/grafana`
|
||||||
|
- `/addons/prometheus`
|
||||||
|
- `/addons/ssh-tasks`
|
||||||
|
|
||||||
|
Unknown addons render a "not installed" alert.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `GET /api/widgets/sources` — list source types.
|
||||||
|
- `GET /api/widgets/types` — list widget type metadata.
|
||||||
|
- `GET /api/widgets/instances` — list widget instances.
|
||||||
|
- `POST /api/widgets/instances` — create instance.
|
||||||
|
- `PUT /api/widgets/instances/{id}` — update instance.
|
||||||
|
- `DELETE /api/widgets/instances/{id}` — delete instance.
|
||||||
|
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
|
||||||
|
|
||||||
## Decision Log
|
## Decision Log
|
||||||
|
|
||||||
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
|
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { FileBrowser } from "./pages/FileBrowser";
|
|||||||
import { Actions } from "./pages/Actions";
|
import { Actions } from "./pages/Actions";
|
||||||
import BackupsPage from "./components/BackupsPage";
|
import BackupsPage from "./components/BackupsPage";
|
||||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||||
|
import { AddonPage } from "./pages/AddonPage";
|
||||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||||
import { fetchAppVersion } from "./api/client";
|
import { fetchAppVersion } from "./api/client";
|
||||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||||
@@ -449,6 +450,7 @@ function AppInner() {
|
|||||||
<Route path="/backups" element={<BackupsPage />} />
|
<Route path="/backups" element={<BackupsPage />} />
|
||||||
<Route path="/observability" element={<ObservabilityPage />} />
|
<Route path="/observability" element={<ObservabilityPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -480,6 +482,7 @@ function AppInner() {
|
|||||||
<Route path="/backups" element={<BackupsPage />} />
|
<Route path="/backups" element={<BackupsPage />} />
|
||||||
<Route path="/observability" element={<ObservabilityPage />} />
|
<Route path="/observability" element={<ObservabilityPage />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/addons/:addonId" element={<AddonPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function GrafanaAddonPage() {
|
||||||
|
const grafanaUrl =
|
||||||
|
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
|
||||||
|
"http://localhost:3000";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Grafana</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metrics & logs</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Open the full Grafana instance for dashboards, metrics, and log
|
||||||
|
exploration.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
href={grafanaUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
>
|
||||||
|
Open Grafana
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export function PrometheusAddonPage() {
|
||||||
|
const prometheusUrl =
|
||||||
|
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
|
||||||
|
"http://localhost:9090";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">Prometheus</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Metrics explorer</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
|
||||||
|
</p>
|
||||||
|
<Button asChild>
|
||||||
|
<a
|
||||||
|
href={prometheusUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center"
|
||||||
|
>
|
||||||
|
Open Prometheus
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { Terminal } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export function SshTasksAddonPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<h2 className="text-xl font-semibold">SSH tasks</h2>
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Saved actions</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Create, edit, and run saved shell or Python tasks against local or
|
||||||
|
remote machines.
|
||||||
|
</p>
|
||||||
|
<Button onClick={() => navigate("/actions")}>
|
||||||
|
<Terminal className="mr-2 h-4 w-4" />
|
||||||
|
Open Actions
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { GrafanaAddonPage } from "./GrafanaAddonPage";
|
||||||
|
export { PrometheusAddonPage } from "./PrometheusAddonPage";
|
||||||
|
export { SshTasksAddonPage } from "./SshTasksAddonPage";
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type {
|
||||||
|
WidgetDataResponse,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetInstanceInput,
|
||||||
|
WidgetTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
export async function fetchWidgetSources(): Promise<string[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/sources`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget sources");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/types`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget types");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget instances");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createWidgetInstance(
|
||||||
|
input: WidgetInstanceInput,
|
||||||
|
): Promise<WidgetInstance> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create widget instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWidgetInstance(
|
||||||
|
input: WidgetInstanceInput,
|
||||||
|
): Promise<WidgetInstance> {
|
||||||
|
if (!input.id) throw new Error("Widget ID is required for update");
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update widget instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteWidgetInstance(
|
||||||
|
widgetId: string,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete widget instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWidgetData(
|
||||||
|
widgetId: string,
|
||||||
|
): Promise<WidgetDataResponse> {
|
||||||
|
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch widget data");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,464 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useDeleteWidgetInstance,
|
||||||
|
useSaveWidgetInstance,
|
||||||
|
useWidgetInstances,
|
||||||
|
useWidgetTypes,
|
||||||
|
} from "../hooks/useWidgets";
|
||||||
|
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
|
||||||
|
import type {
|
||||||
|
MonitoringMachine,
|
||||||
|
SavedTask,
|
||||||
|
WidgetInstance,
|
||||||
|
WidgetInstanceInput,
|
||||||
|
} from "../types";
|
||||||
|
import {
|
||||||
|
getWidgetDefinition,
|
||||||
|
listWidgetTypes,
|
||||||
|
type WidgetDefinition,
|
||||||
|
} from "../widgets/registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(widgetType: string): WidgetInstanceInput {
|
||||||
|
const def = getWidgetDefinition(widgetType);
|
||||||
|
return {
|
||||||
|
addon_id: def?.addonId ?? "",
|
||||||
|
widget_type: widgetType,
|
||||||
|
title: def?.name ?? "",
|
||||||
|
config: { ...(def?.defaultConfig ?? {}) },
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
helper,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
helper?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<Label htmlFor={htmlFor}>{label}</Label>
|
||||||
|
{children}
|
||||||
|
{helper ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{helper}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetConfigFields({
|
||||||
|
definition,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
machines,
|
||||||
|
tasks,
|
||||||
|
}: {
|
||||||
|
definition: WidgetDefinition;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
|
machines: MonitoringMachine[];
|
||||||
|
tasks: SavedTask[];
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{definition.configFields.map((field) => {
|
||||||
|
const value = config[field.key] ?? "";
|
||||||
|
|
||||||
|
if (
|
||||||
|
definition.widgetType === "jellyfin" &&
|
||||||
|
field.key === "machine_id"
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={String(value)}
|
||||||
|
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={field.key}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Default</SelectItem>
|
||||||
|
{machines
|
||||||
|
.filter((m) => m.enabled && m.services.includes("jellyfin"))
|
||||||
|
.map((m) => (
|
||||||
|
<SelectItem key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (definition.widgetType === "ssh-task" && field.key === "task_id") {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={String(value)}
|
||||||
|
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id={field.key}>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tasks
|
||||||
|
.filter((t) => t.enabled)
|
||||||
|
.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (field.type === "number") {
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type="number"
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[field.key]:
|
||||||
|
e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={field.key}
|
||||||
|
helper={field.helper}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
value={String(value)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[field.key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||||
|
const { data: instances = [] } = useWidgetInstances();
|
||||||
|
const { data: types = [] } = useWidgetTypes();
|
||||||
|
const { data: machines = [] } = useMonitoringSettings();
|
||||||
|
const { data: tasks = [] } = useTasks();
|
||||||
|
const saveWidget = useSaveWidgetInstance();
|
||||||
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
|
||||||
|
|
||||||
|
const sortedInstances = useMemo(
|
||||||
|
() =>
|
||||||
|
[...instances].sort(
|
||||||
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
|
),
|
||||||
|
[instances],
|
||||||
|
);
|
||||||
|
|
||||||
|
function startAdd(widgetType: string) {
|
||||||
|
setDraft(emptyDraft(widgetType));
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(instance: WidgetInstance) {
|
||||||
|
setDraft({
|
||||||
|
id: instance.id,
|
||||||
|
addon_id: instance.addon_id,
|
||||||
|
widget_type: instance.widget_type,
|
||||||
|
title: instance.title,
|
||||||
|
config: instance.config,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
sort_order: instance.sort_order,
|
||||||
|
});
|
||||||
|
setEditingId(instance.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDraft(null);
|
||||||
|
setEditingId(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (!draft) return;
|
||||||
|
await saveWidget.mutateAsync(draft);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(instance: WidgetInstance) {
|
||||||
|
await saveWidget.mutateAsync({
|
||||||
|
...instance,
|
||||||
|
enabled: !instance.enabled,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveInstance(index: number, direction: -1 | 1) {
|
||||||
|
const targetIndex = index + direction;
|
||||||
|
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
|
||||||
|
const a = sortedInstances[index];
|
||||||
|
const b = sortedInstances[targetIndex];
|
||||||
|
await Promise.all([
|
||||||
|
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
|
||||||
|
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeInstance(instance: WidgetInstance) {
|
||||||
|
await deleteWidget.mutateAsync(instance.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose(next: boolean) {
|
||||||
|
if (!next) {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{draft
|
||||||
|
? editingId
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{draft && definition ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{definition.description}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||||
|
<Field label="Title" htmlFor="widget-title">
|
||||||
|
<Input
|
||||||
|
id="widget-title"
|
||||||
|
value={draft.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({ ...draft, title: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label="Sort order" htmlFor="widget-sort-order">
|
||||||
|
<Input
|
||||||
|
id="widget-sort-order"
|
||||||
|
type="number"
|
||||||
|
value={String(draft.sort_order)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sort_order:
|
||||||
|
e.target.value === "" ? 0 : Number(e.target.value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="widget-enabled"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="widget-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<WidgetConfigFields
|
||||||
|
definition={definition}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
machines={machines}
|
||||||
|
tasks={tasks}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="outline" onClick={reset}>
|
||||||
|
Back
|
||||||
|
</Button>
|
||||||
|
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
|
||||||
|
Save widget
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{sortedInstances.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
No widgets yet. Add one below.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{sortedInstances.map((instance, index) => {
|
||||||
|
const typeDef = getWidgetDefinition(instance.widget_type);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={instance.id}
|
||||||
|
className="flex items-center gap-2 rounded border p-2"
|
||||||
|
>
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{instance.title}</span>
|
||||||
|
<Badge variant="outline">
|
||||||
|
{typeDef?.name ?? instance.widget_type}
|
||||||
|
</Badge>
|
||||||
|
{!instance.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={index === 0}
|
||||||
|
onClick={() => moveInstance(index, -1)}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
disabled={index === sortedInstances.length - 1}
|
||||||
|
onClick={() => moveInstance(index, 1)}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={instance.enabled}
|
||||||
|
onCheckedChange={() => toggleEnabled(instance)}
|
||||||
|
aria-label={`Toggle ${instance.title}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => startEdit(instance)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => removeInstance(instance)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<p className="text-sm font-medium">Add widget</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{registryDefinitions.map((def) => (
|
||||||
|
<Button
|
||||||
|
key={def.widgetType}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAdd(def.widgetType)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{def.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{types.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Widget registry is empty. Backend may not be running.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { getWidgetDefinition } from "../widgets/registry";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetInstance({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
if (!def) {
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title}>
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Unknown widget type: {widget.widget_type}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = def.component;
|
||||||
|
return <Component widget={widget} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createWidgetInstance,
|
||||||
|
deleteWidgetInstance,
|
||||||
|
fetchWidgetData,
|
||||||
|
fetchWidgetInstances,
|
||||||
|
fetchWidgetSources,
|
||||||
|
fetchWidgetTypes,
|
||||||
|
updateWidgetInstance,
|
||||||
|
} from "../api/widgets";
|
||||||
|
import type { WidgetInstanceInput } from "../types";
|
||||||
|
|
||||||
|
export function useWidgetInstances() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "instances"],
|
||||||
|
queryFn: fetchWidgetInstances,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetData(widgetId: string, refreshInterval: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "data", widgetId],
|
||||||
|
queryFn: () => fetchWidgetData(widgetId),
|
||||||
|
refetchInterval: refreshInterval || false,
|
||||||
|
enabled: !!widgetId,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveWidgetInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: WidgetInstanceInput) =>
|
||||||
|
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteWidgetInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetSources() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "sources"],
|
||||||
|
queryFn: fetchWidgetSources,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWidgetTypes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["widgets", "types"],
|
||||||
|
queryFn: fetchWidgetTypes,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import {
|
||||||
|
GrafanaAddonPage,
|
||||||
|
PrometheusAddonPage,
|
||||||
|
SshTasksAddonPage,
|
||||||
|
} from "../addons";
|
||||||
|
|
||||||
|
const ADDON_PAGES: Record<string, React.ComponentType> = {
|
||||||
|
grafana: GrafanaAddonPage,
|
||||||
|
prometheus: PrometheusAddonPage,
|
||||||
|
"ssh-tasks": SshTasksAddonPage,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddonPage() {
|
||||||
|
const { addonId } = useParams<{ addonId: string }>();
|
||||||
|
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
|
||||||
|
|
||||||
|
if (!Page) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Page />;
|
||||||
|
}
|
||||||
@@ -21,18 +21,17 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
useActivity,
|
|
||||||
useDashboardShortcuts,
|
useDashboardShortcuts,
|
||||||
useDeleteDashboardShortcut,
|
useDeleteDashboardShortcut,
|
||||||
useSaveDashboardShortcut,
|
useSaveDashboardShortcut,
|
||||||
} from "../hooks/useDashboard";
|
} from "../hooks/useDashboard";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useWidgetInstances } from "../hooks/useWidgets";
|
||||||
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
|
||||||
import { NowPlaying } from "../components/NowPlaying";
|
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { ConfirmDialog } from "../components/ConfirmDialog";
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
import { DialogFooter } from "../components/DialogFooter";
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
import BackupDashboardWidget from "../components/BackupDashboardWidget";
|
import { WidgetInstance } from "../components/WidgetInstance";
|
||||||
|
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
|
||||||
|
|
||||||
function emptyShortcut(): DashboardShortcutInput {
|
function emptyShortcut(): DashboardShortcutInput {
|
||||||
return {
|
return {
|
||||||
@@ -327,19 +326,6 @@ function ShortcutCard({
|
|||||||
|
|
||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: machines = [] } = useMonitoringSettings();
|
|
||||||
const jellyfinMachines = useMemo(
|
|
||||||
() =>
|
|
||||||
machines.filter(
|
|
||||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
|
||||||
),
|
|
||||||
[machines],
|
|
||||||
);
|
|
||||||
const [activeJellyfinMachineId, setActiveJellyfinMachineId] =
|
|
||||||
useState<string>("");
|
|
||||||
const selectedJellyfinId =
|
|
||||||
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
|
|
||||||
const { data: activity } = useActivity(selectedJellyfinId || undefined);
|
|
||||||
const { data: shortcuts = [] } = useDashboardShortcuts();
|
const { data: shortcuts = [] } = useDashboardShortcuts();
|
||||||
const saveShortcut = useSaveDashboardShortcut();
|
const saveShortcut = useSaveDashboardShortcut();
|
||||||
const deleteShortcut = useDeleteDashboardShortcut();
|
const deleteShortcut = useDeleteDashboardShortcut();
|
||||||
@@ -348,6 +334,16 @@ export function Dashboard() {
|
|||||||
emptyShortcut(),
|
emptyShortcut(),
|
||||||
);
|
);
|
||||||
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
|
||||||
|
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
|
||||||
|
const { data: widgetInstances = [] } = useWidgetInstances();
|
||||||
|
|
||||||
|
const visibleWidgets = useMemo(
|
||||||
|
() =>
|
||||||
|
widgetInstances
|
||||||
|
.filter((w) => w.enabled)
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order),
|
||||||
|
[widgetInstances],
|
||||||
|
);
|
||||||
|
|
||||||
const openCreateShortcut = () => {
|
const openCreateShortcut = () => {
|
||||||
setShortcutDraft(emptyShortcut());
|
setShortcutDraft(emptyShortcut());
|
||||||
@@ -382,9 +378,14 @@ export function Dashboard() {
|
|||||||
title="Shortcuts"
|
title="Shortcuts"
|
||||||
description="Quick links to websites today, with room for action and user shortcuts later."
|
description="Quick links to websites today, with room for action and user shortcuts later."
|
||||||
action={
|
action={
|
||||||
<Button variant="outline" onClick={openCreateShortcut}>
|
<div className="flex gap-2">
|
||||||
Add shortcut
|
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
|
||||||
</Button>
|
Edit dashboard
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" onClick={openCreateShortcut}>
|
||||||
|
Add shortcut
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{shortcuts.length ? (
|
{shortcuts.length ? (
|
||||||
@@ -416,42 +417,9 @@ export function Dashboard() {
|
|||||||
)}
|
)}
|
||||||
</SectionCard>
|
</SectionCard>
|
||||||
|
|
||||||
<SectionCard
|
{visibleWidgets.map((widget) => (
|
||||||
title="Jellyfin activity"
|
<WidgetInstance key={widget.id} widget={widget} />
|
||||||
description="Live sessions and idle users from Jellyfin."
|
))}
|
||||||
action={
|
|
||||||
jellyfinMachines.length > 1 ? (
|
|
||||||
<Select
|
|
||||||
value={selectedJellyfinId}
|
|
||||||
onValueChange={(value) => setActiveJellyfinMachineId(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-8 w-[180px] text-xs">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{jellyfinMachines.map((m) => (
|
|
||||||
<SelectItem key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : jellyfinMachines.length === 1 ? (
|
|
||||||
<Badge variant="outline">{jellyfinMachines[0].name}</Badge>
|
|
||||||
) : null
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{activity ? (
|
|
||||||
<NowPlaying
|
|
||||||
sessions={activity}
|
|
||||||
onSelectSession={(session) =>
|
|
||||||
navigate(`/users?user=${encodeURIComponent(session.user)}`)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</SectionCard>
|
|
||||||
|
|
||||||
<BackupDashboardWidget />
|
|
||||||
|
|
||||||
<ShortcutDialog
|
<ShortcutDialog
|
||||||
open={shortcutDialogOpen}
|
open={shortcutDialogOpen}
|
||||||
@@ -473,6 +441,10 @@ export function Dashboard() {
|
|||||||
setDeleteShortcutId(null);
|
setDeleteShortcutId(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<WidgetConfigDialog
|
||||||
|
open={widgetDialogOpen}
|
||||||
|
onClose={() => setWidgetDialogOpen(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -443,3 +443,42 @@ export interface PrometheusTarget {
|
|||||||
labels: Record<string, string>;
|
labels: Record<string, string>;
|
||||||
targets: string[];
|
targets: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WidgetInstance {
|
||||||
|
id: string;
|
||||||
|
addon_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetInstanceInput {
|
||||||
|
id?: string | null;
|
||||||
|
addon_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetTypeInfo {
|
||||||
|
addon_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
source_type: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetDataResponse {
|
||||||
|
widget_id: string;
|
||||||
|
widget_type: string;
|
||||||
|
data: Record<string, unknown> | null;
|
||||||
|
error: string | null;
|
||||||
|
fetched_at: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { BackupDashboardSummary } from "../types/backups";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BackupsWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const summary = data?.data as BackupDashboardSummary | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
<Skeleton className="h-10 w-20" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : summary ? (
|
||||||
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">{summary.total_jobs}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Jobs</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
{summary.success_rate_24h}%
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">24h Success</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-semibold">
|
||||||
|
{summary.active_alerts > 0 ? (
|
||||||
|
<Badge variant="destructive">{summary.active_alerts}</Badge>
|
||||||
|
) : (
|
||||||
|
0
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">Alerts</div>
|
||||||
|
</div>
|
||||||
|
{summary.last_failed_at ? (
|
||||||
|
<div className="self-center text-xs text-destructive">
|
||||||
|
Last failed:{" "}
|
||||||
|
{new Date(summary.last_failed_at * 1000).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ExternalLink } from "lucide-react";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GrafanaLinkWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const url = data?.data?.url as string | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-10 w-48" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : url ? (
|
||||||
|
<Button asChild>
|
||||||
|
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||||
|
Open Grafana
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>No Grafana URL configured.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { NowPlayingSession, WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JellyfinWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
<Skeleton className="h-4 w-1/2" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : Array.isArray(sessions) ? (
|
||||||
|
<SessionActivityPanel
|
||||||
|
sessions={sessions}
|
||||||
|
emptyMessage="No recent user activity sessions right now."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
type PromQLResult = {
|
||||||
|
resultType?: string;
|
||||||
|
result?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PromQLVectorSample = {
|
||||||
|
metric?: Record<string, string>;
|
||||||
|
value?: [number, string];
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPrometheusValue(result: PromQLResult | undefined): string {
|
||||||
|
if (!result) return "No data";
|
||||||
|
if (result.resultType === "scalar" && Array.isArray(result.result)) {
|
||||||
|
return String(result.result[1] ?? "No data");
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
result.resultType === "vector" &&
|
||||||
|
Array.isArray(result.result) &&
|
||||||
|
result.result.length > 0
|
||||||
|
) {
|
||||||
|
const first = result.result[0] as PromQLVectorSample;
|
||||||
|
if (first.value) return String(first.value[1]);
|
||||||
|
}
|
||||||
|
return JSON.stringify(result, null, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PrometheusMetricWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const result = data?.data?.result as PromQLResult | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<Skeleton className="h-10 w-32" />
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<pre className="whitespace-pre-wrap text-sm">
|
||||||
|
{formatPrometheusValue(result)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SshTaskResult = {
|
||||||
|
exit_status: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SshTaskWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data, isLoading } = useWidgetData(
|
||||||
|
widget.id,
|
||||||
|
def?.refreshInterval ?? 0,
|
||||||
|
);
|
||||||
|
const result = data?.data as SshTaskResult | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{isLoading && !data ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Skeleton className="h-4 w-full" />
|
||||||
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
</div>
|
||||||
|
) : data?.error ? (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertDescription>{data.error}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : result ? (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Exit status:{" "}
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
result.exit_status === 0 ? "text-green-600" : "text-destructive"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{result.exit_status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{result.stdout ? (
|
||||||
|
<pre className="max-h-64 overflow-auto rounded bg-muted p-2 text-xs">
|
||||||
|
{result.stdout}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
{result.stderr ? (
|
||||||
|
<pre className="max-h-64 overflow-auto rounded bg-destructive/10 p-2 text-xs text-destructive">
|
||||||
|
{result.stderr}
|
||||||
|
</pre>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { getWidgetDefinition } from "./registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaticWidget({ widget }: Props) {
|
||||||
|
const def = getWidgetDefinition(widget.widget_type);
|
||||||
|
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
||||||
|
const text = data?.data?.text as string | undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title} description={def?.description}>
|
||||||
|
{text ? (
|
||||||
|
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">No content configured.</p>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
export { BackupsWidget } from "./BackupsWidget";
|
||||||
|
export { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||||
|
export { JellyfinWidget } from "./JellyfinWidget";
|
||||||
|
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
|
export { SshTaskWidget } from "./SshTaskWidget";
|
||||||
|
export { StaticWidget } from "./StaticWidget";
|
||||||
|
export {
|
||||||
|
getWidgetDefinition,
|
||||||
|
listWidgetTypes,
|
||||||
|
WIDGET_REGISTRY,
|
||||||
|
} from "./registry";
|
||||||
|
export type { WidgetConfigField, WidgetDefinition } from "./registry";
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getWidgetDefinition,
|
||||||
|
listWidgetTypes,
|
||||||
|
WIDGET_REGISTRY,
|
||||||
|
} from "./registry";
|
||||||
|
|
||||||
|
describe("widget registry", () => {
|
||||||
|
it("contains exactly six Phase 1 types", () => {
|
||||||
|
const types = listWidgetTypes();
|
||||||
|
expect(types).toHaveLength(6);
|
||||||
|
expect(types.map((t) => t.widgetType).sort()).toEqual([
|
||||||
|
"backups",
|
||||||
|
"grafana-link",
|
||||||
|
"jellyfin",
|
||||||
|
"prometheus-metric",
|
||||||
|
"ssh-task",
|
||||||
|
"static",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("has refresh intervals matching the spec", () => {
|
||||||
|
expect(getWidgetDefinition("jellyfin")?.refreshInterval).toBe(30_000);
|
||||||
|
expect(getWidgetDefinition("backups")?.refreshInterval).toBe(60_000);
|
||||||
|
expect(getWidgetDefinition("grafana-link")?.refreshInterval).toBe(0);
|
||||||
|
expect(getWidgetDefinition("prometheus-metric")?.refreshInterval).toBe(
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
expect(getWidgetDefinition("ssh-task")?.refreshInterval).toBe(0);
|
||||||
|
expect(getWidgetDefinition("static")?.refreshInterval).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defines required metadata for every widget", () => {
|
||||||
|
for (const def of Object.values(WIDGET_REGISTRY)) {
|
||||||
|
expect(def.widgetType).toBeTruthy();
|
||||||
|
expect(def.addonId).toBeTruthy();
|
||||||
|
expect(def.name).toBeTruthy();
|
||||||
|
expect(def.sourceType).toBeTruthy();
|
||||||
|
expect(def.component).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { BackupsWidget } from "./BackupsWidget";
|
||||||
|
import { GrafanaLinkWidget } from "./GrafanaLinkWidget";
|
||||||
|
import { JellyfinWidget } from "./JellyfinWidget";
|
||||||
|
import { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
|
import { SshTaskWidget } from "./SshTaskWidget";
|
||||||
|
import { StaticWidget } from "./StaticWidget";
|
||||||
|
|
||||||
|
export interface WidgetConfigField {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
type: "string" | "select" | "boolean" | "number";
|
||||||
|
options?: { label: string; value: string }[];
|
||||||
|
helper?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetDefinition {
|
||||||
|
widgetType: string;
|
||||||
|
addonId: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sourceType: string;
|
||||||
|
refreshInterval: number;
|
||||||
|
defaultConfig: Record<string, unknown>;
|
||||||
|
configFields: WidgetConfigField[];
|
||||||
|
component: ComponentType<{ widget: WidgetInstance }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WIDGET_REGISTRY: Record<string, WidgetDefinition> = {
|
||||||
|
jellyfin: {
|
||||||
|
widgetType: "jellyfin",
|
||||||
|
addonId: "core",
|
||||||
|
name: "Jellyfin activity",
|
||||||
|
description: "Live sessions and idle users from a Jellyfin server.",
|
||||||
|
sourceType: "jellyfin",
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
defaultConfig: { machine_id: "" },
|
||||||
|
configFields: [
|
||||||
|
{
|
||||||
|
key: "machine_id",
|
||||||
|
label: "Machine ID",
|
||||||
|
type: "string",
|
||||||
|
helper: "Jellyfin machine id (empty = default)",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
component: JellyfinWidget,
|
||||||
|
},
|
||||||
|
backups: {
|
||||||
|
widgetType: "backups",
|
||||||
|
addonId: "backups",
|
||||||
|
name: "Backups",
|
||||||
|
description: "Backup job summary and active alerts.",
|
||||||
|
sourceType: "backups",
|
||||||
|
refreshInterval: 60_000,
|
||||||
|
defaultConfig: {},
|
||||||
|
configFields: [],
|
||||||
|
component: BackupsWidget,
|
||||||
|
},
|
||||||
|
"grafana-link": {
|
||||||
|
widgetType: "grafana-link",
|
||||||
|
addonId: "grafana",
|
||||||
|
name: "Grafana link",
|
||||||
|
description: "Deep-link to a Grafana dashboard or panel.",
|
||||||
|
sourceType: "grafana",
|
||||||
|
refreshInterval: 0,
|
||||||
|
defaultConfig: { dashboard_uid: "" },
|
||||||
|
configFields: [
|
||||||
|
{ key: "dashboard_uid", label: "Dashboard UID", type: "string" },
|
||||||
|
{
|
||||||
|
key: "panel_id",
|
||||||
|
label: "Panel ID",
|
||||||
|
type: "number",
|
||||||
|
helper: "Optional",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
component: GrafanaLinkWidget,
|
||||||
|
},
|
||||||
|
"prometheus-metric": {
|
||||||
|
widgetType: "prometheus-metric",
|
||||||
|
addonId: "prometheus",
|
||||||
|
name: "Prometheus metric",
|
||||||
|
description: "Instant query result rendered as a metric.",
|
||||||
|
sourceType: "prometheus",
|
||||||
|
refreshInterval: 30_000,
|
||||||
|
defaultConfig: { promql: "" },
|
||||||
|
configFields: [{ key: "promql", label: "PromQL query", type: "string" }],
|
||||||
|
component: PrometheusMetricWidget,
|
||||||
|
},
|
||||||
|
"ssh-task": {
|
||||||
|
widgetType: "ssh-task",
|
||||||
|
addonId: "ssh-tasks",
|
||||||
|
name: "SSH task output",
|
||||||
|
description: "Output of a saved task run on a machine.",
|
||||||
|
sourceType: "ssh_task",
|
||||||
|
refreshInterval: 0,
|
||||||
|
defaultConfig: { task_id: "" },
|
||||||
|
configFields: [{ key: "task_id", label: "Saved task ID", type: "string" }],
|
||||||
|
component: SshTaskWidget,
|
||||||
|
},
|
||||||
|
static: {
|
||||||
|
widgetType: "static",
|
||||||
|
addonId: "core",
|
||||||
|
name: "Static text",
|
||||||
|
description: "Plain text or markdown note.",
|
||||||
|
sourceType: "static",
|
||||||
|
refreshInterval: 0,
|
||||||
|
defaultConfig: { text: "" },
|
||||||
|
configFields: [{ key: "text", label: "Text", type: "string" }],
|
||||||
|
component: StaticWidget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getWidgetDefinition(
|
||||||
|
widgetType: string,
|
||||||
|
): WidgetDefinition | undefined {
|
||||||
|
return WIDGET_REGISTRY[widgetType];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listWidgetTypes(): WidgetDefinition[] {
|
||||||
|
return Object.values(WIDGET_REGISTRY);
|
||||||
|
}
|
||||||
@@ -51,11 +51,145 @@ 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.
|
||||||
|
|
||||||
|
## Completed tasks (Slice 3)
|
||||||
|
|
||||||
|
All Slice 3 tasks are marked `- [x]` in `tasks.md`:
|
||||||
|
|
||||||
|
- [x] 3.1 Add TypeScript widget interfaces (`WidgetInstance`, `WidgetInstanceInput`, `WidgetTypeInfo`, `WidgetDataResponse`)
|
||||||
|
- [x] 3.2 Create widget API client (`frontend/src/api/widgets.ts`)
|
||||||
|
- [x] 3.3 Create widget TanStack Query hooks (`frontend/src/hooks/useWidgets.ts`)
|
||||||
|
- [x] 3.4 Create frontend widget registry (`frontend/src/widgets/registry.ts`)
|
||||||
|
- [x] 3.5 Implement six widget presentational components (`frontend/src/widgets/*.tsx`)
|
||||||
|
- [x] 3.6 Add frontend registry unit test (`frontend/src/widgets/registry.test.ts`)
|
||||||
|
|
||||||
|
## Files changed (Slice 3)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `frontend/src/api/widgets.ts` — API functions for widget CRUD, registry metadata, and per-widget data.
|
||||||
|
- `frontend/src/hooks/useWidgets.ts` — TanStack Query hooks for instances, data, sources, types, and mutations.
|
||||||
|
- `frontend/src/widgets/registry.ts` — Closed frontend registry with metadata, refresh intervals, and config fields.
|
||||||
|
- `frontend/src/widgets/JellyfinWidget.tsx` — Renders Jellyfin session activity.
|
||||||
|
- `frontend/src/widgets/BackupsWidget.tsx` — Renders backup dashboard summary.
|
||||||
|
- `frontend/src/widgets/GrafanaLinkWidget.tsx` — Renders a deep-link to Grafana (no iframe).
|
||||||
|
- `frontend/src/widgets/PrometheusMetricWidget.tsx` — Renders PromQL instant query result.
|
||||||
|
- `frontend/src/widgets/SshTaskWidget.tsx` — Renders saved SSH task output.
|
||||||
|
- `frontend/src/widgets/StaticWidget.tsx` — Renders static text.
|
||||||
|
- `frontend/src/widgets/index.ts` — Barrel exports.
|
||||||
|
- `frontend/src/widgets/registry.test.ts` — Vitest unit tests for registry metadata.
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `frontend/src/types/index.ts` — Added widget TypeScript interfaces.
|
||||||
|
|
||||||
|
## Verification (Slice 3)
|
||||||
|
|
||||||
|
```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
|
||||||
|
npm run test -- src/widgets/registry.test.ts # 3 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from design (Slice 3)
|
||||||
|
|
||||||
|
- Registry unit test is colocated at `frontend/src/widgets/registry.test.ts` and runs with Vitest, matching the project's existing `npm run test` setup, instead of `frontend/tests/widgets.test.mjs`.
|
||||||
|
- `JellyfinWidget` uses `SessionActivityPanel` directly because `NowPlaying` does not expose an `emptyMessage` prop.
|
||||||
|
|
||||||
|
## Completed tasks (Slice 4)
|
||||||
|
|
||||||
|
All Slice 4 tasks are marked `- [x]` in `tasks.md`:
|
||||||
|
|
||||||
|
- [x] 4.1 Refactor `Dashboard.tsx` to render enabled widget instances in sort order
|
||||||
|
- [x] 4.2 Create `WidgetInstance` renderer component
|
||||||
|
- [x] 4.3 Create `WidgetConfigDialog` for add/edit/reorder/delete widgets
|
||||||
|
- [x] 4.4 Create addon pages (`AddonPage`, `GrafanaAddonPage`, `PrometheusAddonPage`, `SshTasksAddonPage`)
|
||||||
|
- [x] 4.5 Register `/addons/:addonId` route in `App.tsx`
|
||||||
|
- [x] 4.6 Update `docs/REQUIREMENTS.md` with widget system documentation
|
||||||
|
|
||||||
|
## Files changed (Slice 4)
|
||||||
|
|
||||||
|
### New files
|
||||||
|
|
||||||
|
- `frontend/src/components/WidgetInstance.tsx` — Renders a widget instance by looking up its definition and dispatching to the registered component.
|
||||||
|
- `frontend/src/components/WidgetConfigDialog.tsx` — Dashboard widget configuration UI: list, add, edit, delete, reorder, enable/disable.
|
||||||
|
- `frontend/src/pages/AddonPage.tsx` — Route mapper for `/addons/:addonId`.
|
||||||
|
- `frontend/src/addons/GrafanaAddonPage.tsx` — Grafana addon landing page (deep-link only).
|
||||||
|
- `frontend/src/addons/PrometheusAddonPage.tsx` — Prometheus addon landing page.
|
||||||
|
- `frontend/src/addons/SshTasksAddonPage.tsx` — SSH tasks addon landing page.
|
||||||
|
- `frontend/src/addons/index.ts` — Barrel exports.
|
||||||
|
|
||||||
|
### Modified files
|
||||||
|
|
||||||
|
- `frontend/src/pages/Dashboard.tsx` — Replaced hard-coded Jellyfin/Backups sections with widget instance loop; kept Shortcuts section; added "Edit dashboard" button.
|
||||||
|
- `frontend/src/App.tsx` — Registered `/addons/:addonId` route in both OIDC and non-OIDC route trees.
|
||||||
|
- `docs/REQUIREMENTS.md` — Added Configurable Dashboard Widgets section.
|
||||||
|
|
||||||
|
## Verification (Slice 4)
|
||||||
|
|
||||||
|
```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
|
||||||
|
npm run test -- src/widgets/registry.test.ts # 3 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Deviations from design (Slice 4)
|
||||||
|
|
||||||
|
- The "Edit dashboard" button lives in the Shortcuts section action area for now. A future UI pass can move it to a dedicated dashboard header.
|
||||||
|
- Machine/task selectors in the config dialog filter to enabled Jellyfin machines / enabled tasks, which is slightly stricter than the design's generic string field.
|
||||||
|
|
||||||
## Remaining work
|
## Remaining work
|
||||||
|
|
||||||
- Slice 2: Backend source adapters + `GET /api/widgets/instances/{id}/data`
|
- Phase 1 widget system is complete. Future work could include widget grid layout, drag-and-drop reorder, richer Prometheus visualizations, or migrating shortcuts into the widget system.
|
||||||
- Slice 3: Frontend types/API/hooks/registry/components
|
|
||||||
- Slice 4: Dashboard loop + configuration UI + addon pages
|
|
||||||
|
|
||||||
## PR boundary
|
## PR boundary
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user