Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 | |||
| 2557185fb7 | |||
| e1356b20f1 | |||
| e6d333ef7b | |||
| 1cd8e926de | |||
| 1a52dfb087 | |||
| 9dfe62eb6f |
@@ -1,3 +1,7 @@
|
|||||||
|
# Manage environment template
|
||||||
|
# Copy this file to .env, fill in the required values, and export them in your shell
|
||||||
|
# before running docker compose. Compose files use interpolation, not env_file.
|
||||||
|
|
||||||
# App
|
# App
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=0.1.0
|
||||||
APP_BUILD_INFO=dev
|
APP_BUILD_INFO=dev
|
||||||
@@ -23,6 +27,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
|
||||||
@@ -42,6 +48,7 @@ VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
VITE_GRAFANA_URL=https://grafana.example.com
|
VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
|
VITE_PROMETHEUS_URL=https://prometheus.example.com
|
||||||
|
|
||||||
# SMTP
|
# SMTP
|
||||||
SMTP_HOST=smtp.example.com
|
SMTP_HOST=smtp.example.com
|
||||||
|
|||||||
@@ -20,14 +20,15 @@ The project consists of two subprojects:
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, SSH task output, static text) and shortcuts
|
||||||
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines
|
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
||||||
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine
|
- Per-machine settings for Jellyfin, Jellyseerr, SSH, and monitoring targets
|
||||||
- SQLite-indexed media table with full-library sort/filter
|
- SQLite-indexed media table with full-library sort/filter
|
||||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||||
- Remote file browser with ffprobe preview and job execution
|
- Remote file browser with ffprobe preview and job execution
|
||||||
- Jellyfin API integration for library metadata and user identity data
|
- Jellyfin API integration for library metadata and user identity data
|
||||||
- SSH-based file inspection and remote job templates
|
- SSH-based file inspection and safe remote job templates
|
||||||
|
- Addon pages for Grafana, Prometheus, and SSH tasks at `/addons/:addonId`
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
@@ -39,7 +40,9 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
|
|||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Open the app at http://localhost:8080.
|
Open the app at <http://localhost:8080>.
|
||||||
|
|
||||||
|
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
|
||||||
|
|
||||||
Local development with hot reload:
|
Local development with hot reload:
|
||||||
|
|
||||||
@@ -47,9 +50,9 @@ Local development with hot reload:
|
|||||||
docker compose -f docker-compose.dev.yml up --build
|
docker compose -f docker-compose.dev.yml up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000.
|
Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
|
||||||
The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index.
|
|
||||||
Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts.
|
The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
|
||||||
|
|
||||||
### Manual backend/frontend development
|
### Manual backend/frontend development
|
||||||
|
|
||||||
@@ -76,13 +79,16 @@ The Compose files use environment-variable interpolation. Export the required va
|
|||||||
Production-style example with shell exports:
|
Production-style example with shell exports:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export BACKEND_APP_HOST=manage.example.com
|
export BACKEND_APP_HOST=api.manage.example.com
|
||||||
export FRONTEND_APP_HOST=manage.example.com
|
export FRONTEND_APP_HOST=manage.example.com
|
||||||
|
export GRAFANA_APP_HOST=grafana.manage.example.com
|
||||||
export CERT_RESOLVER=letsencrypt
|
export CERT_RESOLVER=letsencrypt
|
||||||
export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/
|
export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||||
export VITE_OIDC_CLIENT_ID=manage
|
export VITE_OIDC_CLIENT_ID=manage
|
||||||
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/
|
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
|
export VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||||
|
export VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||||
|
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
@@ -90,7 +96,7 @@ docker compose up --build
|
|||||||
Inline one-liner example:
|
Inline one-liner example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build
|
BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com GRAFANA_APP_HOST=grafana.manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_PROMETHEUS_URL=https://prometheus.manage.example.com docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
For local development, no SSH key is required unless you want to connect to remote SSH machines later:
|
||||||
@@ -124,27 +130,33 @@ SMTP_TIMEOUT=30
|
|||||||
|
|
||||||
# Authentik / OIDC
|
# Authentik / OIDC
|
||||||
AUTH_ENABLED=true
|
AUTH_ENABLED=true
|
||||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||||
OIDC_AUDIENCE=media-library-viewer
|
OIDC_AUDIENCE=manage
|
||||||
OIDC_JWKS_URL=
|
OIDC_JWKS_URL=
|
||||||
OIDC_CLOCK_SKEW_SECONDS=30
|
OIDC_CLOCK_SKEW_SECONDS=30
|
||||||
|
|
||||||
# Frontend OIDC settings
|
# Frontend OIDC settings
|
||||||
VITE_OIDC_ENABLED=true
|
VITE_OIDC_ENABLED=true
|
||||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
VITE_OIDC_CLIENT_ID=manage
|
||||||
VITE_OIDC_SCOPE=openid profile email
|
VITE_OIDC_SCOPE=openid profile email
|
||||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||||
|
|
||||||
|
# Grafana / Prometheus URLs used by widget adapters and frontend deep-links
|
||||||
|
GRAFANA_URL=http://grafana:3000
|
||||||
|
PROMETHEUS_URL=http://prometheus:9090
|
||||||
|
VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||||
|
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||||
```
|
```
|
||||||
|
|
||||||
## Remote server requirements
|
## Remote server requirements
|
||||||
|
|
||||||
The remote server needs:
|
The remote server needs:
|
||||||
|
|
||||||
- Linux `/proc` and `/sys/block` for monitoring
|
|
||||||
- `/bin/sh` (POSIX shell)
|
- `/bin/sh` (POSIX shell)
|
||||||
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk`
|
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
|
||||||
|
- SSH access with a key configured in the app's Settings tab
|
||||||
|
|
||||||
The SSH client rejects unknown host keys. Connect manually once first:
|
The SSH client rejects unknown host keys. Connect manually once first:
|
||||||
|
|
||||||
@@ -167,5 +179,6 @@ cd frontend && npx tsc --noEmit && npm run build
|
|||||||
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
|
||||||
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
- SSH commands run through `/bin/sh -c` regardless of remote login shell.
|
||||||
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
|
||||||
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
|
|
||||||
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
|
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
|
||||||
|
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
|
||||||
|
- Grafana and Prometheus widget adapters use `GRAFANA_URL` and `PROMETHEUS_URL` (backend) and `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` (frontend) for deep-links; no credentials are stored in widget config.
|
||||||
|
|||||||
@@ -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:
|
||||||
@@ -38,6 +40,7 @@ services:
|
|||||||
VITE_OIDC_ENABLED: "false"
|
VITE_OIDC_ENABLED: "false"
|
||||||
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
||||||
VITE_GRAFANA_URL: "http://localhost:3000"
|
VITE_GRAFANA_URL: "http://localhost:3000"
|
||||||
|
VITE_PROMETHEUS_URL: "http://localhost:9090"
|
||||||
ports:
|
ports:
|
||||||
- "5173:5173"
|
- "5173:5173"
|
||||||
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
|
||||||
@@ -70,6 +72,7 @@ services:
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
|
||||||
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
|
||||||
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
|
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
|
||||||
|
VITE_PROMETHEUS_URL: ${VITE_PROMETHEUS_URL:-http://localhost:9090}
|
||||||
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
|
||||||
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ ARG VITE_OIDC_REDIRECT_URI=
|
|||||||
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
||||||
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||||
ARG VITE_GRAFANA_URL=https://grafana.example.com
|
ARG VITE_GRAFANA_URL=https://grafana.example.com
|
||||||
|
ARG VITE_PROMETHEUS_URL=http://localhost:9090
|
||||||
ARG VITE_APP_VERSION=0.1.0
|
ARG VITE_APP_VERSION=0.1.0
|
||||||
ARG VITE_APP_BUILD_INFO=dev
|
ARG VITE_APP_BUILD_INFO=dev
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ ENV VITE_API_URL=${VITE_API_URL} \
|
|||||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
|
||||||
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
|
||||||
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
||||||
|
VITE_PROMETHEUS_URL=${VITE_PROMETHEUS_URL} \
|
||||||
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
||||||
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ ENV VITE_API_URL=/api \
|
|||||||
VITE_OIDC_ENABLED=false \
|
VITE_OIDC_ENABLED=false \
|
||||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
||||||
VITE_GRAFANA_URL=http://localhost:3000 \
|
VITE_GRAFANA_URL=http://localhost:3000 \
|
||||||
|
VITE_PROMETHEUS_URL=http://localhost:9090 \
|
||||||
VITE_APP_VERSION=0.1.0 \
|
VITE_APP_VERSION=0.1.0 \
|
||||||
VITE_APP_BUILD_INFO=dev
|
VITE_APP_BUILD_INFO=dev
|
||||||
|
|
||||||
|
|||||||
@@ -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