Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c69911252 | |||
| 7b3e2ebace | |||
| cfb9977532 | |||
| 802a9202e9 | |||
| 7ab9b1ac59 | |||
| cbb703341e | |||
| a13f560df2 | |||
| 5eb49be697 | |||
| 8ff735d644 | |||
| d998e6ab0c | |||
| 9a6cbfae68 | |||
| c9c72be0b6 | |||
| 5ec35b4849 | |||
| 739ad38e29 | |||
| 1da67f38c7 | |||
| 41dddbccc0 | |||
| f6a86310cc | |||
| 10fd4ead4a | |||
| 2452e2e1e4 | |||
| fd534a816b | |||
| 8cdeadd6dd | |||
| d1819c0186 | |||
| 9459de5c07 | |||
| 9782280a03 | |||
| 0ad6a04053 | |||
| 75636c00d4 | |||
| f4b16b5844 | |||
| 09eb76bf0f | |||
| ed7a7a5ce0 | |||
| e4e879d1c8 |
+8
-2
@@ -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,8 +27,9 @@ 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
|
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
|
||||||
PROMETHEUS_URL=http://prometheus:9090
|
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||||
BACKEND_CACHE_DIR=./backend-cache
|
BACKEND_CACHE_DIR=./backend-cache
|
||||||
|
|
||||||
# Auth
|
# Auth
|
||||||
@@ -44,6 +49,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
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to Manage. Breaking changes are marked with **BREAKING**.
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added — Service registry
|
||||||
|
|
||||||
|
- Runtime **service registry** persisted in the backend SQLite database. External
|
||||||
|
services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are now
|
||||||
|
configured in the app instead of via environment variables.
|
||||||
|
- Services page (`/services`) to create, list, and delete service instances.
|
||||||
|
- Service detail pages (`/services/:serviceType/:serviceId`) to edit name/enabled
|
||||||
|
state, rotate secrets, and view the widgets a service provides.
|
||||||
|
- Service definitions live as Pydantic modules in `backend/.../integrations/`,
|
||||||
|
each declaring its config schema, secret fields, and widget kinds.
|
||||||
|
- Multi-instance support: multiple Grafana/Jellyfin/etc. instances per type.
|
||||||
|
- SSH task runner service records run history in a new `service_task_runs`
|
||||||
|
table, shown on the runner's service page.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Dashboard widgets are now **service-bound** (reference a service instance +
|
||||||
|
widget kind) or **built-in** (backups, static text). The "Add widget" flow is
|
||||||
|
pick-service → pick-widget-kind → configure.
|
||||||
|
- Deleting a service cascade-deletes widgets that reference it.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
|
||||||
|
Fernet.
|
||||||
|
|
||||||
|
### **BREAKING**
|
||||||
|
|
||||||
|
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
|
||||||
|
with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
```
|
||||||
|
|
||||||
|
- The `GRAFANA_URL` and `PROMETHEUS_URL` backend environment variables were
|
||||||
|
removed; Grafana/Prometheus URLs now live on service records configured in the
|
||||||
|
UI. Re-create them on the Services page after upgrading.
|
||||||
|
- The legacy widget/addon-pages model (`/addons/:addonId`,
|
||||||
|
`/api/widgets/types`, `/api/widgets/sources`) was removed in favor of the
|
||||||
|
service registry.
|
||||||
|
- Default dashboard widget seeding was removed; a fresh install starts with an
|
||||||
|
empty dashboard. Add widgets from the dashboard's edit dialog after
|
||||||
|
configuring services.
|
||||||
|
|
||||||
|
### Notes / follow-ups
|
||||||
|
|
||||||
|
- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
|
||||||
|
pages. Migrating those onto the service registry is a separate follow-up change
|
||||||
|
(see `openspec/changes/service-registry/design.md` §12.5).
|
||||||
|
|
||||||
|
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
|
||||||
|
|
||||||
|
With Jellyfin/Jellyseerr now resolved from the service registry, the machine-level
|
||||||
|
Jellyfin/Jellyseerr fields are dead config. Removed from `dependencies.py` (dead
|
||||||
|
`_jellyseerr_client_for`; `_resolve_machine` simplified to SSH-only),
|
||||||
|
`services/settings_store.py`, `routers/settings.py` (`MachineInput`), frontend
|
||||||
|
types, the `Settings.tsx` form, and frontend test fixtures. Existing DB rows may
|
||||||
|
still carry these keys in `config_json`; they are inert and get dropped on the
|
||||||
|
next machine save. No data migration required.
|
||||||
@@ -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,17 @@ 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
|
||||||
|
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
|
||||||
|
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
@@ -90,7 +97,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 +131,35 @@ 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 public URLs for frontend deep-links (service adapters read URLs from service records)
|
||||||
|
VITE_GRAFANA_URL=https://grafana.manage.example.com
|
||||||
|
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
|
||||||
|
|
||||||
|
# Required: master key encrypting service secrets (API keys/tokens) at rest.
|
||||||
|
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||||
|
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
|
||||||
```
|
```
|
||||||
|
|
||||||
## 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 +182,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 resolve URLs from service records configured in the app; `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` are only used for frontend deep-links. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ dependencies = [
|
|||||||
"python-multipart>=0.0.9",
|
"python-multipart>=0.0.9",
|
||||||
"prometheus-client>=0.21",
|
"prometheus-client>=0.21",
|
||||||
"python-json-logger>=2.0",
|
"python-json-logger>=2.0",
|
||||||
|
"cryptography>=42.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
|||||||
@@ -57,8 +57,6 @@ 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 = ""
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Dependency injection for FastAPI.
|
"""Dependency injection for FastAPI.
|
||||||
|
|
||||||
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
|
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
||||||
context. The selected machine can be chosen with a ``machine_id`` query
|
machine-specific SSH clients via FastAPI's request context.
|
||||||
parameter; otherwise the backend falls back to the first enabled machine that
|
|
||||||
matches the requested service.
|
- Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
|
||||||
|
parameter (resolved against the service registry); the backend falls back to
|
||||||
|
the first enabled ``jellyfin``/``jellyseerr`` service instance.
|
||||||
|
- SSH/Files transport is selected with ``machine_id`` as before.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
|
|||||||
return machine_id or None
|
return machine_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _request_jellyfin_service_id(request: Request | None) -> str | None:
|
||||||
|
if request is None:
|
||||||
|
return None
|
||||||
|
service_id = request.query_params.get("jellyfin_service_id")
|
||||||
|
return service_id or None
|
||||||
|
|
||||||
|
|
||||||
|
def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None:
|
||||||
|
"""Return a service row for a type, preferring the requested id.
|
||||||
|
|
||||||
|
The row carries an in-memory decrypted ``secrets`` dict. Returns None if no
|
||||||
|
enabled instance of the type exists.
|
||||||
|
"""
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||||
|
|
||||||
|
row = None
|
||||||
|
if service_id:
|
||||||
|
candidate = store.get_service(service_id)
|
||||||
|
if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True):
|
||||||
|
row = candidate
|
||||||
|
if row is None:
|
||||||
|
instances = [s for s in store.list_services(service_type) if s.get("enabled", True)]
|
||||||
|
row = instances[0] if instances else None
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
decrypted = {}
|
||||||
|
blob = row.get("secrets") or {}
|
||||||
|
if blob:
|
||||||
|
try:
|
||||||
|
decrypted = decrypt_secrets(blob)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id"))
|
||||||
|
return {**row, "secrets": decrypted}
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||||
machine_id, url, api_key = cache_key
|
machine_id, url, api_key = cache_key
|
||||||
@@ -43,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
|||||||
return JellyfinClient(url, api_key)
|
return JellyfinClient(url, api_key)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
|
||||||
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
|
|
||||||
machine_id, url = cache_key
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
|
|
||||||
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
|
|
||||||
if not api_key:
|
|
||||||
return None
|
|
||||||
logger.info(
|
|
||||||
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
|
|
||||||
)
|
|
||||||
return JellyseerrClient(url, api_key)
|
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=32)
|
@lru_cache(maxsize=32)
|
||||||
def _ssh_client_for(
|
def _ssh_client_for(
|
||||||
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
||||||
@@ -116,6 +139,10 @@ def _ssh_client_for(
|
|||||||
|
|
||||||
|
|
||||||
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
|
||||||
|
"""Resolve an SSH/Files machine for the given transport service.
|
||||||
|
|
||||||
|
Jellyfin/Jellyseerr are resolved against the service registry, not here.
|
||||||
|
"""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
machine_id = _request_machine_id(request)
|
||||||
if machine_id:
|
if machine_id:
|
||||||
@@ -123,11 +150,7 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
|||||||
if machine and (service in machine.get("services", []) or service == "ssh"):
|
if machine and (service in machine.get("services", []) or service == "ssh"):
|
||||||
return machine
|
return machine
|
||||||
return machine
|
return machine
|
||||||
if service == "jellyfin":
|
if service == "ssh":
|
||||||
machines = store.list_machines_for_service("jellyfin")
|
|
||||||
elif service == "jellyseerr":
|
|
||||||
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
|
|
||||||
elif service == "ssh":
|
|
||||||
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
||||||
else:
|
else:
|
||||||
machines = store.list_machines_for_service(service)
|
machines = store.list_machines_for_service(service)
|
||||||
@@ -135,37 +158,34 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
|||||||
|
|
||||||
|
|
||||||
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
def get_jellyfin_client(request: Request = None) -> JellyfinClient:
|
||||||
"""Return a Jellyfin client for the selected machine."""
|
"""Return a Jellyfin client for the selected Jellyfin service instance."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if machine is None:
|
if service is None:
|
||||||
resolved = _resolve_machine("jellyfin", request)
|
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
|
||||||
if resolved:
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
machine = store.get_machine_config(resolved["id"])
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"):
|
if not base_url or not api_key:
|
||||||
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
|
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||||
return _jellyfin_client_for(cache_key)
|
cache_key = (service["id"], base_url, api_key)
|
||||||
|
return _jellyfin_client_for(cache_key)
|
||||||
raise RuntimeError(
|
|
||||||
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyseerr", service_id)
|
||||||
if machine is None:
|
if service is None:
|
||||||
resolved = _resolve_machine("jellyseerr", request)
|
logger.info("Jellyseerr client not configured (no jellyseerr service)")
|
||||||
if resolved:
|
return None
|
||||||
machine = store.get_machine_config(resolved["id"])
|
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||||
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
|
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||||
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
|
if not base_url or not api_key:
|
||||||
|
logger.info("Jellyseerr service is missing base_url or api_key")
|
||||||
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)")
|
return None
|
||||||
return None
|
return JellyseerrClient(base_url, api_key)
|
||||||
|
|
||||||
|
|
||||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||||
@@ -253,16 +273,12 @@ def get_settings_store() -> SettingsStore:
|
|||||||
def get_user_id(request: Request = None) -> str:
|
def get_user_id(request: Request = None) -> str:
|
||||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
machine_id = _request_machine_id(request)
|
service_id = _request_jellyfin_service_id(request)
|
||||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
service = _service_record(store, "jellyfin", service_id)
|
||||||
if machine is None:
|
if service and service.get("config", {}).get("user_id"):
|
||||||
resolved = _resolve_machine("jellyfin", request)
|
return str(service["config"]["user_id"])
|
||||||
if resolved:
|
|
||||||
machine = store.get_machine_config(resolved["id"])
|
|
||||||
if machine and machine.get("jellyfin_user_id"):
|
|
||||||
return str(machine["jellyfin_user_id"])
|
|
||||||
client = get_jellyfin_client(request)
|
client = get_jellyfin_client(request)
|
||||||
users = client.users()
|
users = client.users()
|
||||||
if not users:
|
if not users:
|
||||||
raise RuntimeError("No Jellyfin users found and no machine/user id configured")
|
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
|
||||||
return users[0]["Id"]
|
return users[0]["Id"]
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Closed registry of service integrations."""
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
"""Base classes for service integrations.
|
||||||
|
|
||||||
|
A *service definition* is a closed, compile-time description of an external service
|
||||||
|
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
|
||||||
|
|
||||||
|
* its non-secret ``config_schema`` (derived from a Pydantic model),
|
||||||
|
* the secret fields it accepts (API keys / tokens),
|
||||||
|
* the widget kinds it can contribute to the dashboard (each with its own
|
||||||
|
Pydantic-derived config schema).
|
||||||
|
|
||||||
|
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
|
||||||
|
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
|
||||||
|
map. There is no runtime plugin loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceConfigBase(BaseModel):
|
||||||
|
"""Base for per-service non-secret config models.
|
||||||
|
|
||||||
|
Subclass this in each integration module and declare the connection fields.
|
||||||
|
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetConfigBase(BaseModel):
|
||||||
|
"""Base for per-widget config models.
|
||||||
|
|
||||||
|
Subclass this for each widget kind a service provides. Widget configs never
|
||||||
|
hold secrets; credentials live on the parent service record.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model_config = {"extra": "forbid"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SecretField:
|
||||||
|
"""A secret field stored encrypted on the service record."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
required: bool = False
|
||||||
|
helper: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class WidgetKind:
|
||||||
|
"""A widget kind contributed by a service definition."""
|
||||||
|
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
default_config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
refresh_interval_ms: int = 0
|
||||||
|
config_model: type[WidgetConfigBase] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ServiceDefinition:
|
||||||
|
"""Closed description of an external service type."""
|
||||||
|
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_model: type[ServiceConfigBase]
|
||||||
|
secret_fields: list[SecretField]
|
||||||
|
widget_kinds: list[WidgetKind]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def config_schema(self) -> dict[str, Any]:
|
||||||
|
"""JSON schema for the service's non-secret config."""
|
||||||
|
return self.config_model.model_json_schema()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def secret_keys(self) -> set[str]:
|
||||||
|
return {sf.key for sf in self.secret_fields}
|
||||||
|
|
||||||
|
def widget_kind(self, kind: str) -> WidgetKind | None:
|
||||||
|
for wk in self.widget_kinds:
|
||||||
|
if wk.kind == kind:
|
||||||
|
return wk
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def widget_kind(
|
||||||
|
kind: str,
|
||||||
|
name: str,
|
||||||
|
description: str,
|
||||||
|
model_cls: type[WidgetConfigBase],
|
||||||
|
*,
|
||||||
|
default_config: dict[str, Any] | None = None,
|
||||||
|
refresh_interval_ms: int = 0,
|
||||||
|
) -> WidgetKind:
|
||||||
|
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
|
||||||
|
schema = model_cls.model_json_schema()
|
||||||
|
# Strip Pydantic's title noise so the exposed schema stays clean.
|
||||||
|
schema.pop("title", None)
|
||||||
|
return WidgetKind(
|
||||||
|
kind=kind,
|
||||||
|
name=name,
|
||||||
|
description=description,
|
||||||
|
config_schema=schema,
|
||||||
|
default_config=dict(default_config or {}),
|
||||||
|
refresh_interval_ms=refresh_interval_ms,
|
||||||
|
config_model=model_cls,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
|
||||||
|
instance = model_cls.model_validate(config or {})
|
||||||
|
return instance.model_dump(exclude_none=True)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Grafana service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Grafana connection config."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
timeout_seconds: int = 5
|
||||||
|
|
||||||
|
|
||||||
|
class GrafanaLinkWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Deep-link to a Grafana dashboard or panel."""
|
||||||
|
|
||||||
|
dashboard_uid: str
|
||||||
|
panel_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="grafana",
|
||||||
|
name="Grafana",
|
||||||
|
description="Dashboards, metrics, and logs.",
|
||||||
|
config_model=GrafanaConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="link",
|
||||||
|
name="Dashboard link",
|
||||||
|
description="Deep-link to a Grafana dashboard or panel.",
|
||||||
|
model_cls=GrafanaLinkWidgetConfig,
|
||||||
|
default_config={"dashboard_uid": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""Jellyfin service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Jellyfin connection config."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
user_id: str = ""
|
||||||
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class JellyfinActivityWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Live Jellyfin session activity."""
|
||||||
|
|
||||||
|
# No user-overridable fields; the service record carries user_id.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="jellyfin",
|
||||||
|
name="Jellyfin",
|
||||||
|
description="Media server with live session activity.",
|
||||||
|
config_model=JellyfinConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="activity",
|
||||||
|
name="Activity",
|
||||||
|
description="Live sessions and idle users.",
|
||||||
|
model_cls=JellyfinActivityWidgetConfig,
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=30_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Jellyseerr service definition.
|
||||||
|
|
||||||
|
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
|
||||||
|
own service type so multiple Jellyseerr instances are supported independently of
|
||||||
|
Jellyfin. It provides no dashboard widgets today.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JellyseerrConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Jellyseerr connection config."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="jellyseerr",
|
||||||
|
name="Jellyseerr",
|
||||||
|
description="Request management companion to Jellyfin.",
|
||||||
|
config_model=JellyseerrConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[],
|
||||||
|
)
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Nextcloud service definition.
|
||||||
|
|
||||||
|
Nextcloud is included as a proof-of-concept third-party service. It has no
|
||||||
|
dashboard widgets yet; its service page holds connection config only.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class NextcloudConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Nextcloud connection config."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
username: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="nextcloud",
|
||||||
|
name="Nextcloud",
|
||||||
|
description="Self-hosted files and collaboration.",
|
||||||
|
config_model=NextcloudConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="app_password", label="App password", required=True),
|
||||||
|
],
|
||||||
|
widget_kinds=[],
|
||||||
|
)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Prometheus service definition."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret Prometheus connection config."""
|
||||||
|
|
||||||
|
base_url: str
|
||||||
|
timeout_seconds: int = 10
|
||||||
|
|
||||||
|
|
||||||
|
class PrometheusMetricWidgetConfig(WidgetConfigBase):
|
||||||
|
"""A PromQL instant query rendered as a metric."""
|
||||||
|
|
||||||
|
promql: str
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="prometheus",
|
||||||
|
name="Prometheus",
|
||||||
|
description="Metrics storage and PromQL queries.",
|
||||||
|
config_model=PrometheusConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="metric",
|
||||||
|
name="Metric",
|
||||||
|
description="Instant query result rendered as a metric.",
|
||||||
|
model_cls=PrometheusMetricWidgetConfig,
|
||||||
|
default_config={"promql": ""},
|
||||||
|
refresh_interval_ms=30_000,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
"""Closed registry of service definitions.
|
||||||
|
|
||||||
|
Adding a brand-new service still requires a backend deploy and a module here.
|
||||||
|
There is no runtime plugin loading.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
|
||||||
|
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
|
||||||
|
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
|
||||||
|
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
|
||||||
|
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
|
||||||
|
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
|
||||||
|
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
|
||||||
|
|
||||||
|
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||||
|
GRAFANA.service_type: GRAFANA,
|
||||||
|
PROMETHEUS.service_type: PROMETHEUS,
|
||||||
|
JELLYFIN.service_type: JELLYFIN,
|
||||||
|
JELLYSEERR.service_type: JELLYSEERR,
|
||||||
|
NEXTCLOUD.service_type: NEXTCLOUD,
|
||||||
|
SSH_TASKS.service_type: SSH_TASKS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def list_service_types() -> list[str]:
|
||||||
|
"""Return all registered service type names (sorted for stable output)."""
|
||||||
|
return sorted(SERVICE_DEFINITIONS)
|
||||||
|
|
||||||
|
|
||||||
|
def get_service_definition(service_type: str) -> ServiceDefinition | None:
|
||||||
|
"""Return the definition for a service type, or ``None`` if unknown."""
|
||||||
|
return SERVICE_DEFINITIONS.get(service_type)
|
||||||
|
|
||||||
|
|
||||||
|
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None:
|
||||||
|
"""Return a widget kind declared by a service definition, or ``None``."""
|
||||||
|
definition = get_service_definition(service_type)
|
||||||
|
if definition is None:
|
||||||
|
return None
|
||||||
|
return definition.widget_kind(widget_kind)
|
||||||
|
|
||||||
|
|
||||||
|
def require_service_definition(service_type: str) -> ServiceDefinition:
|
||||||
|
"""Return the definition or raise ``ValueError`` for an unknown type."""
|
||||||
|
definition = get_service_definition(service_type)
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(f"Unknown service type: {service_type}")
|
||||||
|
return definition
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""SSH task runner service definition.
|
||||||
|
|
||||||
|
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
|
||||||
|
Tasks themselves stay in the global saved-task registry; the instance only owns
|
||||||
|
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
|
||||||
|
and shown as history on the instance's service page.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import (
|
||||||
|
SecretField,
|
||||||
|
ServiceConfigBase,
|
||||||
|
ServiceDefinition,
|
||||||
|
WidgetConfigBase,
|
||||||
|
widget_kind,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SshTasksConfig(ServiceConfigBase):
|
||||||
|
"""Non-secret SSH task runner config.
|
||||||
|
|
||||||
|
The SSH key itself lives in the saved SSH-key registry and is referenced by
|
||||||
|
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
|
||||||
|
"""
|
||||||
|
|
||||||
|
host: str
|
||||||
|
port: int = 22
|
||||||
|
username: str = ""
|
||||||
|
ssh_key_id: str = ""
|
||||||
|
timeout_seconds: int = 30
|
||||||
|
|
||||||
|
|
||||||
|
class SshTaskOutputWidgetConfig(WidgetConfigBase):
|
||||||
|
"""Output of a saved task run on this instance."""
|
||||||
|
|
||||||
|
task_id: str
|
||||||
|
# service_id is implicit (the widget's service); allow overriding per-widget.
|
||||||
|
service_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
DEFINITION = ServiceDefinition(
|
||||||
|
service_type="ssh_tasks",
|
||||||
|
name="SSH task runner",
|
||||||
|
description="Run reusable saved tasks over SSH and keep run history.",
|
||||||
|
config_model=SshTasksConfig,
|
||||||
|
secret_fields=[
|
||||||
|
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
widget_kind(
|
||||||
|
kind="task_output",
|
||||||
|
name="Task output",
|
||||||
|
description="Output of a saved task run.",
|
||||||
|
model_cls=SshTaskOutputWidgetConfig,
|
||||||
|
default_config={"task_id": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
@@ -23,6 +23,7 @@ from media_library_viewer_api.observability import (
|
|||||||
)
|
)
|
||||||
from media_library_viewer_api.routers import backups as backups_router
|
from media_library_viewer_api.routers import backups as backups_router
|
||||||
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
|
||||||
|
from media_library_viewer_api.routers import services as services_router
|
||||||
from media_library_viewer_api.routers import widgets as widgets_router
|
from media_library_viewer_api.routers import widgets as widgets_router
|
||||||
from media_library_viewer_api.routers.settings import router as settings_router
|
from media_library_viewer_api.routers.settings import router as settings_router
|
||||||
|
|
||||||
@@ -38,6 +39,9 @@ async def lifespan(app: FastAPI):
|
|||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
configure_logging(settings.log_level, settings.log_format)
|
configure_logging(settings.log_level, settings.log_format)
|
||||||
validate_auth_settings(settings)
|
validate_auth_settings(settings)
|
||||||
|
from media_library_viewer_api.services.secrets import validate_encryption_key
|
||||||
|
|
||||||
|
validate_encryption_key()
|
||||||
logger.info("Backend startup complete: %s", describe_settings(settings))
|
logger.info("Backend startup complete: %s", describe_settings(settings))
|
||||||
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||||
try:
|
try:
|
||||||
@@ -143,6 +147,7 @@ app.include_router(tasks.router)
|
|||||||
app.include_router(settings_router)
|
app.include_router(settings_router)
|
||||||
app.include_router(backups_router.router)
|
app.include_router(backups_router.router)
|
||||||
app.include_router(widgets_router.router)
|
app.include_router(widgets_router.router)
|
||||||
|
app.include_router(services_router.router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"""Pydantic models for the service registry API."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, field_validator
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Reject credential keys in non-secret service config.
|
||||||
|
|
||||||
|
Secrets are sent in the separate ``secrets`` mapping; the plain ``config``
|
||||||
|
object must never hold them.
|
||||||
|
"""
|
||||||
|
forbidden = {
|
||||||
|
"password",
|
||||||
|
"token",
|
||||||
|
"secret",
|
||||||
|
"api_key",
|
||||||
|
"apikey",
|
||||||
|
"private_key",
|
||||||
|
"passphrase",
|
||||||
|
"credential",
|
||||||
|
}
|
||||||
|
|
||||||
|
def _check(value: Any) -> None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
for key, child in value.items():
|
||||||
|
if key.lower() in forbidden:
|
||||||
|
raise ValueError(f"Credential key '{key}' is not allowed in service config")
|
||||||
|
_check(child)
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
_check(item)
|
||||||
|
|
||||||
|
_check(config)
|
||||||
|
return config
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstanceInput(BaseModel):
|
||||||
|
"""Payload for creating or updating a service instance."""
|
||||||
|
|
||||||
|
id: str | None = None
|
||||||
|
service_type: str = Field(..., min_length=1)
|
||||||
|
name: str = Field(..., min_length=1)
|
||||||
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = Field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@field_validator("config")
|
||||||
|
@classmethod
|
||||||
|
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return _validate_config_keys(value or {})
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInstance(BaseModel):
|
||||||
|
"""Persisted service instance returned by the API (no plaintext secrets)."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
config: dict[str, Any]
|
||||||
|
secrets_set: dict[str, bool]
|
||||||
|
enabled: bool
|
||||||
|
created_at: int
|
||||||
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
|
class SecretFieldInfo(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
required: bool = False
|
||||||
|
helper: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WidgetKindInfo(BaseModel):
|
||||||
|
kind: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
default_config: dict[str, Any]
|
||||||
|
refresh_interval_ms: int
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceTypeInfo(BaseModel):
|
||||||
|
"""Metadata about a registered service type."""
|
||||||
|
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any]
|
||||||
|
secret_fields: list[SecretFieldInfo]
|
||||||
|
widget_kinds: list[WidgetKindInfo]
|
||||||
@@ -1,8 +1,18 @@
|
|||||||
"""Pydantic models for the dashboard widget system."""
|
"""Pydantic models for the dashboard widget system.
|
||||||
|
|
||||||
|
Widgets are either:
|
||||||
|
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
|
||||||
|
by that service's definition (Grafana link, Prometheus metric, Jellyfin
|
||||||
|
activity, SSH task output); or
|
||||||
|
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
|
||||||
|
service-less kinds (backups, static).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, field_validator
|
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||||
|
|
||||||
FORBIDDEN_CONFIG_KEYS = {
|
FORBIDDEN_CONFIG_KEYS = {
|
||||||
"password",
|
"password",
|
||||||
@@ -47,8 +57,8 @@ def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
|||||||
class _WidgetInstanceBase(BaseModel):
|
class _WidgetInstanceBase(BaseModel):
|
||||||
"""Shared fields between input and output widget models."""
|
"""Shared fields between input and output widget models."""
|
||||||
|
|
||||||
addon_id: str
|
service_id: str | None = None
|
||||||
widget_type: str
|
widget_kind: str = Field(..., min_length=1)
|
||||||
title: str = Field(..., min_length=1)
|
title: str = Field(..., min_length=1)
|
||||||
config: dict[str, Any] = Field(default_factory=dict)
|
config: dict[str, Any] = Field(default_factory=dict)
|
||||||
enabled: bool = True
|
enabled: bool = True
|
||||||
@@ -59,6 +69,13 @@ class _WidgetInstanceBase(BaseModel):
|
|||||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||||
return _validate_config_keys(value or {})
|
return _validate_config_keys(value or {})
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_kind(self) -> "_WidgetInstanceBase":
|
||||||
|
# The kind must be non-empty (Field enforces it); service_id may be None
|
||||||
|
# for built-ins. Deeper validation happens in the router against the
|
||||||
|
# service definition / built-in registry.
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
class WidgetInstanceInput(_WidgetInstanceBase):
|
class WidgetInstanceInput(_WidgetInstanceBase):
|
||||||
"""Payload for creating or updating a widget instance."""
|
"""Payload for creating or updating a widget instance."""
|
||||||
@@ -74,22 +91,21 @@ class WidgetInstance(_WidgetInstanceBase):
|
|||||||
updated_at: int
|
updated_at: int
|
||||||
|
|
||||||
|
|
||||||
class WidgetTypeInfo(BaseModel):
|
class BuiltinWidgetKindInfo(BaseModel):
|
||||||
"""Metadata about a built-in widget type."""
|
"""Metadata about a built-in (service-less) widget kind."""
|
||||||
|
|
||||||
addon_id: str
|
kind: str
|
||||||
widget_type: str
|
|
||||||
name: str
|
name: str
|
||||||
description: str
|
description: str
|
||||||
source_type: str
|
|
||||||
config_schema: dict[str, Any]
|
config_schema: dict[str, Any]
|
||||||
|
default_config: dict[str, Any]
|
||||||
|
refresh_interval_ms: int
|
||||||
|
|
||||||
|
|
||||||
class WidgetDataResponse(BaseModel):
|
class WidgetDataResponse(BaseModel):
|
||||||
"""Response from the per-widget data endpoint."""
|
"""Response from the per-widget data endpoint."""
|
||||||
|
|
||||||
widget_id: str
|
widget_id: str
|
||||||
widget_type: str
|
|
||||||
data: dict[str, Any] | None = None
|
data: dict[str, Any] | None = None
|
||||||
error: str | None = None
|
error: str | None = None
|
||||||
fetched_at: int
|
fetched_at: int
|
||||||
|
|||||||
@@ -0,0 +1,182 @@
|
|||||||
|
"""REST API for the service registry.
|
||||||
|
|
||||||
|
Service instances hold non-secret config and encrypted secrets. Plaintext
|
||||||
|
secrets are never returned; only the boolean ``secrets_set`` map is exposed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.integrations.base import validate_config
|
||||||
|
from media_library_viewer_api.integrations.registry import (
|
||||||
|
SERVICE_DEFINITIONS,
|
||||||
|
get_service_definition,
|
||||||
|
require_service_definition,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.models.services import (
|
||||||
|
SecretFieldInfo,
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
WidgetKindInfo,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/services", tags=["services"])
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_type_info(service_type: str) -> ServiceTypeInfo:
|
||||||
|
definition = require_service_definition(service_type)
|
||||||
|
return ServiceTypeInfo(
|
||||||
|
service_type=definition.service_type,
|
||||||
|
name=definition.name,
|
||||||
|
description=definition.description,
|
||||||
|
config_schema=definition.config_schema,
|
||||||
|
secret_fields=[
|
||||||
|
SecretFieldInfo(
|
||||||
|
key=sf.key,
|
||||||
|
label=sf.label,
|
||||||
|
required=sf.required,
|
||||||
|
helper=sf.helper,
|
||||||
|
)
|
||||||
|
for sf in definition.secret_fields
|
||||||
|
],
|
||||||
|
widget_kinds=[
|
||||||
|
WidgetKindInfo(
|
||||||
|
kind=wk.kind,
|
||||||
|
name=wk.name,
|
||||||
|
description=wk.description,
|
||||||
|
config_schema=wk.config_schema,
|
||||||
|
default_config=wk.default_config,
|
||||||
|
refresh_interval_ms=wk.refresh_interval_ms,
|
||||||
|
)
|
||||||
|
for wk in definition.widget_kinds
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _to_instance(row: dict[str, Any]) -> ServiceInstance:
|
||||||
|
"""Build an API response model, surfacing only secret 'set' flags."""
|
||||||
|
definition = get_service_definition(row["service_type"])
|
||||||
|
known_secrets = definition.secret_keys if definition else set()
|
||||||
|
secrets_blob = row.get("secrets") or {}
|
||||||
|
secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets}
|
||||||
|
return ServiceInstance(
|
||||||
|
id=row["id"],
|
||||||
|
service_type=row["service_type"],
|
||||||
|
name=row["name"],
|
||||||
|
config=row.get("config") or {},
|
||||||
|
secrets_set=secrets_set,
|
||||||
|
enabled=row["enabled"],
|
||||||
|
created_at=row["created_at"],
|
||||||
|
updated_at=row["updated_at"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_input(body: ServiceInstanceInput) -> None:
|
||||||
|
"""Validate service_type, config, and secret keys against the definition."""
|
||||||
|
definition = get_service_definition(body.service_type)
|
||||||
|
if definition is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown service type: {body.service_type}",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_config(definition.config_model, body.config)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid service config: {exc}",
|
||||||
|
) from exc
|
||||||
|
unknown_secrets = set(body.secrets) - definition.secret_keys
|
||||||
|
if unknown_secrets:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/types")
|
||||||
|
def list_types() -> list[ServiceTypeInfo]:
|
||||||
|
"""Return metadata for every registered service type."""
|
||||||
|
return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/instances")
|
||||||
|
def list_instances(
|
||||||
|
service_type: str | None = None,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> list[ServiceInstance]:
|
||||||
|
"""Return all persisted service instances (no plaintext secrets)."""
|
||||||
|
rows = store.list_services(service_type)
|
||||||
|
return [_to_instance(row) for row in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_instance(
|
||||||
|
body: ServiceInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> ServiceInstance:
|
||||||
|
"""Create a new service instance."""
|
||||||
|
_validate_input(body)
|
||||||
|
row = store.upsert_service(
|
||||||
|
{
|
||||||
|
"id": body.id,
|
||||||
|
"service_type": body.service_type,
|
||||||
|
"name": body.name,
|
||||||
|
"config": body.config,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
secret_values=body.secrets,
|
||||||
|
)
|
||||||
|
return _to_instance(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/instances/{service_id}")
|
||||||
|
def update_instance(
|
||||||
|
service_id: str,
|
||||||
|
body: ServiceInstanceInput,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> ServiceInstance:
|
||||||
|
"""Update an existing service instance."""
|
||||||
|
existing = store.get_service(service_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||||
|
if body.id is not None and body.id != service_id:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="ID in path does not match ID in body",
|
||||||
|
)
|
||||||
|
_validate_input(body)
|
||||||
|
row = store.upsert_service(
|
||||||
|
{
|
||||||
|
"id": service_id,
|
||||||
|
"service_type": body.service_type,
|
||||||
|
"name": body.name,
|
||||||
|
"config": body.config,
|
||||||
|
"enabled": body.enabled,
|
||||||
|
},
|
||||||
|
secret_values=body.secrets,
|
||||||
|
service_id=service_id,
|
||||||
|
)
|
||||||
|
return _to_instance(row)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/instances/{service_id}")
|
||||||
|
def delete_instance(
|
||||||
|
service_id: str,
|
||||||
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Delete a service instance (cascade-deletes widgets referencing it)."""
|
||||||
|
existing = store.get_service(service_id)
|
||||||
|
if not existing:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
|
||||||
|
store.delete_service(service_id)
|
||||||
|
return {"status": "deleted"}
|
||||||
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
|
|||||||
password: str = ""
|
password: str = ""
|
||||||
media_root: str = ""
|
media_root: str = ""
|
||||||
path_prefix: str = ""
|
path_prefix: str = ""
|
||||||
jellyfin_url: str = ""
|
|
||||||
jellyfin_user_id: str = ""
|
|
||||||
jellyfin_api_key: str = ""
|
|
||||||
jellyseerr_url: str = ""
|
|
||||||
jellyseerr_api_key: str = ""
|
|
||||||
notes: str = ""
|
notes: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
"""REST API for dashboard widget instances and registry metadata."""
|
"""REST API for dashboard widget instances.
|
||||||
|
|
||||||
|
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
|
||||||
|
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
|
||||||
|
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
@@ -7,68 +14,91 @@ 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.integrations.base import validate_config
|
||||||
|
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||||
from media_library_viewer_api.models.widgets import (
|
from media_library_viewer_api.models.widgets import (
|
||||||
|
BuiltinWidgetKindInfo,
|
||||||
WidgetDataResponse,
|
WidgetDataResponse,
|
||||||
WidgetInstance,
|
WidgetInstance,
|
||||||
WidgetInstanceInput,
|
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.builtin import (
|
||||||
get_widget_info,
|
BUILTIN_WIDGET_KINDS,
|
||||||
list_source_types,
|
is_builtin_kind,
|
||||||
list_widget_types,
|
validate_builtin_config,
|
||||||
validate_config,
|
)
|
||||||
|
from media_library_viewer_api.widgets.sources import (
|
||||||
|
build_service_record,
|
||||||
|
get_builtin_adapter,
|
||||||
|
get_service_adapter,
|
||||||
)
|
)
|
||||||
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
|
||||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
"""Validate widget_kind + config against the service definition or built-ins."""
|
||||||
|
if body.service_id:
|
||||||
|
service = store.get_service(body.service_id)
|
||||||
|
if not service:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Service {body.service_id} not found",
|
||||||
|
)
|
||||||
|
definition = get_service_definition(service["service_type"])
|
||||||
|
if definition is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Unknown service type: {service['service_type']}",
|
||||||
|
)
|
||||||
|
widget_kind = definition.widget_kind(body.widget_kind)
|
||||||
|
if widget_kind is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
|
||||||
|
)
|
||||||
|
if widget_kind.config_model is not None:
|
||||||
|
try:
|
||||||
|
validate_config(widget_kind.config_model, body.config)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid widget config: {exc}",
|
||||||
|
) from exc
|
||||||
|
else:
|
||||||
|
if not is_builtin_kind(body.widget_kind):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=(
|
||||||
|
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
validate_builtin_config(body.widget_kind, body.config)
|
||||||
|
except Exception as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
||||||
|
detail=f"Invalid widget config: {exc}",
|
||||||
|
) from exc
|
||||||
|
|
||||||
info = WIDGET_REGISTRY.get(widget_type)
|
|
||||||
if not info:
|
@router.get("/builtin")
|
||||||
raise HTTPException(
|
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
"""Return metadata for service-less built-in widget kinds."""
|
||||||
detail=f"Unknown widget type: {widget_type}",
|
return [
|
||||||
|
BuiltinWidgetKindInfo(
|
||||||
|
kind=wk.kind,
|
||||||
|
name=wk.name,
|
||||||
|
description=wk.description,
|
||||||
|
config_schema=wk.config_schema,
|
||||||
|
default_config=wk.default_config,
|
||||||
|
refresh_interval_ms=wk.refresh_interval_ms,
|
||||||
)
|
)
|
||||||
return info
|
for wk in BUILTIN_WIDGET_KINDS.values()
|
||||||
|
]
|
||||||
|
|
||||||
def _validate_widget_input(body: WidgetInstanceInput) -> None:
|
|
||||||
"""Validate widget_type/addon_id match and config schema."""
|
|
||||||
info = _registry_for_type(body.widget_type)
|
|
||||||
expected_addon = info["addon_id"]
|
|
||||||
if body.addon_id != expected_addon:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=(
|
|
||||||
f"Widget type '{body.widget_type}' belongs to addon "
|
|
||||||
f"'{expected_addon}', not '{body.addon_id}'"
|
|
||||||
),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
validate_config(body.widget_type, body.config)
|
|
||||||
except ValueError as exc:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
|
||||||
detail=str(exc),
|
|
||||||
) from exc
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sources")
|
|
||||||
def list_sources() -> list[str]:
|
|
||||||
"""Return all registered widget source types."""
|
|
||||||
return list_source_types()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/types")
|
|
||||||
def list_types() -> list[dict[str, Any]]:
|
|
||||||
"""Return metadata for all registered widget types."""
|
|
||||||
return [info.model_dump() for info in list_widget_types()]
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/instances")
|
@router.get("/instances")
|
||||||
@@ -85,7 +115,7 @@ def create_instance(
|
|||||||
store: SettingsStore = Depends(get_settings_store),
|
store: SettingsStore = Depends(get_settings_store),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a new widget instance."""
|
"""Create a new widget instance."""
|
||||||
_validate_widget_input(body)
|
_validate_widget_input(body, store)
|
||||||
widget = store.upsert_widget(body.model_dump())
|
widget = store.upsert_widget(body.model_dump())
|
||||||
return WidgetInstance(**widget).model_dump()
|
return WidgetInstance(**widget).model_dump()
|
||||||
|
|
||||||
@@ -105,7 +135,7 @@ def update_instance(
|
|||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail="ID in path does not match ID in body",
|
detail="ID in path does not match ID in body",
|
||||||
)
|
)
|
||||||
_validate_widget_input(body)
|
_validate_widget_input(body, store)
|
||||||
widget = store.upsert_widget(body.model_dump(), widget_id)
|
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||||
return WidgetInstance(**widget).model_dump()
|
return WidgetInstance(**widget).model_dump()
|
||||||
|
|
||||||
@@ -133,31 +163,44 @@ async def fetch_data(
|
|||||||
if not widget:
|
if not widget:
|
||||||
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")
|
||||||
|
|
||||||
widget_type = widget["widget_type"]
|
service_id = widget.get("service_id")
|
||||||
info = get_widget_info(widget_type)
|
widget_kind = widget.get("widget_kind") or ""
|
||||||
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)
|
service: Any = None
|
||||||
if adapter is None:
|
if service_id:
|
||||||
# Defensive: registry should prevent this, but return a safe error.
|
service_row = store.get_service(service_id)
|
||||||
return WidgetDataResponse(
|
if not service_row:
|
||||||
widget_id=widget_id,
|
return WidgetDataResponse(
|
||||||
widget_type=widget_type,
|
widget_id=widget_id,
|
||||||
data=None,
|
error=f"Service {service_id} not found",
|
||||||
error=f"No adapter registered for source type: {info.source_type}",
|
fetched_at=int(time.time()),
|
||||||
fetched_at=int(time.time()),
|
).model_dump()
|
||||||
).model_dump()
|
if not service_row.get("enabled", True):
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
error="Service is disabled",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
adapter = get_service_adapter(service_row["service_type"])
|
||||||
|
if adapter is None:
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
error=f"No adapter for service type {service_row['service_type']}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
service = build_service_record(store, service_row)
|
||||||
|
else:
|
||||||
|
adapter = get_builtin_adapter(widget_kind)
|
||||||
|
if adapter is None:
|
||||||
|
return WidgetDataResponse(
|
||||||
|
widget_id=widget_id,
|
||||||
|
error=f"Unknown built-in widget kind: {widget_kind}",
|
||||||
|
fetched_at=int(time.time()),
|
||||||
|
).model_dump()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
data = await adapter.fetch(widget["config"])
|
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
|
||||||
except Exception as exc:
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
@@ -166,7 +209,6 @@ async def fetch_data(
|
|||||||
|
|
||||||
return WidgetDataResponse(
|
return WidgetDataResponse(
|
||||||
widget_id=widget_id,
|
widget_id=widget_id,
|
||||||
widget_type=widget_type,
|
|
||||||
data=data if "error" not in data else None,
|
data=data if "error" not in data else None,
|
||||||
error=data.get("error"),
|
error=data.get("error"),
|
||||||
fetched_at=int(time.time()),
|
fetched_at=int(time.time()),
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Encryption-at-rest for service secrets.
|
||||||
|
|
||||||
|
Service API keys / tokens are stored encrypted in the ``services.secrets_json``
|
||||||
|
column. Encryption uses Fernet (symmetric authenticated encryption) with a single
|
||||||
|
master key provided via the ``MANAGE_ENCRYPTION_KEY`` environment variable.
|
||||||
|
|
||||||
|
* The key **must** be a urlsafe base64-encoded 32-byte value (Fernet format).
|
||||||
|
* The key is **always required** — there is no development fallback, so secrets
|
||||||
|
are never accidentally stored in plaintext.
|
||||||
|
* Secrets are encrypted field-by-field; the ``"which secrets are set"`` metadata
|
||||||
|
can be derived from the ciphertext blob without decrypting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
ENCRYPTION_KEY_ENV = "MANAGE_ENCRYPTION_KEY"
|
||||||
|
|
||||||
|
|
||||||
|
class EncryptionKeyError(RuntimeError):
|
||||||
|
"""Raised when the encryption key is missing or invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def get_encryption_key() -> bytes:
|
||||||
|
"""Return the raw Fernet key, or raise if missing/invalid.
|
||||||
|
|
||||||
|
The result is cached for the process lifetime. Tests should call
|
||||||
|
:func:`reset_encryption_key_cache` after changing the environment.
|
||||||
|
"""
|
||||||
|
raw = os.environ.get(ENCRYPTION_KEY_ENV)
|
||||||
|
if not raw:
|
||||||
|
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} is required to store service secrets")
|
||||||
|
key = raw.strip().encode()
|
||||||
|
try:
|
||||||
|
Fernet(key)
|
||||||
|
except (ValueError, TypeError) as exc: # pragma: no cover - validated by tests
|
||||||
|
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key") from exc
|
||||||
|
return key
|
||||||
|
|
||||||
|
|
||||||
|
def reset_encryption_key_cache() -> None:
|
||||||
|
"""Drop the cached encryption key (used by tests that swap keys)."""
|
||||||
|
get_encryption_key.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _fernet() -> Fernet:
|
||||||
|
return Fernet(get_encryption_key())
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_value(plaintext: str) -> str:
|
||||||
|
"""Encrypt a single secret value and return the ciphertext string."""
|
||||||
|
return _fernet().encrypt(plaintext.encode()).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_value(ciphertext: str) -> str:
|
||||||
|
"""Decrypt a single ciphertext value."""
|
||||||
|
try:
|
||||||
|
return _fernet().decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise EncryptionKeyError("Service secret could not be decrypted") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Encrypt every provided secret value."""
|
||||||
|
fernet = _fernet()
|
||||||
|
return {key: fernet.encrypt(value.encode()).decode() for key, value in values.items()}
|
||||||
|
|
||||||
|
|
||||||
|
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]:
|
||||||
|
"""Decrypt every secret value in a blob."""
|
||||||
|
fernet = _fernet()
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
for key, ciphertext in blob.items():
|
||||||
|
try:
|
||||||
|
result[key] = fernet.decrypt(ciphertext.encode()).decode()
|
||||||
|
except InvalidToken as exc:
|
||||||
|
raise EncryptionKeyError(f"Service secret '{key}' could not be decrypted") from exc
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def generate_development_key() -> str:
|
||||||
|
"""Return a freshly generated Fernet key (helper for operators/docs)."""
|
||||||
|
return Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_encryption_key() -> None:
|
||||||
|
"""Eagerly validate that the encryption key is present and well-formed."""
|
||||||
|
get_encryption_key() # raises EncryptionKeyError on failure
|
||||||
@@ -44,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
|
|||||||
"password": "",
|
"password": "",
|
||||||
"media_root": settings.media_root,
|
"media_root": settings.media_root,
|
||||||
"path_prefix": settings.path_prefix,
|
"path_prefix": settings.path_prefix,
|
||||||
"jellyfin_url": "",
|
|
||||||
"jellyfin_user_id": "",
|
|
||||||
"jellyfin_api_key": "",
|
|
||||||
"jellyseerr_url": "",
|
|
||||||
"jellyseerr_api_key": "",
|
|
||||||
"node_exporter_enabled": False,
|
"node_exporter_enabled": False,
|
||||||
"node_exporter_port": 9100,
|
"node_exporter_port": 9100,
|
||||||
"node_exporter_scrape_host": "",
|
"node_exporter_scrape_host": "",
|
||||||
@@ -179,9 +174,12 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
conn.execute(
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
|
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||||
)
|
if "service_id" not in widget_cols:
|
||||||
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||||
|
if "widget_kind" not in widget_cols:
|
||||||
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
|
||||||
conn.execute("""
|
conn.execute("""
|
||||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
@@ -226,6 +224,44 @@ class SettingsStore:
|
|||||||
""")
|
""")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
|
||||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
service_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
secrets_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute("CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type)")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS service_task_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
exit_status INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
stdout_tail TEXT NOT NULL DEFAULT '',
|
||||||
|
stderr_tail TEXT NOT NULL DEFAULT '',
|
||||||
|
error TEXT NOT NULL DEFAULT '',
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_service "
|
||||||
|
"ON service_task_runs(service_id, created_at DESC)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
|
||||||
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||||
@@ -265,11 +301,6 @@ class SettingsStore:
|
|||||||
"password_set": bool(data.get("password")),
|
"password_set": bool(data.get("password")),
|
||||||
"media_root": data.get("media_root", ""),
|
"media_root": data.get("media_root", ""),
|
||||||
"path_prefix": data.get("path_prefix", ""),
|
"path_prefix": data.get("path_prefix", ""),
|
||||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
|
||||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
|
||||||
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
|
|
||||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
|
||||||
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
|
|
||||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||||
@@ -319,17 +350,6 @@ class SettingsStore:
|
|||||||
password = str(password or "")
|
password = str(password or "")
|
||||||
media_root = _current_str("media_root")
|
media_root = _current_str("media_root")
|
||||||
path_prefix = _current_str("path_prefix")
|
path_prefix = _current_str("path_prefix")
|
||||||
jellyfin_url = _current_str("jellyfin_url")
|
|
||||||
jellyfin_user_id = _current_str("jellyfin_user_id")
|
|
||||||
jellyfin_api_key = payload.get("jellyfin_api_key")
|
|
||||||
if jellyfin_api_key in (None, ""):
|
|
||||||
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
|
|
||||||
jellyfin_api_key = str(jellyfin_api_key or "")
|
|
||||||
jellyseerr_url = _current_str("jellyseerr_url")
|
|
||||||
jellyseerr_api_key = payload.get("jellyseerr_api_key")
|
|
||||||
if jellyseerr_api_key in (None, ""):
|
|
||||||
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
|
|
||||||
jellyseerr_api_key = str(jellyseerr_api_key or "")
|
|
||||||
node_exporter_enabled = bool(
|
node_exporter_enabled = bool(
|
||||||
payload.get("node_exporter_enabled")
|
payload.get("node_exporter_enabled")
|
||||||
if payload.get("node_exporter_enabled") is not None
|
if payload.get("node_exporter_enabled") is not None
|
||||||
@@ -361,11 +381,6 @@ class SettingsStore:
|
|||||||
"password": password,
|
"password": password,
|
||||||
"media_root": media_root,
|
"media_root": media_root,
|
||||||
"path_prefix": path_prefix,
|
"path_prefix": path_prefix,
|
||||||
"jellyfin_url": jellyfin_url,
|
|
||||||
"jellyfin_user_id": jellyfin_user_id,
|
|
||||||
"jellyfin_api_key": jellyfin_api_key,
|
|
||||||
"jellyseerr_url": jellyseerr_url,
|
|
||||||
"jellyseerr_api_key": jellyseerr_api_key,
|
|
||||||
"node_exporter_enabled": node_exporter_enabled,
|
"node_exporter_enabled": node_exporter_enabled,
|
||||||
"node_exporter_port": node_exporter_port,
|
"node_exporter_port": node_exporter_port,
|
||||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||||
@@ -389,11 +404,6 @@ class SettingsStore:
|
|||||||
"password": "",
|
"password": "",
|
||||||
"media_root": machine["media_root"],
|
"media_root": machine["media_root"],
|
||||||
"path_prefix": machine["path_prefix"],
|
"path_prefix": machine["path_prefix"],
|
||||||
"jellyfin_url": machine["jellyfin_url"],
|
|
||||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
|
||||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
|
||||||
"jellyseerr_url": machine["jellyseerr_url"],
|
|
||||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
|
||||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||||
"node_exporter_port": machine["node_exporter_port"],
|
"node_exporter_port": machine["node_exporter_port"],
|
||||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||||
@@ -417,39 +427,13 @@ class SettingsStore:
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _seed_dashboard_widgets(self) -> None:
|
def _seed_dashboard_widgets(self) -> None:
|
||||||
"""Seed default dashboard widgets only when the table is empty."""
|
"""Default widget seeding was removed.
|
||||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
|
||||||
|
|
||||||
self.init_schema()
|
Widgets are now service-bound (or built-in). A fresh install starts with
|
||||||
with self.connect() as conn:
|
no widgets; the user configures services and adds widgets from the UI.
|
||||||
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
|
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
|
||||||
if row and int(row[0]) > 0:
|
"""
|
||||||
return
|
return None
|
||||||
defaults = [
|
|
||||||
{
|
|
||||||
"id": "jellyfin-activity-default",
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "jellyfin",
|
|
||||||
"title": "Jellyfin activity",
|
|
||||||
"config": {"machine_id": ""},
|
|
||||||
"enabled": True,
|
|
||||||
"sort_order": 0,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "backups-summary-default",
|
|
||||||
"addon_id": "backups",
|
|
||||||
"widget_type": "backups",
|
|
||||||
"title": "Backups",
|
|
||||||
"config": {},
|
|
||||||
"enabled": True,
|
|
||||||
"sort_order": 1,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
for widget in defaults:
|
|
||||||
info = WIDGET_REGISTRY.get(widget["widget_type"])
|
|
||||||
if not info or info["addon_id"] != widget["addon_id"]:
|
|
||||||
continue
|
|
||||||
self.upsert_widget(widget)
|
|
||||||
|
|
||||||
def ensure_defaults(self) -> None:
|
def ensure_defaults(self) -> None:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
@@ -457,7 +441,6 @@ class SettingsStore:
|
|||||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||||
if not row or int(row[0]) == 0:
|
if not row or int(row[0]) == 0:
|
||||||
self._seed_local_machine()
|
self._seed_local_machine()
|
||||||
self._seed_dashboard_widgets()
|
|
||||||
|
|
||||||
def list_machines(self) -> list[dict[str, Any]]:
|
def list_machines(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
@@ -506,11 +489,6 @@ class SettingsStore:
|
|||||||
"password": data.get("password", ""),
|
"password": data.get("password", ""),
|
||||||
"media_root": data.get("media_root", ""),
|
"media_root": data.get("media_root", ""),
|
||||||
"path_prefix": data.get("path_prefix", ""),
|
"path_prefix": data.get("path_prefix", ""),
|
||||||
"jellyfin_url": data.get("jellyfin_url", ""),
|
|
||||||
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
|
|
||||||
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
|
|
||||||
"jellyseerr_url": data.get("jellyseerr_url", ""),
|
|
||||||
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
|
|
||||||
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
|
||||||
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||||
@@ -550,11 +528,6 @@ class SettingsStore:
|
|||||||
"password": machine["password"],
|
"password": machine["password"],
|
||||||
"media_root": machine["media_root"],
|
"media_root": machine["media_root"],
|
||||||
"path_prefix": machine["path_prefix"],
|
"path_prefix": machine["path_prefix"],
|
||||||
"jellyfin_url": machine["jellyfin_url"],
|
|
||||||
"jellyfin_user_id": machine["jellyfin_user_id"],
|
|
||||||
"jellyfin_api_key": machine["jellyfin_api_key"],
|
|
||||||
"jellyseerr_url": machine["jellyseerr_url"],
|
|
||||||
"jellyseerr_api_key": machine["jellyseerr_api_key"],
|
|
||||||
"node_exporter_enabled": machine["node_exporter_enabled"],
|
"node_exporter_enabled": machine["node_exporter_enabled"],
|
||||||
"node_exporter_port": machine["node_exporter_port"],
|
"node_exporter_port": machine["node_exporter_port"],
|
||||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||||
@@ -1363,16 +1336,18 @@ class SettingsStore:
|
|||||||
(key, value, now),
|
(key, value, now),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Dashboard widgets
|
# Dashboard widgets
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
keys = row.keys()
|
||||||
return {
|
return {
|
||||||
"id": row["id"],
|
"id": row["id"],
|
||||||
"addon_id": row["addon_id"],
|
"addon_id": row["addon_id"],
|
||||||
"widget_type": row["widget_type"],
|
"widget_type": row["widget_type"],
|
||||||
|
"service_id": row["service_id"] if "service_id" in keys else None,
|
||||||
|
"widget_kind": row["widget_kind"] if "widget_kind" in keys else None,
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
"config": json.loads(row["config_json"] or "{}"),
|
"config": json.loads(row["config_json"] or "{}"),
|
||||||
"enabled": bool(row["enabled"]),
|
"enabled": bool(row["enabled"]),
|
||||||
@@ -1387,14 +1362,9 @@ class SettingsStore:
|
|||||||
widget_id: str | None = None,
|
widget_id: str | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
current = self.get_widget(widget_id) if widget_id else None
|
current = self.get_widget(widget_id) if widget_id else None
|
||||||
widget_id = (
|
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||||
str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip()
|
service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
|
||||||
or uuid.uuid4().hex[:12]
|
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
|
||||||
)
|
|
||||||
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
|
|
||||||
widget_type = str(
|
|
||||||
payload.get("widget_type") or (current or {}).get("widget_type", "")
|
|
||||||
).strip()
|
|
||||||
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
||||||
config = payload.get("config", (current or {}).get("config", {}))
|
config = payload.get("config", (current or {}).get("config", {}))
|
||||||
if not isinstance(config, dict):
|
if not isinstance(config, dict):
|
||||||
@@ -1403,10 +1373,14 @@ class SettingsStore:
|
|||||||
_validate_config_keys(config)
|
_validate_config_keys(config)
|
||||||
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
|
||||||
|
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
|
||||||
|
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
|
||||||
return {
|
return {
|
||||||
"id": widget_id,
|
"id": widget_id,
|
||||||
"addon_id": addon_id,
|
"addon_id": "",
|
||||||
"widget_type": widget_type,
|
"widget_type": widget_type,
|
||||||
|
"service_id": service_id,
|
||||||
|
"widget_kind": widget_kind,
|
||||||
"title": title,
|
"title": title,
|
||||||
"config": config,
|
"config": config,
|
||||||
"enabled": enabled,
|
"enabled": enabled,
|
||||||
@@ -1416,9 +1390,7 @@ class SettingsStore:
|
|||||||
def list_widgets(self) -> list[dict[str, Any]]:
|
def list_widgets(self) -> list[dict[str, Any]]:
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
rows = conn.execute(
|
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
|
||||||
"SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC"
|
|
||||||
).fetchall()
|
|
||||||
return [self._row_to_widget(row) for row in rows]
|
return [self._row_to_widget(row) for row in rows]
|
||||||
|
|
||||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||||
@@ -1426,9 +1398,7 @@ class SettingsStore:
|
|||||||
return None
|
return None
|
||||||
self.init_schema()
|
self.init_schema()
|
||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
row = conn.execute(
|
row = conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone()
|
||||||
"SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)
|
|
||||||
).fetchone()
|
|
||||||
return self._row_to_widget(row) if row else None
|
return self._row_to_widget(row) if row else None
|
||||||
|
|
||||||
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
|
||||||
@@ -1444,13 +1414,15 @@ class SettingsStore:
|
|||||||
conn.execute(
|
conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO dashboard_widgets (
|
INSERT INTO dashboard_widgets (
|
||||||
id, addon_id, widget_type, title, config_json, enabled,
|
id, addon_id, widget_type, service_id, widget_kind, title,
|
||||||
sort_order, created_at, updated_at
|
config_json, enabled, sort_order, created_at, updated_at
|
||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON CONFLICT(id) DO UPDATE SET
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
addon_id = excluded.addon_id,
|
addon_id = excluded.addon_id,
|
||||||
widget_type = excluded.widget_type,
|
widget_type = excluded.widget_type,
|
||||||
|
service_id = excluded.service_id,
|
||||||
|
widget_kind = excluded.widget_kind,
|
||||||
title = excluded.title,
|
title = excluded.title,
|
||||||
config_json = excluded.config_json,
|
config_json = excluded.config_json,
|
||||||
enabled = excluded.enabled,
|
enabled = excluded.enabled,
|
||||||
@@ -1461,6 +1433,8 @@ class SettingsStore:
|
|||||||
widget["id"],
|
widget["id"],
|
||||||
widget["addon_id"],
|
widget["addon_id"],
|
||||||
widget["widget_type"],
|
widget["widget_type"],
|
||||||
|
widget["service_id"],
|
||||||
|
widget["widget_kind"],
|
||||||
widget["title"],
|
widget["title"],
|
||||||
json.dumps(widget["config"]),
|
json.dumps(widget["config"]),
|
||||||
1 if widget["enabled"] else 0,
|
1 if widget["enabled"] else 0,
|
||||||
@@ -1476,6 +1450,204 @@ class SettingsStore:
|
|||||||
with self.connect() as conn:
|
with self.connect() as conn:
|
||||||
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Service registry
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _row_to_service(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||||
|
secrets_blob = json.loads(row["secrets_json"] or "{}")
|
||||||
|
return {
|
||||||
|
"id": row["id"],
|
||||||
|
"service_type": row["service_type"],
|
||||||
|
"name": row["name"],
|
||||||
|
"config": json.loads(row["config_json"] or "{}"),
|
||||||
|
"secrets": secrets_blob,
|
||||||
|
"enabled": bool(row["enabled"]),
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
"updated_at": row["updated_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_services(self, service_type: str | None = None) -> list[dict[str, Any]]:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
if service_type:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM services WHERE service_type = ? ORDER BY name ASC",
|
||||||
|
(service_type,),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall()
|
||||||
|
return [self._row_to_service(row) for row in rows]
|
||||||
|
|
||||||
|
def get_service(self, service_id: str) -> dict[str, Any] | None:
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
row = conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone()
|
||||||
|
return self._row_to_service(row) if row else None
|
||||||
|
|
||||||
|
def _normalize_service_payload(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
service_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
current = self.get_service(service_id) if service_id else None
|
||||||
|
service_id = str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||||
|
service_type = str(payload.get("service_type") or (current or {}).get("service_type", "")).strip()
|
||||||
|
name = str(payload.get("name") or (current or {}).get("name", "") or "").strip()
|
||||||
|
config = payload.get("config", (current or {}).get("config", {}))
|
||||||
|
if not isinstance(config, dict):
|
||||||
|
config = {}
|
||||||
|
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
|
||||||
|
return {
|
||||||
|
"id": service_id,
|
||||||
|
"service_type": service_type,
|
||||||
|
"name": name,
|
||||||
|
"config": config,
|
||||||
|
"enabled": enabled,
|
||||||
|
}
|
||||||
|
|
||||||
|
def upsert_service(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
secret_values: dict[str, str] | None = None,
|
||||||
|
service_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Insert or update a service instance.
|
||||||
|
|
||||||
|
``secret_values`` carries plaintext secrets to encrypt and store. A key
|
||||||
|
absent from ``secret_values`` preserves the existing ciphertext; a key
|
||||||
|
mapped to an empty string clears it.
|
||||||
|
"""
|
||||||
|
self.init_schema()
|
||||||
|
service = self._normalize_service_payload(payload, service_id)
|
||||||
|
now = int(time.time())
|
||||||
|
|
||||||
|
existing = self.get_service(service["id"])
|
||||||
|
secrets_blob: dict[str, str]
|
||||||
|
if existing is not None:
|
||||||
|
secrets_blob = dict(existing["secrets"])
|
||||||
|
else:
|
||||||
|
secrets_blob = {}
|
||||||
|
if secret_values:
|
||||||
|
from media_library_viewer_api.services.secrets import encrypt_value
|
||||||
|
|
||||||
|
for key, value in secret_values.items():
|
||||||
|
if value == "":
|
||||||
|
secrets_blob.pop(key, None)
|
||||||
|
else:
|
||||||
|
secrets_blob[key] = encrypt_value(value)
|
||||||
|
|
||||||
|
with self.connect() as conn:
|
||||||
|
created_at = int(existing["created_at"]) if existing else now
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO services (
|
||||||
|
id, service_type, name, config_json, secrets_json,
|
||||||
|
enabled, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET
|
||||||
|
service_type = excluded.service_type,
|
||||||
|
name = excluded.name,
|
||||||
|
config_json = excluded.config_json,
|
||||||
|
secrets_json = excluded.secrets_json,
|
||||||
|
enabled = excluded.enabled,
|
||||||
|
updated_at = excluded.updated_at
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
service["id"],
|
||||||
|
service["service_type"],
|
||||||
|
service["name"],
|
||||||
|
json.dumps(service["config"]),
|
||||||
|
json.dumps(secrets_blob),
|
||||||
|
1 if service["enabled"] else 0,
|
||||||
|
created_at,
|
||||||
|
now,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return self.get_service(service["id"]) or service
|
||||||
|
|
||||||
|
def delete_service(self, service_id: str) -> None:
|
||||||
|
"""Delete a service and cascade-delete widgets referencing it."""
|
||||||
|
self.init_schema()
|
||||||
|
with self.connect() as conn:
|
||||||
|
# The service_id column on dashboard_widgets is added in a later
|
||||||
|
# slice; only cascade when it is present.
|
||||||
|
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||||
|
if "service_id" in widget_cols:
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM dashboard_widgets WHERE service_id = ?",
|
||||||
|
(service_id,),
|
||||||
|
)
|
||||||
|
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
|
||||||
|
|
||||||
|
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Append a service task run history row."""
|
||||||
|
self.init_schema()
|
||||||
|
run_id = str(payload.get("id") or uuid.uuid4().hex[:12])
|
||||||
|
now = int(time.time())
|
||||||
|
with self.connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_task_runs (
|
||||||
|
id, task_id, service_id, status, exit_status, duration_ms,
|
||||||
|
stdout_tail, stderr_tail, error, created_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
run_id,
|
||||||
|
str(payload.get("task_id") or ""),
|
||||||
|
str(payload.get("service_id") or ""),
|
||||||
|
str(payload.get("status") or "error"),
|
||||||
|
payload.get("exit_status"),
|
||||||
|
payload.get("duration_ms"),
|
||||||
|
str(payload.get("stdout_tail") or "")[:8000],
|
||||||
|
str(payload.get("stderr_tail") or "")[:8000],
|
||||||
|
str(payload.get("error") or "")[:1000],
|
||||||
|
int(payload.get("created_at") or now),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return {"id": run_id}
|
||||||
|
|
||||||
|
def list_service_task_runs(
|
||||||
|
self,
|
||||||
|
service_id: str | None = None,
|
||||||
|
task_id: str | None = None,
|
||||||
|
limit: int = 50,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
self.init_schema()
|
||||||
|
clauses: list[str] = []
|
||||||
|
params: list[Any] = []
|
||||||
|
if service_id:
|
||||||
|
clauses.append("service_id = ?")
|
||||||
|
params.append(service_id)
|
||||||
|
if task_id:
|
||||||
|
clauses.append("task_id = ?")
|
||||||
|
params.append(task_id)
|
||||||
|
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
|
||||||
|
params.append(int(limit))
|
||||||
|
with self.connect() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?",
|
||||||
|
params,
|
||||||
|
).fetchall()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"id": row["id"],
|
||||||
|
"task_id": row["task_id"],
|
||||||
|
"service_id": row["service_id"],
|
||||||
|
"status": row["status"],
|
||||||
|
"exit_status": row["exit_status"],
|
||||||
|
"duration_ms": row["duration_ms"],
|
||||||
|
"stdout_tail": row["stdout_tail"],
|
||||||
|
"stderr_tail": row["stderr_tail"],
|
||||||
|
"error": row["error"],
|
||||||
|
"created_at": row["created_at"],
|
||||||
|
}
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
_store: SettingsStore | None = None
|
_store: SettingsStore | None = None
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Built-in, service-less widget kinds.
|
||||||
|
|
||||||
|
These widgets do not talk to an external service and therefore have no
|
||||||
|
``service_id``. They are kept out of the service registry (which models
|
||||||
|
configurable external services) and live here as a small closed set.
|
||||||
|
|
||||||
|
Currently: ``backups`` (reads the internal backup tables) and ``static``
|
||||||
|
(plain text/markdown).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from media_library_viewer_api.integrations.base import WidgetKind
|
||||||
|
|
||||||
|
BUILTIN_WIDGET_KINDS: dict[str, WidgetKind] = {
|
||||||
|
"backups": WidgetKind(
|
||||||
|
kind="backups",
|
||||||
|
name="Backups",
|
||||||
|
description="Backup job summary and active alerts.",
|
||||||
|
config_schema={"type": "object", "properties": {}, "required": []},
|
||||||
|
default_config={},
|
||||||
|
refresh_interval_ms=60_000,
|
||||||
|
),
|
||||||
|
"static": WidgetKind(
|
||||||
|
kind="static",
|
||||||
|
name="Static text",
|
||||||
|
description="Plain text or markdown note.",
|
||||||
|
config_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"text": {"type": "string", "description": "Text or markdown content"}},
|
||||||
|
"required": ["text"],
|
||||||
|
},
|
||||||
|
default_config={"text": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_builtin_widget_kind(kind: str) -> WidgetKind | None:
|
||||||
|
return BUILTIN_WIDGET_KINDS.get(kind)
|
||||||
|
|
||||||
|
|
||||||
|
def is_builtin_kind(kind: str) -> bool:
|
||||||
|
return kind in BUILTIN_WIDGET_KINDS
|
||||||
|
|
||||||
|
|
||||||
|
def builtin_widget_kind_models() -> dict[str, type]:
|
||||||
|
"""Pydantic widget-config models for built-in kinds (validated manually).
|
||||||
|
|
||||||
|
Backups has no user fields; static validates ``text``.
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class StaticConfig(BaseModel):
|
||||||
|
text: str = Field(default="")
|
||||||
|
|
||||||
|
return {"static": StaticConfig}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_builtin_config(kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
"""Validate (lightly) a built-in widget config and return the cleaned dict."""
|
||||||
|
models = builtin_widget_kind_models()
|
||||||
|
model_cls = models.get(kind)
|
||||||
|
if model_cls is None:
|
||||||
|
return dict(config or {})
|
||||||
|
return model_cls.model_validate(config or {}).model_dump(exclude_none=True)
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
"""Closed, compile-time widget registry.
|
|
||||||
|
|
||||||
New widget types and source adapters require a code change in Phase 1.
|
|
||||||
There is no runtime plugin loading.
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from media_library_viewer_api.models.widgets import WidgetTypeInfo
|
|
||||||
|
|
||||||
WIDGET_REGISTRY: dict[str, dict[str, Any]] = {
|
|
||||||
"jellyfin": {
|
|
||||||
"addon_id": "core",
|
|
||||||
"name": "Jellyfin activity",
|
|
||||||
"description": "Live sessions and idle users from a Jellyfin server.",
|
|
||||||
"source_type": "jellyfin",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"machine_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Jellyfin machine id (empty = default)",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["machine_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"backups": {
|
|
||||||
"addon_id": "backups",
|
|
||||||
"name": "Backups",
|
|
||||||
"description": "Backup job summary and active alerts.",
|
|
||||||
"source_type": "backups",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"grafana-link": {
|
|
||||||
"addon_id": "grafana",
|
|
||||||
"name": "Grafana link",
|
|
||||||
"description": "Deep-link to a Grafana dashboard or panel.",
|
|
||||||
"source_type": "grafana",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"dashboard_uid": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Grafana dashboard UID",
|
|
||||||
},
|
|
||||||
"panel_id": {
|
|
||||||
"type": "integer",
|
|
||||||
"description": "Optional panel id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["dashboard_uid"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"prometheus-metric": {
|
|
||||||
"addon_id": "prometheus",
|
|
||||||
"name": "Prometheus metric",
|
|
||||||
"description": "Instant query result rendered as a metric.",
|
|
||||||
"source_type": "prometheus",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"promql": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "PromQL instant query",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["promql"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"ssh-task": {
|
|
||||||
"addon_id": "ssh-tasks",
|
|
||||||
"name": "SSH task output",
|
|
||||||
"description": "Output of a saved task run on a machine.",
|
|
||||||
"source_type": "ssh_task",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"task_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Saved task id",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["task_id"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"static": {
|
|
||||||
"addon_id": "core",
|
|
||||||
"name": "Static text",
|
|
||||||
"description": "Plain text or markdown note.",
|
|
||||||
"source_type": "static",
|
|
||||||
"config_schema": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"text": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Text or markdown content",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"required": ["text"],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def list_source_types() -> list[str]:
|
|
||||||
"""Return all registered source type names."""
|
|
||||||
return sorted({info["source_type"] for info in WIDGET_REGISTRY.values()})
|
|
||||||
|
|
||||||
|
|
||||||
def list_widget_types() -> list[WidgetTypeInfo]:
|
|
||||||
"""Return metadata for all registered widget types."""
|
|
||||||
return [
|
|
||||||
WidgetTypeInfo(
|
|
||||||
addon_id=info["addon_id"],
|
|
||||||
widget_type=widget_type,
|
|
||||||
name=info["name"],
|
|
||||||
description=info["description"],
|
|
||||||
source_type=info["source_type"],
|
|
||||||
config_schema=info["config_schema"],
|
|
||||||
)
|
|
||||||
for widget_type, info in WIDGET_REGISTRY.items()
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
def get_widget_info(widget_type: str) -> WidgetTypeInfo | None:
|
|
||||||
"""Return metadata for a single widget type, or None if unknown."""
|
|
||||||
info = WIDGET_REGISTRY.get(widget_type)
|
|
||||||
if not info:
|
|
||||||
return None
|
|
||||||
return WidgetTypeInfo(
|
|
||||||
addon_id=info["addon_id"],
|
|
||||||
widget_type=widget_type,
|
|
||||||
name=info["name"],
|
|
||||||
description=info["description"],
|
|
||||||
source_type=info["source_type"],
|
|
||||||
config_schema=info["config_schema"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _validate_type(value: Any, expected: str) -> bool:
|
|
||||||
if expected == "string":
|
|
||||||
return isinstance(value, str)
|
|
||||||
if expected == "integer":
|
|
||||||
return isinstance(value, int) and not isinstance(value, bool)
|
|
||||||
if expected == "boolean":
|
|
||||||
return isinstance(value, bool)
|
|
||||||
if expected == "number":
|
|
||||||
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
||||||
if expected == "object":
|
|
||||||
return isinstance(value, dict)
|
|
||||||
if expected == "array":
|
|
||||||
return isinstance(value, list)
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def validate_config(widget_type: str, config: dict[str, Any]) -> None:
|
|
||||||
"""Validate a widget config against its registered JSON schema.
|
|
||||||
|
|
||||||
Raises ValueError with a descriptive message if validation fails.
|
|
||||||
Phase 1 supports only required-field and primitive-type checks.
|
|
||||||
"""
|
|
||||||
info = WIDGET_REGISTRY.get(widget_type)
|
|
||||||
if not info:
|
|
||||||
raise ValueError(f"Unknown widget type: {widget_type}")
|
|
||||||
|
|
||||||
schema = info["config_schema"]
|
|
||||||
required = schema.get("required", [])
|
|
||||||
properties = schema.get("properties", {})
|
|
||||||
|
|
||||||
for key in required:
|
|
||||||
if key not in config:
|
|
||||||
raise ValueError(f"Missing required config field: {key}")
|
|
||||||
|
|
||||||
for key, value in config.items():
|
|
||||||
prop = properties.get(key)
|
|
||||||
if not prop:
|
|
||||||
# Unknown keys are allowed in Phase 1 unless they look like secrets
|
|
||||||
# (handled by the model validator). Skip type checks for unknowns.
|
|
||||||
continue
|
|
||||||
expected_type = prop.get("type")
|
|
||||||
if expected_type and not _validate_type(value, expected_type):
|
|
||||||
raise ValueError(f"Config field '{key}' must be of type {expected_type}")
|
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""Widget source adapters.
|
"""Widget source adapters.
|
||||||
|
|
||||||
Each adapter implements a uniform async interface and translates widget
|
Adapters translate a widget instance into dashboard data. Service-bound widgets
|
||||||
configuration into data for the dashboard. Adapters reuse existing clients,
|
are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
|
||||||
machine registries, and environment settings; they never accept arbitrary
|
built-in widgets (backups, static) take ``service=None``.
|
||||||
commands or store credentials.
|
|
||||||
|
Adapters never accept arbitrary commands and never store credentials — secrets
|
||||||
|
are decrypted in memory only for the duration of a fetch.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -11,94 +13,103 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import shlex
|
import shlex
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from starlette.requests import Request
|
|
||||||
|
|
||||||
|
from media_library_viewer_api.clients.jellyfin import JellyfinClient
|
||||||
|
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||||
from media_library_viewer_api.config import get_settings
|
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 (
|
from media_library_viewer_api.domain.dashboard import (
|
||||||
_map_sessions_to_activity_rows,
|
_map_sessions_to_activity_rows,
|
||||||
build_backup_dashboard_summary,
|
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 SettingsStore, get_settings_store
|
||||||
from media_library_viewer_api.services.settings_store import get_settings_store
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _request_with_machine_id(machine_id: str | None = None) -> Request:
|
@dataclass
|
||||||
"""Build a minimal Starlette Request carrying a machine_id query param."""
|
class ServiceRecord:
|
||||||
query = f"machine_id={machine_id}".encode() if machine_id else b""
|
"""Runtime view of a service instance with decrypted secrets."""
|
||||||
return Request({"type": "http", "query_string": query})
|
|
||||||
|
id: str
|
||||||
|
service_type: str
|
||||||
|
name: str
|
||||||
|
config: dict[str, Any] = field(default_factory=dict)
|
||||||
|
secrets: dict[str, str] = field(default_factory=dict)
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
def build_service_record(store: SettingsStore, service_row: dict[str, Any]) -> ServiceRecord:
|
||||||
|
"""Build a :class:`ServiceRecord`, decrypting secrets in memory."""
|
||||||
|
from media_library_viewer_api.services.secrets import decrypt_secrets
|
||||||
|
|
||||||
|
return ServiceRecord(
|
||||||
|
id=service_row["id"],
|
||||||
|
service_type=service_row["service_type"],
|
||||||
|
name=service_row["name"],
|
||||||
|
config=service_row.get("config") or {},
|
||||||
|
secrets=decrypt_secrets(service_row.get("secrets") or {}),
|
||||||
|
enabled=bool(service_row.get("enabled", True)),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WidgetSource(Protocol):
|
class WidgetSource(Protocol):
|
||||||
"""Protocol for widget source adapters."""
|
"""Protocol for widget source adapters."""
|
||||||
|
|
||||||
source_type: str
|
async def fetch(
|
||||||
|
self,
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
|
service: ServiceRecord | None,
|
||||||
|
widget_kind: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
class JellyfinWidgetSource:
|
# ---------------------------------------------------------------------------
|
||||||
"""Fetch Jellyfin sessions and map them to activity rows."""
|
# Built-in (service-less) adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
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:
|
class BackupsWidgetSource:
|
||||||
"""Compute the backup dashboard summary."""
|
"""Compute the backup dashboard summary from internal tables."""
|
||||||
|
|
||||||
source_type = "backups"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 10
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
summary = build_backup_dashboard_summary(store)
|
summary = build_backup_dashboard_summary(store)
|
||||||
return summary.model_dump()
|
return summary.model_dump()
|
||||||
except asyncio.TimeoutError:
|
|
||||||
return {"error": "Widget data fetch timed out"}
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("backups adapter failed")
|
logger.exception("backups adapter failed")
|
||||||
return {"error": f"Backup summary failed: {exc}"}
|
return {"error": f"Backup summary failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
|
class StaticWidgetSource:
|
||||||
|
"""Return static text/markdown unchanged."""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {"text": config.get("text", "")}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service-bound adapters
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class GrafanaWidgetSource:
|
class GrafanaWidgetSource:
|
||||||
"""Build a Grafana deep-link (no embedding)."""
|
"""Build a Grafana deep-link (no embedding)."""
|
||||||
|
|
||||||
source_type = "grafana"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 5
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
if service is None:
|
||||||
|
return {"error": "Grafana widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
dashboard_uid = config.get("dashboard_uid")
|
dashboard_uid = config.get("dashboard_uid")
|
||||||
if not dashboard_uid:
|
if not dashboard_uid:
|
||||||
return {"error": "dashboard_uid is required"}
|
return {"error": "dashboard_uid is required"}
|
||||||
url = f"{settings.grafana_url.rstrip('/')}/d/{dashboard_uid}"
|
url = f"{base_url}/d/{dashboard_uid}"
|
||||||
panel_id = config.get("panel_id")
|
panel_id = config.get("panel_id")
|
||||||
if panel_id is not None:
|
if panel_id is not None:
|
||||||
url = f"{url}?viewPanel={panel_id}"
|
url = f"{url}?viewPanel={panel_id}"
|
||||||
@@ -109,26 +120,26 @@ class GrafanaWidgetSource:
|
|||||||
|
|
||||||
|
|
||||||
class PrometheusWidgetSource:
|
class PrometheusWidgetSource:
|
||||||
"""Run a PromQL instant query against Prometheus."""
|
"""Run a PromQL instant query against a Prometheus service."""
|
||||||
|
|
||||||
source_type = "prometheus"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 10
|
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
settings = get_settings()
|
if service is None:
|
||||||
|
return {"error": "Prometheus widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "").rstrip("/")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||||
promql = config.get("promql")
|
promql = config.get("promql")
|
||||||
if not promql:
|
if not promql:
|
||||||
return {"error": "promql is required"}
|
return {"error": "promql is required"}
|
||||||
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
|
url = f"{base_url}/api/v1/query"
|
||||||
response = await asyncio.wait_for(
|
response = await asyncio.wait_for(
|
||||||
asyncio.to_thread(
|
asyncio.to_thread(
|
||||||
requests.get,
|
requests.get,
|
||||||
url,
|
url,
|
||||||
params={"query": promql},
|
params={"query": promql},
|
||||||
timeout=self.timeout,
|
timeout=timeout,
|
||||||
),
|
),
|
||||||
timeout=self.timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
payload = response.json()
|
payload = response.json()
|
||||||
@@ -143,16 +154,44 @@ class PrometheusWidgetSource:
|
|||||||
return {"error": f"Prometheus query failed: {exc}"}
|
return {"error": f"Prometheus query failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
class SshTaskWidgetSource:
|
class JellyfinWidgetSource:
|
||||||
"""Run a saved task from the registry and return its output."""
|
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||||
|
|
||||||
source_type = "ssh_task"
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
timeout = 30
|
timeout = 10
|
||||||
|
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
|
||||||
try:
|
try:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "Jellyfin widget is missing its service"}
|
||||||
|
base_url = str(service.config.get("base_url") or "")
|
||||||
|
api_key = str(service.secrets.get("api_key") or "")
|
||||||
|
timeout = int(service.config.get("timeout_seconds") or 10)
|
||||||
|
client = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
|
sessions = await asyncio.wait_for(
|
||||||
|
asyncio.to_thread(client.sessions),
|
||||||
|
timeout=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 SshTaskWidgetSource:
|
||||||
|
"""Run a saved task on an SSH task runner instance and log the run."""
|
||||||
|
|
||||||
|
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
timeout = 30
|
||||||
|
try:
|
||||||
|
if service is None:
|
||||||
|
return {"error": "SSH task widget is missing its service"}
|
||||||
store = get_settings_store()
|
store = get_settings_store()
|
||||||
task_id = config.get("task_id")
|
task_id = config.get("task_id") or ""
|
||||||
if not task_id:
|
if not task_id:
|
||||||
return {"error": "task_id is required"}
|
return {"error": "task_id is required"}
|
||||||
task = store.get_task(task_id)
|
task = store.get_task(task_id)
|
||||||
@@ -161,11 +200,8 @@ class SshTaskWidgetSource:
|
|||||||
if not task.get("enabled", True):
|
if not task.get("enabled", True):
|
||||||
return {"error": "Task is disabled"}
|
return {"error": "Task is disabled"}
|
||||||
|
|
||||||
machine = _resolve_machine_for_task(store, task, None)
|
client = _build_ssh_client(store, service)
|
||||||
if not machine:
|
timeout = int(service.config.get("timeout_seconds") or 30)
|
||||||
return {"error": "No machine available for this task"}
|
|
||||||
|
|
||||||
client = _client_for_machine(store, machine)
|
|
||||||
task_type = str(task.get("task_type") or "shell").lower()
|
task_type = str(task.get("task_type") or "shell").lower()
|
||||||
command = str(task.get("content") or "")
|
command = str(task.get("content") or "")
|
||||||
if task_type == "python":
|
if task_type == "python":
|
||||||
@@ -173,41 +209,112 @@ class SshTaskWidgetSource:
|
|||||||
elif task_type != "shell":
|
elif task_type != "shell":
|
||||||
return {"error": f"Unknown task type: {task_type}"}
|
return {"error": f"Unknown task type: {task_type}"}
|
||||||
|
|
||||||
|
start = time.perf_counter()
|
||||||
result = await asyncio.wait_for(
|
result = await asyncio.wait_for(
|
||||||
asyncio.to_thread(client.run, command, timeout=self.timeout),
|
asyncio.to_thread(client.run, command, timeout),
|
||||||
timeout=self.timeout,
|
timeout=timeout,
|
||||||
)
|
)
|
||||||
return {
|
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||||
"exit_status": result.exit_status,
|
stdout = result.stdout or ""
|
||||||
"stdout": result.stdout or "",
|
stderr = result.stderr or ""
|
||||||
"stderr": result.stderr or "",
|
store.record_service_task_run(
|
||||||
}
|
{
|
||||||
|
"task_id": task_id,
|
||||||
|
"service_id": service.id,
|
||||||
|
"status": "success" if result.exit_status == 0 else "failure",
|
||||||
|
"exit_status": result.exit_status,
|
||||||
|
"duration_ms": duration_ms,
|
||||||
|
"stdout_tail": stdout,
|
||||||
|
"stderr_tail": stderr,
|
||||||
|
"error": "" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"exit_status": result.exit_status, "stdout": stdout, "stderr": stderr}
|
||||||
except asyncio.TimeoutError:
|
except asyncio.TimeoutError:
|
||||||
|
_record_timeout(service, config, timeout)
|
||||||
return {"error": "Widget data fetch timed out"}
|
return {"error": "Widget data fetch timed out"}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("ssh_task adapter failed")
|
logger.exception("ssh_task adapter failed")
|
||||||
|
store = get_settings_store()
|
||||||
|
store.record_service_task_run(
|
||||||
|
{
|
||||||
|
"task_id": str(config.get("task_id") or ""),
|
||||||
|
"service_id": service.id if service else "",
|
||||||
|
"status": "error",
|
||||||
|
"duration_ms": 0,
|
||||||
|
"error": str(exc)[:1000],
|
||||||
|
}
|
||||||
|
)
|
||||||
return {"error": f"SSH task failed: {exc}"}
|
return {"error": f"SSH task failed: {exc}"}
|
||||||
|
|
||||||
|
|
||||||
class StaticWidgetSource:
|
def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
|
||||||
"""Return static text/markdown unchanged."""
|
try:
|
||||||
|
store = get_settings_store()
|
||||||
source_type = "static"
|
store.record_service_task_run(
|
||||||
|
{
|
||||||
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
|
"task_id": str(config.get("task_id") or ""),
|
||||||
return {"text": config.get("text", "")}
|
"service_id": service.id if service else "",
|
||||||
|
"status": "timeout",
|
||||||
|
"duration_ms": timeout * 1000,
|
||||||
|
"error": f"Task timed out after {timeout}s",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - logging best-effort
|
||||||
|
logger.exception("failed to record ssh task timeout")
|
||||||
|
|
||||||
|
|
||||||
SOURCE_REGISTRY: dict[str, WidgetSource] = {
|
def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient:
|
||||||
"jellyfin": JellyfinWidgetSource(),
|
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
|
||||||
"backups": BackupsWidgetSource(),
|
config = service.config
|
||||||
|
host = str(config.get("host") or "").strip()
|
||||||
|
username = str(config.get("username") or "").strip()
|
||||||
|
if not host or not username:
|
||||||
|
raise ValueError("SSH task service is missing host or username")
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
|
private_key = ""
|
||||||
|
key_passphrase = ""
|
||||||
|
ssh_key_id = str(config.get("ssh_key_id") or "").strip()
|
||||||
|
if ssh_key_id:
|
||||||
|
ssh_key = store.get_ssh_key(ssh_key_id)
|
||||||
|
if ssh_key:
|
||||||
|
private_key = str(ssh_key.get("private_key") or "")
|
||||||
|
key_passphrase = str(ssh_key.get("passphrase") or "")
|
||||||
|
# Service-level passphrase secret takes precedence.
|
||||||
|
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
|
||||||
|
|
||||||
|
return RemoteSSHClient(
|
||||||
|
host=host,
|
||||||
|
username=username,
|
||||||
|
port=int(config.get("port") or 22),
|
||||||
|
private_key=private_key or None,
|
||||||
|
private_key_passphrase=key_passphrase or None,
|
||||||
|
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||||
|
timeout=int(config.get("timeout_seconds") or 30),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registries
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
"grafana": GrafanaWidgetSource(),
|
"grafana": GrafanaWidgetSource(),
|
||||||
"prometheus": PrometheusWidgetSource(),
|
"prometheus": PrometheusWidgetSource(),
|
||||||
"ssh_task": SshTaskWidgetSource(),
|
"jellyfin": JellyfinWidgetSource(),
|
||||||
|
"ssh_tasks": SshTaskWidgetSource(),
|
||||||
|
}
|
||||||
|
|
||||||
|
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
|
||||||
|
"backups": BackupsWidgetSource(),
|
||||||
"static": StaticWidgetSource(),
|
"static": StaticWidgetSource(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def get_source_adapter(source_type: str) -> WidgetSource | None:
|
def get_service_adapter(service_type: str) -> WidgetSource | None:
|
||||||
"""Return the adapter for a source type, or None if unknown."""
|
return SERVICE_ADAPTERS.get(service_type)
|
||||||
return SOURCE_REGISTRY.get(source_type)
|
|
||||||
|
|
||||||
|
def get_builtin_adapter(kind: str) -> WidgetSource | None:
|
||||||
|
return BUILTIN_ADAPTERS.get(kind)
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
"""Tests for the service registry: definitions, encryption, CRUD, cascade delete."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from media_library_viewer_api.dependencies import get_settings_store
|
||||||
|
from media_library_viewer_api.integrations.registry import (
|
||||||
|
SERVICE_DEFINITIONS,
|
||||||
|
get_service_definition,
|
||||||
|
get_widget_kind,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.main import app
|
||||||
|
from media_library_viewer_api.services.secrets import (
|
||||||
|
EncryptionKeyError,
|
||||||
|
decrypt_secrets,
|
||||||
|
decrypt_value,
|
||||||
|
encrypt_secrets,
|
||||||
|
encrypt_value,
|
||||||
|
get_encryption_key,
|
||||||
|
reset_encryption_key_cache,
|
||||||
|
)
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
|
||||||
|
TEST_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _encryption_key(monkeypatch):
|
||||||
|
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
yield
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path):
|
||||||
|
"""FastAPI test client with a fresh settings store and auth disabled."""
|
||||||
|
store = SettingsStore(tmp_path / "settings.sqlite")
|
||||||
|
store.ensure_defaults()
|
||||||
|
app.dependency_overrides[get_settings_store] = lambda: store
|
||||||
|
auth_settings = SimpleNamespace(auth_enabled=False)
|
||||||
|
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
|
||||||
|
yield TestClient(app)
|
||||||
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Registry
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_contains_five_service_types():
|
||||||
|
assert set(SERVICE_DEFINITIONS) == {
|
||||||
|
"grafana",
|
||||||
|
"prometheus",
|
||||||
|
"jellyfin",
|
||||||
|
"jellyseerr",
|
||||||
|
"nextcloud",
|
||||||
|
"ssh_tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_definitions_declare_widget_kinds():
|
||||||
|
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
|
||||||
|
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
|
||||||
|
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
|
||||||
|
assert get_service_definition("nextcloud").widget_kinds == []
|
||||||
|
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_widget_kind_lookup():
|
||||||
|
assert get_widget_kind("grafana", "link") is not None
|
||||||
|
assert get_widget_kind("grafana", "missing") is None
|
||||||
|
assert get_widget_kind("unknown", "link") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_config_schema_is_json_schema():
|
||||||
|
schema = get_service_definition("grafana").config_schema
|
||||||
|
assert schema["type"] == "object"
|
||||||
|
assert "base_url" in schema["properties"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Encryption
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_round_trip():
|
||||||
|
cipher = encrypt_value("hunter2")
|
||||||
|
assert cipher != "hunter2"
|
||||||
|
assert decrypt_value(cipher) == "hunter2"
|
||||||
|
|
||||||
|
|
||||||
|
def test_encrypt_decrypt_secrets_dict():
|
||||||
|
blob = encrypt_secrets({"api_key": "abc", "token": "xyz"})
|
||||||
|
assert decrypt_secrets(blob) == {"api_key": "abc", "token": "xyz"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_encryption_key_raises(monkeypatch):
|
||||||
|
monkeypatch.delenv("MANAGE_ENCRYPTION_KEY", raising=False)
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
with pytest.raises(EncryptionKeyError):
|
||||||
|
get_encryption_key()
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_decrypt_with_wrong_key_raises(monkeypatch):
|
||||||
|
blob = encrypt_secrets({"api_key": "abc"})
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
with pytest.raises(EncryptionKeyError):
|
||||||
|
decrypt_secrets(blob)
|
||||||
|
reset_encryption_key_cache()
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_ciphertext_raises():
|
||||||
|
with pytest.raises(EncryptionKeyError):
|
||||||
|
decrypt_value("not-a-real-token")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service type metadata endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_service_types(client):
|
||||||
|
response = client.get("/api/services/types")
|
||||||
|
assert response.status_code == 200
|
||||||
|
types = {item["service_type"] for item in response.json()}
|
||||||
|
assert types == {
|
||||||
|
"grafana",
|
||||||
|
"jellyfin",
|
||||||
|
"jellyseerr",
|
||||||
|
"nextcloud",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_tasks",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_type_includes_secret_and_widget_metadata(client):
|
||||||
|
response = client.get("/api/services/types")
|
||||||
|
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
|
||||||
|
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
|
||||||
|
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _grafana_payload(**overrides):
|
||||||
|
payload = {
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Production Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"api_key": "secret-token"},
|
||||||
|
"enabled": True,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_and_list_service(client):
|
||||||
|
response = client.post("/api/services/instances", json=_grafana_payload())
|
||||||
|
assert response.status_code == 201
|
||||||
|
created = response.json()
|
||||||
|
assert created["service_type"] == "grafana"
|
||||||
|
assert created["config"]["base_url"] == "https://grafana.example.com"
|
||||||
|
# Plaintext secrets are never returned.
|
||||||
|
assert "secrets" not in created
|
||||||
|
assert created["secrets_set"] == {"api_key": True}
|
||||||
|
|
||||||
|
response = client.get("/api/services/instances")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_instances_filters_by_type(client):
|
||||||
|
client.post("/api/services/instances", json=_grafana_payload())
|
||||||
|
client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={
|
||||||
|
"service_type": "prometheus",
|
||||||
|
"name": "Prom",
|
||||||
|
"config": {"base_url": "http://prometheus:9090"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
response = client.get("/api/services/instances?service_type=grafana")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert len(response.json()) == 1
|
||||||
|
assert response.json()[0]["service_type"] == "grafana"
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_service_preserves_unsent_secrets(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
# Update without sending secrets; the existing key should remain set.
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/services/instances/{created['id']}",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Renamed Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert updated["name"] == "Renamed Grafana"
|
||||||
|
assert updated["secrets_set"] == {"api_key": True}
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_service_can_clear_secret(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/services/instances/{created['id']}",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "Production Grafana",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"api_key": ""},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
assert updated["secrets_set"] == {"api_key": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_service_type_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "bogus", "name": "x", "config": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_config_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
|
||||||
|
)
|
||||||
|
# Pydantic accepts empty string; force a real validation error via bad type.
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_secret_field_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "x",
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"secrets": {"password": "leak"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_key_in_config_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": "x",
|
||||||
|
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nonexistent_returns_404(client):
|
||||||
|
response = client.put(
|
||||||
|
"/api/services/instances/missing",
|
||||||
|
json=_grafana_payload(id="missing"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_id_mismatch_returns_400(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
response = client.put(
|
||||||
|
f"/api/services/instances/{created['id']}",
|
||||||
|
json=_grafana_payload(id="other-id"),
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_service(client):
|
||||||
|
created = client.post("/api/services/instances", json=_grafana_payload()).json()
|
||||||
|
response = client.delete(f"/api/services/instances/{created['id']}")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert client.get("/api/services/instances").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_nonexistent_returns_404(client):
|
||||||
|
assert client.delete("/api/services/instances/missing").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Cascade delete
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_service_cascades_to_widgets(client, tmp_path):
|
||||||
|
"""Once widgets carry service_id (Slice 2), deleting a service removes them.
|
||||||
|
|
||||||
|
This test seeds a widget row directly with the column present to prove the
|
||||||
|
cascade path; the column is added defensively here so the test is meaningful
|
||||||
|
even before Slice 2 lands.
|
||||||
|
"""
|
||||||
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
|
service = store.upsert_service(
|
||||||
|
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Ensure the service_id column exists and seed a referencing widget.
|
||||||
|
with store.connect() as conn:
|
||||||
|
cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
|
||||||
|
if "service_id" not in cols:
|
||||||
|
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO dashboard_widgets (id, addon_id, widget_type, title, config_json,
|
||||||
|
enabled, sort_order, created_at, updated_at, service_id)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
store.delete_service(service["id"])
|
||||||
|
assert store.get_service(service["id"]) is None
|
||||||
|
with store.connect() as conn:
|
||||||
|
remaining = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
|
||||||
|
(service["id"],),
|
||||||
|
).fetchone()
|
||||||
|
assert int(remaining[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service task run history
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_and_list_service_task_runs(client):
|
||||||
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
|
service = store.upsert_service(
|
||||||
|
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h"}, "enabled": True}
|
||||||
|
)
|
||||||
|
store.record_service_task_run(
|
||||||
|
{
|
||||||
|
"task_id": "t1",
|
||||||
|
"service_id": service["id"],
|
||||||
|
"status": "success",
|
||||||
|
"exit_status": 0,
|
||||||
|
"stdout_tail": "ok",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
runs = store.list_service_task_runs(service_id=service["id"])
|
||||||
|
assert len(runs) == 1
|
||||||
|
assert runs[0]["status"] == "success"
|
||||||
|
assert runs[0]["stdout_tail"] == "ok"
|
||||||
+256
-344
@@ -1,22 +1,32 @@
|
|||||||
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters."""
|
"""Tests for the dashboard widget system: service-bound + built-in widgets."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
import asyncio
|
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
from cryptography.fernet import Fernet
|
||||||
from fastapi.testclient import TestClient
|
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 (
|
from media_library_viewer_api.widgets.sources import (
|
||||||
SOURCE_REGISTRY,
|
BackupsWidgetSource,
|
||||||
GrafanaWidgetSource,
|
GrafanaWidgetSource,
|
||||||
SshTaskWidgetSource,
|
ServiceRecord,
|
||||||
StaticWidgetSource,
|
StaticWidgetSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
TEST_KEY = Fernet.generate_key().decode()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _encryption_key(monkeypatch):
|
||||||
|
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def client(tmp_path):
|
def client(tmp_path):
|
||||||
@@ -30,446 +40,348 @@ def client(tmp_path):
|
|||||||
app.dependency_overrides.clear()
|
app.dependency_overrides.clear()
|
||||||
|
|
||||||
|
|
||||||
def test_widget_sources(client):
|
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||||
response = client.get("/api/widgets/sources")
|
config = {"base_url": "https://grafana.example.com"}
|
||||||
|
config.update(config_overrides)
|
||||||
|
return client.post(
|
||||||
|
"/api/services/instances",
|
||||||
|
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
|
||||||
|
).json()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Built-in kinds + built-in widget CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_builtin_kinds(client):
|
||||||
|
response = client.get("/api/widgets/builtin")
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert set(response.json()) == {
|
kinds = {item["kind"] for item in response.json()}
|
||||||
"jellyfin",
|
assert kinds == {"backups", "static"}
|
||||||
"backups",
|
|
||||||
"grafana",
|
|
||||||
"prometheus",
|
|
||||||
"ssh_task",
|
|
||||||
"static",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_widget_types(client):
|
def test_create_and_read_static_widget(client):
|
||||||
response = client.get("/api/widgets/types")
|
|
||||||
assert response.status_code == 200
|
|
||||||
types = {item["widget_type"] for item in response.json()}
|
|
||||||
assert types == {
|
|
||||||
"jellyfin",
|
|
||||||
"backups",
|
|
||||||
"grafana-link",
|
|
||||||
"prometheus-metric",
|
|
||||||
"ssh-task",
|
|
||||||
"static",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_and_read_widget(client):
|
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"widget_kind": "static",
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Note",
|
"title": "Note",
|
||||||
"config": {"text": "hello"},
|
"config": {"text": "hello"},
|
||||||
"enabled": True,
|
|
||||||
"sort_order": 5,
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 201
|
assert response.status_code == 201
|
||||||
widget = response.json()
|
created = response.json()
|
||||||
assert widget["title"] == "Note"
|
assert created["widget_kind"] == "static"
|
||||||
assert widget["config"] == {"text": "hello"}
|
assert created["service_id"] is None
|
||||||
assert widget["enabled"] is True
|
assert created["config"]["text"] == "hello"
|
||||||
assert widget["sort_order"] == 5
|
|
||||||
widget_id = widget["id"]
|
|
||||||
|
|
||||||
response = client.get("/api/widgets/instances")
|
listed = client.get("/api/widgets/instances").json()
|
||||||
assert response.status_code == 200
|
assert len(listed) == 1
|
||||||
assert any(w["id"] == widget_id for w in response.json())
|
assert listed[0]["id"] == created["id"]
|
||||||
|
|
||||||
|
|
||||||
def test_update_widget(client):
|
def test_create_backups_widget(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "backups", "title": "Backups", "config": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_builtin_kind_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "bogus", "title": "x", "config": {}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_credential_key_in_config_rejected(client):
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
|
||||||
|
)
|
||||||
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Service-bound widget CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_service_bound_widget(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "static",
|
"widget_kind": "link",
|
||||||
"title": "Note",
|
"title": "Dashboard",
|
||||||
"config": {"text": "hello"},
|
"config": {"dashboard_uid": "overview"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
widget_id = response.json()["id"]
|
assert response.status_code == 201
|
||||||
|
created = response.json()
|
||||||
response = client.put(
|
assert created["service_id"] == service["id"]
|
||||||
f"/api/widgets/instances/{widget_id}",
|
assert created["widget_kind"] == "link"
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Updated",
|
|
||||||
"config": {"text": "world"},
|
|
||||||
"enabled": False,
|
|
||||||
"sort_order": 10,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
data = response.json()
|
|
||||||
assert data["title"] == "Updated"
|
|
||||||
assert data["config"] == {"text": "world"}
|
|
||||||
assert data["enabled"] is False
|
|
||||||
assert data["sort_order"] == 10
|
|
||||||
|
|
||||||
|
|
||||||
def test_delete_widget(client):
|
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "static",
|
"widget_kind": "metric",
|
||||||
"title": "To delete",
|
"title": "x",
|
||||||
"config": {"text": "bye"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.delete(f"/api/widgets/instances/{widget_id}")
|
|
||||||
assert response.status_code == 200
|
|
||||||
|
|
||||||
response = client.get("/api/widgets/instances")
|
|
||||||
assert not any(w["id"] == widget_id for w in response.json())
|
|
||||||
|
|
||||||
|
|
||||||
def test_unknown_widget_type_rejected(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/widgets/instances",
|
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "unknown",
|
|
||||||
"title": "Bad",
|
|
||||||
"config": {},
|
"config": {},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_addon_id_mismatch_rejected(client):
|
def test_service_bound_widget_service_not_found_rejected(client):
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "grafana",
|
"service_id": "missing",
|
||||||
"widget_type": "static",
|
"widget_kind": "link",
|
||||||
"title": "Bad",
|
"title": "x",
|
||||||
"config": {"text": "x"},
|
"config": {"dashboard_uid": "u"},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_credential_key_rejected(client):
|
def test_service_bound_widget_invalid_config_rejected(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
response = client.post(
|
response = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "static",
|
"widget_kind": "link",
|
||||||
"title": "Bad",
|
"title": "x",
|
||||||
"config": {"api_key": "secret123"},
|
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# Empty string passes Pydantic; force a real failure with a bad type.
|
||||||
|
response = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "link",
|
||||||
|
"title": "x",
|
||||||
|
"config": {"dashboard_uid": 123},
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert response.status_code == 422
|
assert response.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
def test_update_nonexistent_widget(client):
|
def test_update_and_delete_widget(client):
|
||||||
|
created = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
|
||||||
|
).json()
|
||||||
|
updated = client.put(
|
||||||
|
f"/api/widgets/instances/{created['id']}",
|
||||||
|
json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
|
||||||
|
).json()
|
||||||
|
assert updated["title"] == "Note2"
|
||||||
|
|
||||||
|
assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
|
||||||
|
assert client.get("/api/widgets/instances").json() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_nonexistent_returns_404(client):
|
||||||
response = client.put(
|
response = client.put(
|
||||||
"/api/widgets/instances/does-not-exist",
|
"/api/widgets/instances/missing",
|
||||||
json={
|
json={"widget_kind": "static", "title": "x", "config": {}},
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Bad",
|
|
||||||
"config": {"text": "x"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
def test_delete_nonexistent_widget(client):
|
|
||||||
response = client.delete("/api/widgets/instances/does-not-exist")
|
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_default_widgets_seeded(client):
|
|
||||||
response = client.get("/api/widgets/instances")
|
|
||||||
assert response.status_code == 200
|
|
||||||
widgets = response.json()
|
|
||||||
types = [w["widget_type"] for w in widgets]
|
|
||||||
assert "jellyfin" in types
|
|
||||||
assert "backups" in types
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_reseed_when_widgets_exist(tmp_path):
|
|
||||||
db_path = tmp_path / "settings.sqlite"
|
|
||||||
store = SettingsStore(db_path)
|
|
||||||
store.ensure_defaults()
|
|
||||||
widgets = store.list_widgets()
|
|
||||||
assert len(widgets) == 2
|
|
||||||
|
|
||||||
store.delete_widget(widgets[0]["id"])
|
|
||||||
store.ensure_defaults()
|
|
||||||
|
|
||||||
remaining = store.list_widgets()
|
|
||||||
assert len(remaining) == 1
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_id_mismatch_returns_400(client):
|
def test_update_id_mismatch_returns_400(client):
|
||||||
response = client.post(
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={"widget_kind": "static", "title": "x", "config": {}},
|
||||||
"addon_id": "core",
|
).json()
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Note",
|
|
||||||
"config": {"text": "hello"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.put(
|
response = client.put(
|
||||||
f"/api/widgets/instances/{widget_id}",
|
f"/api/widgets/instances/{created['id']}",
|
||||||
json={
|
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
|
||||||
"id": "different-id",
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Updated",
|
|
||||||
"config": {"text": "world"},
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
def test_empty_title_rejected(client):
|
# ---------------------------------------------------------------------------
|
||||||
response = client.post(
|
# Data endpoint
|
||||||
"/api/widgets/instances",
|
# ---------------------------------------------------------------------------
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "",
|
|
||||||
"config": {"text": "hello"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_config_type_error_rejected(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/widgets/instances",
|
|
||||||
json={
|
|
||||||
"addon_id": "grafana",
|
|
||||||
"widget_type": "grafana-link",
|
|
||||||
"title": "Grafana",
|
|
||||||
"config": {"panel_id": "not-an-integer"},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 422
|
|
||||||
|
|
||||||
|
|
||||||
def test_list_instances_respects_sort_order(client):
|
|
||||||
response = client.get("/api/widgets/instances")
|
|
||||||
assert response.status_code == 200
|
|
||||||
widgets = response.json()
|
|
||||||
orders = [w["sort_order"] for w in widgets]
|
|
||||||
assert orders == sorted(orders)
|
|
||||||
|
|
||||||
|
|
||||||
def test_enabled_round_trip(client):
|
|
||||||
response = client.post(
|
|
||||||
"/api/widgets/instances",
|
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Toggle",
|
|
||||||
"config": {"text": "x"},
|
|
||||||
"enabled": False,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
widget_id = response.json()["id"]
|
|
||||||
|
|
||||||
response = client.put(
|
|
||||||
f"/api/widgets/instances/{widget_id}",
|
|
||||||
json={
|
|
||||||
"addon_id": "core",
|
|
||||||
"widget_type": "static",
|
|
||||||
"title": "Toggle",
|
|
||||||
"config": {"text": "x"},
|
|
||||||
"enabled": True,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json()["enabled"] is True
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_static_widget_data(client):
|
def test_fetch_static_widget_data(client):
|
||||||
response = client.post(
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
|
||||||
"addon_id": "core",
|
).json()
|
||||||
"widget_type": "static",
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
"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
|
assert response.status_code == 200
|
||||||
data = response.json()
|
body = response.json()
|
||||||
assert data["widget_id"] == widget_id
|
assert body["data"]["text"] == "hello"
|
||||||
assert data["widget_type"] == "static"
|
assert body["error"] is None
|
||||||
assert data["data"] == {"text": "hello world"}
|
|
||||||
assert data["error"] is None
|
|
||||||
assert isinstance(data["fetched_at"], int)
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_grafana_widget_data(client):
|
def test_fetch_backups_widget_data(client):
|
||||||
response = client.post(
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={"widget_kind": "backups", "title": "Backups", "config": {}},
|
||||||
"addon_id": "grafana",
|
).json()
|
||||||
"widget_type": "grafana-link",
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
"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
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert "total_jobs" in response.json()["data"]
|
||||||
assert data["widget_type"] == "grafana-link"
|
|
||||||
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_prometheus_widget_data(client):
|
def test_fetch_grafana_link_widget_data(client):
|
||||||
response = client.post(
|
service = _make_grafana_service(client)
|
||||||
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "prometheus",
|
"service_id": service["id"],
|
||||||
"widget_type": "prometheus-metric",
|
"widget_kind": "link",
|
||||||
"title": "CPU",
|
"title": "Dashboard",
|
||||||
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'},
|
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||||
},
|
},
|
||||||
)
|
).json()
|
||||||
widget_id = response.json()["id"]
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
|
|
||||||
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
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||||
assert data["widget_type"] == "prometheus-metric"
|
|
||||||
assert data["data"]["result"]["resultType"] == "scalar"
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_jellyfin_widget_data_error(client):
|
def test_fetch_widget_service_not_found(client):
|
||||||
response = client.post(
|
service = _make_grafana_service(client)
|
||||||
|
created = client.post(
|
||||||
"/api/widgets/instances",
|
"/api/widgets/instances",
|
||||||
json={
|
json={
|
||||||
"addon_id": "core",
|
"service_id": service["id"],
|
||||||
"widget_type": "jellyfin",
|
"widget_kind": "link",
|
||||||
"title": "Activity",
|
"title": "x",
|
||||||
"config": {"machine_id": ""},
|
"config": {"dashboard_uid": "u"},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
# Deleting the service cascade-deletes its widgets, so the widget is gone.
|
||||||
|
client.delete(f"/api/services/instances/{service['id']}")
|
||||||
|
assert client.get("/api/widgets/instances").json() == []
|
||||||
|
assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_widget_service_disabled(client):
|
||||||
|
service = _make_grafana_service(client)
|
||||||
|
created = client.post(
|
||||||
|
"/api/widgets/instances",
|
||||||
|
json={
|
||||||
|
"service_id": service["id"],
|
||||||
|
"widget_kind": "link",
|
||||||
|
"title": "x",
|
||||||
|
"config": {"dashboard_uid": "u"},
|
||||||
|
},
|
||||||
|
).json()
|
||||||
|
client.put(
|
||||||
|
f"/api/services/instances/{service['id']}",
|
||||||
|
json={
|
||||||
|
"service_type": "grafana",
|
||||||
|
"name": service["name"],
|
||||||
|
"config": {"base_url": "https://grafana.example.com"},
|
||||||
|
"enabled": False,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
widget_id = response.json()["id"]
|
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||||
|
|
||||||
response = client.get(f"/api/widgets/instances/{widget_id}/data")
|
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
data = response.json()
|
assert "disabled" in response.json()["error"]
|
||||||
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):
|
def test_fetch_widget_not_found(client):
|
||||||
response = client.get("/api/widgets/instances/does-not-exist/data")
|
assert client.get("/api/widgets/instances/missing/data").status_code == 404
|
||||||
assert response.status_code == 404
|
|
||||||
|
|
||||||
|
|
||||||
def test_fetch_widget_data_unhandled_exception_returns_500(client):
|
# ---------------------------------------------------------------------------
|
||||||
response = client.post(
|
# Adapter unit tests
|
||||||
"/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):
|
@pytest.mark.asyncio
|
||||||
raise RuntimeError("boom")
|
async def test_grafana_adapter_builds_url():
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
|
||||||
|
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
|
||||||
|
assert result["url"] == "http://g:3000/d/ov"
|
||||||
|
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
|
||||||
|
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
|
||||||
|
|
||||||
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_grafana_adapter_missing_service():
|
||||||
|
adapter = GrafanaWidgetSource()
|
||||||
|
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_static_adapter():
|
async def test_static_adapter():
|
||||||
adapter = StaticWidgetSource()
|
adapter = StaticWidgetSource()
|
||||||
result = await adapter.fetch({"text": "hello"})
|
result = await adapter.fetch(None, "static", {"text": "hi"})
|
||||||
assert result == {"text": "hello"}
|
assert result == {"text": "hi"}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_grafana_adapter():
|
async def test_backups_adapter(client):
|
||||||
adapter = GrafanaWidgetSource()
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2})
|
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
|
||||||
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2"
|
adapter = BackupsWidgetSource()
|
||||||
|
result = await adapter.fetch(None, "backups", {})
|
||||||
result = await adapter.fetch({"dashboard_uid": "overview"})
|
assert "total_jobs" in result
|
||||||
assert result["url"] == "http://grafana:3000/d/overview"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ssh_task_adapter_timeout(tmp_path):
|
async def test_ssh_task_adapter_missing_service():
|
||||||
store = SettingsStore(tmp_path / "settings.sqlite")
|
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
|
||||||
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()
|
adapter = SshTaskWidgetSource()
|
||||||
with patch(
|
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
|
||||||
"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 "error" in result
|
||||||
assert "timed out" in result["error"].lower()
|
|
||||||
|
|
||||||
|
|
||||||
def test_source_registry_closed():
|
@pytest.mark.asyncio
|
||||||
assert set(SOURCE_REGISTRY.keys()) == {
|
async def test_ssh_task_adapter_records_history_on_run(client):
|
||||||
"jellyfin",
|
store = app.dependency_overrides[get_settings_store]()
|
||||||
"backups",
|
# Save a task and an ssh_tasks service instance.
|
||||||
"grafana",
|
task = store.upsert_task(
|
||||||
"prometheus",
|
{
|
||||||
"ssh_task",
|
"name": "echo",
|
||||||
"static",
|
"task_type": "shell",
|
||||||
}
|
"content": "echo hi",
|
||||||
|
"enabled": True,
|
||||||
|
"default_machine_id": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = store.upsert_service(
|
||||||
|
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
|
||||||
|
)
|
||||||
|
|
||||||
|
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
|
||||||
|
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
|
||||||
|
|
||||||
|
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
|
||||||
|
|
||||||
|
adapter = SshTaskWidgetSource()
|
||||||
|
service_record = ServiceRecord(
|
||||||
|
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
|
||||||
|
)
|
||||||
|
with (
|
||||||
|
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
|
||||||
|
patch("media_library_viewer_api.widgets.sources._build_ssh_client", return_value=fake_client),
|
||||||
|
):
|
||||||
|
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
|
||||||
|
|
||||||
|
assert result["exit_status"] == 0
|
||||||
|
runs = store.list_service_task_runs(service_id=service["id"])
|
||||||
|
assert len(runs) == 1
|
||||||
|
assert runs[0]["status"] == "success"
|
||||||
|
|||||||
@@ -17,8 +17,7 @@ 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}
|
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
|
||||||
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
|
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
volumes:
|
volumes:
|
||||||
@@ -40,6 +39,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:
|
||||||
|
|||||||
+2
-2
@@ -28,8 +28,7 @@ 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}
|
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
|
||||||
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
|
||||||
@@ -72,6 +71,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,79 @@ 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.
|
||||||
|
|
||||||
|
## Service Registry and Dashboard Widgets
|
||||||
|
|
||||||
|
### Overview
|
||||||
|
|
||||||
|
External services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are
|
||||||
|
configured **in the app** and persisted in the backend SQLite database. Each
|
||||||
|
service instance holds non-secret config plus encrypted secret fields. Dashboard
|
||||||
|
widgets are either **service-bound** (reference a service instance + a widget
|
||||||
|
kind declared by that service) or **built-in / service-less** (backups, static
|
||||||
|
text).
|
||||||
|
|
||||||
|
Service definitions live as Pydantic modules in the backend
|
||||||
|
(`integrations/`); they declare the service config schema, secret fields, and
|
||||||
|
the widget kinds the service provides. There is no runtime plugin loading.
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
|
||||||
|
- **Prometheus** — base URL + optional bearer token; provides a PromQL metric widget.
|
||||||
|
- **Jellyfin** — base URL + API key; provides a live-activity widget.
|
||||||
|
- **Nextcloud** — base URL + app password (no widgets yet).
|
||||||
|
- **SSH task runner** — host/port/username + saved SSH key reference + optional
|
||||||
|
passphrase; provides a task-output widget. Tasks stay in the global saved-task
|
||||||
|
registry; every run is recorded in `service_task_runs` as history.
|
||||||
|
|
||||||
|
Multiple instances per service type are supported. Services are managed from the
|
||||||
|
**Services** page (`/services`) and each instance has a detail page at
|
||||||
|
`/services/:serviceType/:serviceId`.
|
||||||
|
|
||||||
|
### Built-in widgets
|
||||||
|
|
||||||
|
- **Backups** — internal backup job summary and active alerts.
|
||||||
|
- **Static text** — plain text or markdown note.
|
||||||
|
|
||||||
|
These do not reference a service.
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
|
||||||
|
Fernet using a single env-provided `MANAGE_ENCRYPTION_KEY`, which is always
|
||||||
|
required to start the backend.
|
||||||
|
- Widget `config` and service `config` may not contain credential keys or
|
||||||
|
secret-looking values; secrets go in the dedicated secret fields only.
|
||||||
|
- Plaintext secrets are never returned by the API; only `secrets_set` flags are
|
||||||
|
surfaced.
|
||||||
|
- SSH task widgets only run tasks from the saved-task registry; arbitrary
|
||||||
|
commands are not accepted.
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
- `GET /api/services/types` — service definition metadata (config schema,
|
||||||
|
secret fields, widget kinds).
|
||||||
|
- `GET /api/services/instances` — list service instances (no plaintext secrets).
|
||||||
|
- `POST /api/services/instances` — create instance.
|
||||||
|
- `PUT /api/services/instances/{id}` — update instance.
|
||||||
|
- `DELETE /api/services/instances/{id}` — delete instance (cascade-deletes
|
||||||
|
widgets referencing it).
|
||||||
|
- `GET /api/widgets/builtin` — built-in (service-less) widget kinds.
|
||||||
|
- `GET /api/widgets/instances` — list widget instances.
|
||||||
|
- `POST/PUT/DELETE /api/widgets/instances/{id}` — widget CRUD.
|
||||||
|
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
|
||||||
|
|
||||||
|
### Breaking change
|
||||||
|
|
||||||
|
Grafana/Prometheus URLs and credentials moved from environment variables into
|
||||||
|
service records. The legacy `GRAFANA_URL` / `PROMETHEUS_URL` backend settings and
|
||||||
|
the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now required.
|
||||||
|
|
||||||
|
> **Follow-up (not in this change):** machine-level Jellyfin/Jellyseerr app
|
||||||
|
> config still powers the Media/Users/Files pages. Migrating those onto the
|
||||||
|
> service registry (and removing the machine app fields) is a separate change;
|
||||||
|
> see `openspec/changes/service-registry/design.md` §12.5.
|
||||||
|
|
||||||
## 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,8 @@ 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 { ServicePage } from "./pages/ServicePage";
|
||||||
|
import { ServicesPage } from "./pages/ServicesPage";
|
||||||
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";
|
||||||
@@ -55,6 +57,7 @@ import {
|
|||||||
LogOut,
|
LogOut,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Boxes,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
@@ -88,6 +91,7 @@ const navItems = [
|
|||||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||||
{ path: "/users", label: "Users", icon: Users },
|
{ path: "/users", label: "Users", icon: Users },
|
||||||
{ path: "/actions", label: "Actions", icon: Zap },
|
{ path: "/actions", label: "Actions", icon: Zap },
|
||||||
|
{ path: "/services", label: "Services", icon: Boxes },
|
||||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -449,6 +453,11 @@ 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="/services" element={<ServicesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/services/:serviceType/:serviceId"
|
||||||
|
element={<ServicePage />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
@@ -480,6 +489,11 @@ 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="/services" element={<ServicesPage />} />
|
||||||
|
<Route
|
||||||
|
path="/services/:serviceType/:serviceId"
|
||||||
|
element={<ServicePage />}
|
||||||
|
/>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
+24
-22
@@ -134,26 +134,26 @@ async function del<T>(path: string): Promise<T> {
|
|||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dashboard
|
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
||||||
export const fetchCounts = (machineId?: string) =>
|
export const fetchCounts = (jellyfinServiceId?: string) =>
|
||||||
get<MediaCounts>(
|
get<MediaCounts>(
|
||||||
"/api/dashboard/counts",
|
"/api/dashboard/counts",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchLibraries = (machineId?: string) =>
|
export const fetchLibraries = (jellyfinServiceId?: string) =>
|
||||||
get<LibraryCount[]>(
|
get<LibraryCount[]>(
|
||||||
"/api/dashboard/libraries",
|
"/api/dashboard/libraries",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchActivity = (machineId?: string) =>
|
export const fetchActivity = (jellyfinServiceId?: string) =>
|
||||||
get<NowPlayingSession[]>(
|
get<NowPlayingSession[]>(
|
||||||
"/api/dashboard/activity",
|
"/api/dashboard/activity",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const fetchUsers = (machineId?: string) =>
|
export const fetchUsers = (jellyfinServiceId?: string) =>
|
||||||
get<UserDirectoryResponse>(
|
get<UserDirectoryResponse>(
|
||||||
"/api/users",
|
"/api/users",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Backward-compatible alias used by older hooks/components.
|
// Backward-compatible alias used by older hooks/components.
|
||||||
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Media
|
// Media
|
||||||
export const fetchMediaStatus = (machineId?: string) =>
|
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
||||||
get<MediaIndexStatus>(
|
get<MediaIndexStatus>(
|
||||||
"/api/media/status",
|
"/api/media/status",
|
||||||
machineId ? { machine_id: machineId } : undefined,
|
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||||
);
|
);
|
||||||
export const buildMediaIndex = (machineId?: string) =>
|
export const buildMediaIndex = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
machineId
|
jellyfinServiceId
|
||||||
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/build",
|
: "/api/media/build",
|
||||||
);
|
);
|
||||||
export const stopMediaIndexBuild = (machineId?: string) =>
|
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
machineId
|
jellyfinServiceId
|
||||||
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/stop",
|
: "/api/media/stop",
|
||||||
);
|
);
|
||||||
export const forceStopMediaIndexBuild = (machineId?: string) =>
|
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||||
post<MediaIndexActionResponse>(
|
post<MediaIndexActionResponse>(
|
||||||
machineId
|
jellyfinServiceId
|
||||||
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
|
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||||
: "/api/media/force-stop",
|
: "/api/media/force-stop",
|
||||||
);
|
);
|
||||||
export const queryMedia = (params: {
|
export const queryMedia = (params: {
|
||||||
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
|
|||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
machineId?: string;
|
jellyfinServiceId?: string;
|
||||||
}) =>
|
}) =>
|
||||||
get<MediaQueryResponse>("/api/media/query", {
|
get<MediaQueryResponse>("/api/media/query", {
|
||||||
libraries: params.libraries || "",
|
libraries: params.libraries || "",
|
||||||
@@ -336,7 +336,9 @@ export const queryMedia = (params: {
|
|||||||
sort_order: params.sort_order || "Ascending",
|
sort_order: params.sort_order || "Ascending",
|
||||||
limit: String(params.limit || 100),
|
limit: String(params.limit || 100),
|
||||||
offset: String(params.offset || 0),
|
offset: String(params.offset || 0),
|
||||||
...(params.machineId ? { machine_id: params.machineId } : {}),
|
...(params.jellyfinServiceId
|
||||||
|
? { jellyfin_service_id: params.jellyfinServiceId }
|
||||||
|
: {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Files
|
// Files
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const API_BASE = "/api";
|
||||||
|
|
||||||
|
export async function fetchServiceTypes(): Promise<ServiceTypeInfo[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/services/types`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch service types");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchServiceInstances(
|
||||||
|
serviceType?: string,
|
||||||
|
): Promise<ServiceInstance[]> {
|
||||||
|
const query = serviceType
|
||||||
|
? `?service_type=${encodeURIComponent(serviceType)}`
|
||||||
|
: "";
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances${query}`);
|
||||||
|
if (!res.ok) throw new Error("Failed to fetch service instances");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createServiceInstance(
|
||||||
|
input: ServiceInstanceInput,
|
||||||
|
): Promise<ServiceInstance> {
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to create service instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateServiceInstance(
|
||||||
|
input: ServiceInstanceInput,
|
||||||
|
): Promise<ServiceInstance> {
|
||||||
|
if (!input.id) throw new Error("Service ID is required for update");
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances/${input.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to update service instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteServiceInstance(
|
||||||
|
serviceId: string,
|
||||||
|
): Promise<{ status: string }> {
|
||||||
|
const res = await fetch(`${API_BASE}/services/instances/${serviceId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("Failed to delete service instance");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -1,21 +1,17 @@
|
|||||||
import type {
|
import type {
|
||||||
|
BuiltinWidgetKindInfo,
|
||||||
WidgetDataResponse,
|
WidgetDataResponse,
|
||||||
WidgetInstance,
|
WidgetInstance,
|
||||||
WidgetInstanceInput,
|
WidgetInstanceInput,
|
||||||
WidgetTypeInfo,
|
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
const API_BASE = "/api";
|
const API_BASE = "/api";
|
||||||
|
|
||||||
export async function fetchWidgetSources(): Promise<string[]> {
|
export async function fetchBuiltinWidgetKinds(): Promise<
|
||||||
const res = await fetch(`${API_BASE}/widgets/sources`);
|
BuiltinWidgetKindInfo[]
|
||||||
if (!res.ok) throw new Error("Failed to fetch widget sources");
|
> {
|
||||||
return res.json();
|
const res = await fetch(`${API_BASE}/widgets/builtin`);
|
||||||
}
|
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
|
||||||
|
|
||||||
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,478 @@
|
|||||||
|
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,
|
||||||
|
} from "../hooks/useWidgets";
|
||||||
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
|
import { useTasks } from "../hooks/useSettings";
|
||||||
|
import type { WidgetInstance, WidgetInstanceInput } from "../types";
|
||||||
|
import {
|
||||||
|
BUILTIN_WIDGETS,
|
||||||
|
SERVICE_REGISTRY,
|
||||||
|
type ServiceWidgetBinding,
|
||||||
|
} from "../integrations/registry";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Draft {
|
||||||
|
id?: string;
|
||||||
|
serviceId: string | null;
|
||||||
|
widgetKind: string;
|
||||||
|
title: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
sortOrder: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 bindingLabel(serviceId: string | null, widgetKind: string): string {
|
||||||
|
if (serviceId === null)
|
||||||
|
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
|
||||||
|
return widgetKind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function WidgetConfigEditor({
|
||||||
|
binding,
|
||||||
|
isTaskOutput,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
tasks,
|
||||||
|
}: {
|
||||||
|
binding: ServiceWidgetBinding | undefined;
|
||||||
|
isTaskOutput: boolean;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
|
tasks: { id: string; name: string; enabled: boolean }[];
|
||||||
|
}) {
|
||||||
|
// SSH task output gets a dedicated task picker; everything else gets a
|
||||||
|
// generic text field per top-level schema property.
|
||||||
|
if (isTaskOutput) {
|
||||||
|
return (
|
||||||
|
<Field label="Saved task" htmlFor="widget-task-id">
|
||||||
|
<Select
|
||||||
|
value={String(config.task_id ?? "")}
|
||||||
|
onValueChange={(v) => onChange({ ...config, task_id: v })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="widget-task-id">
|
||||||
|
<SelectValue placeholder="Select a task" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{tasks
|
||||||
|
.filter((t) => t.enabled)
|
||||||
|
.map((t) => (
|
||||||
|
<SelectItem key={t.id} value={t.id}>
|
||||||
|
{t.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const properties = binding
|
||||||
|
? Object.entries(
|
||||||
|
(
|
||||||
|
binding.configSchema as
|
||||||
|
| { properties?: Record<string, unknown> }
|
||||||
|
| undefined
|
||||||
|
)?.properties ?? {},
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
if (properties.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{properties.map(([key, schema]) => {
|
||||||
|
const isNumber =
|
||||||
|
(schema as { type?: string }).type === "integer" ||
|
||||||
|
(schema as { type?: string }).type === "number";
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
htmlFor={`widget-cfg-${key}`}
|
||||||
|
helper={(schema as { description?: string }).description}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`widget-cfg-${key}`}
|
||||||
|
type={isNumber ? "number" : "text"}
|
||||||
|
value={String(config[key] ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetConfigDialog({ open, onClose }: Props) {
|
||||||
|
const { data: instances = [] } = useWidgetInstances();
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const { data: tasks = [] } = useTasks();
|
||||||
|
const saveWidget = useSaveWidgetInstance();
|
||||||
|
const deleteWidget = useDeleteWidgetInstance();
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
|
||||||
|
const sortedInstances = useMemo(
|
||||||
|
() =>
|
||||||
|
[...instances].sort(
|
||||||
|
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
|
||||||
|
),
|
||||||
|
[instances],
|
||||||
|
);
|
||||||
|
|
||||||
|
function startAddBuiltIn(kind: string) {
|
||||||
|
const binding = BUILTIN_WIDGETS[kind];
|
||||||
|
setDraft({
|
||||||
|
serviceId: null,
|
||||||
|
widgetKind: kind,
|
||||||
|
title: binding?.name ?? kind,
|
||||||
|
config: { ...(binding?.defaultConfig ?? {}) },
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAddService(serviceId: string, kind: string) {
|
||||||
|
const binding = SERVICE_REGISTRY[
|
||||||
|
services.find((s) => s.id === serviceId)?.service_type ?? ""
|
||||||
|
]?.widgets.find((w) => w.kind === kind);
|
||||||
|
setDraft({
|
||||||
|
serviceId,
|
||||||
|
widgetKind: kind,
|
||||||
|
title: binding?.name ?? kind,
|
||||||
|
config: { ...(binding?.defaultConfig ?? {}) },
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function startEdit(instance: WidgetInstance) {
|
||||||
|
setDraft({
|
||||||
|
id: instance.id,
|
||||||
|
serviceId: instance.service_id,
|
||||||
|
widgetKind: instance.widget_kind,
|
||||||
|
title: instance.title,
|
||||||
|
config: instance.config,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
sortOrder: instance.sort_order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDraft() {
|
||||||
|
if (!draft) return;
|
||||||
|
const input: WidgetInstanceInput = {
|
||||||
|
id: draft.id ?? null,
|
||||||
|
service_id: draft.serviceId,
|
||||||
|
widget_kind: draft.widgetKind,
|
||||||
|
title: draft.title,
|
||||||
|
config: draft.config,
|
||||||
|
enabled: draft.enabled,
|
||||||
|
sort_order: draft.sortOrder,
|
||||||
|
};
|
||||||
|
await saveWidget.mutateAsync(input);
|
||||||
|
reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleEnabled(instance: WidgetInstance) {
|
||||||
|
await saveWidget.mutateAsync({
|
||||||
|
id: instance.id,
|
||||||
|
service_id: instance.service_id,
|
||||||
|
widget_kind: instance.widget_kind,
|
||||||
|
title: instance.title,
|
||||||
|
config: instance.config,
|
||||||
|
enabled: !instance.enabled,
|
||||||
|
sort_order: instance.sort_order,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 draftBinding = draft
|
||||||
|
? draft.serviceId
|
||||||
|
? SERVICE_REGISTRY[
|
||||||
|
services.find((s) => s.id === draft.serviceId)?.service_type ?? ""
|
||||||
|
]?.widgets.find((w) => w.kind === draft.widgetKind)
|
||||||
|
: BUILTIN_WIDGETS[draft.widgetKind]
|
||||||
|
: undefined;
|
||||||
|
const isTaskOutput =
|
||||||
|
draft?.serviceId !== null &&
|
||||||
|
services.find((s) => s.id === draft?.serviceId)?.service_type ===
|
||||||
|
"ssh_tasks";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{draft
|
||||||
|
? draft.id
|
||||||
|
? "Edit widget"
|
||||||
|
: "Add widget"
|
||||||
|
: "Dashboard widgets"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{draft ? (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<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.sortOrder)}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraft({
|
||||||
|
...draft,
|
||||||
|
sortOrder:
|
||||||
|
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>
|
||||||
|
<WidgetConfigEditor
|
||||||
|
binding={draftBinding}
|
||||||
|
isTaskOutput={!!isTaskOutput}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
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 serviceName = instance.service_id
|
||||||
|
? services.find((s) => s.id === instance.service_id)?.name
|
||||||
|
: "Built-in";
|
||||||
|
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">
|
||||||
|
{bindingLabel(
|
||||||
|
instance.service_id,
|
||||||
|
instance.widget_kind,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
{serviceName ? (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{serviceName}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
{!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">
|
||||||
|
{Object.values(BUILTIN_WIDGETS).map((b) => (
|
||||||
|
<Button
|
||||||
|
key={b.kind}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAddBuiltIn(b.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{b.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
{services
|
||||||
|
.filter((s) => s.enabled)
|
||||||
|
.flatMap((s) =>
|
||||||
|
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
|
||||||
|
(w) => (
|
||||||
|
<Button
|
||||||
|
key={`${s.id}:${w.kind}`}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startAddService(s.id, w.kind)}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{w.name} · {s.name}
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Configure services on their service pages to unlock more
|
||||||
|
widgets.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
|
import { resolveWidget } from "../integrations/registry";
|
||||||
|
import type { WidgetInstance } from "../types";
|
||||||
|
import { SectionCard } from "./SectionCard";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WidgetInstanceCard({ widget }: Props) {
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const resolved = resolveWidget(widget, services);
|
||||||
|
|
||||||
|
if (!resolved) {
|
||||||
|
const label = widget.service_id
|
||||||
|
? `Unknown widget: ${widget.widget_kind} (service-bound)`
|
||||||
|
: `Unknown widget: ${widget.widget_kind} (built-in)`;
|
||||||
|
return (
|
||||||
|
<SectionCard title={widget.title}>
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>{label}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const Component = resolved.component;
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
widget={widget}
|
||||||
|
refreshIntervalMs={resolved.refreshIntervalMs}
|
||||||
|
description={resolved.description}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,26 +9,26 @@ import {
|
|||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
import type { DashboardShortcutInput } from "../types";
|
import type { DashboardShortcutInput } from "../types";
|
||||||
|
|
||||||
export function useCounts(machineId?: string) {
|
export function useCounts(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "counts", machineId ?? "default"],
|
queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchCounts(machineId),
|
queryFn: () => fetchCounts(jellyfinServiceId),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useLibraries(machineId?: string) {
|
export function useLibraries(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "libraries", machineId ?? "default"],
|
queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchLibraries(machineId),
|
queryFn: () => fetchLibraries(jellyfinServiceId),
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useActivity(machineId?: string) {
|
export function useActivity(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["dashboard", "activity", machineId ?? "default"],
|
queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchActivity(machineId),
|
queryFn: () => fetchActivity(jellyfinServiceId),
|
||||||
refetchInterval: 15_000,
|
refetchInterval: 15_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,10 @@ import {
|
|||||||
forceStopMediaIndexBuild,
|
forceStopMediaIndexBuild,
|
||||||
} from "../api/client";
|
} from "../api/client";
|
||||||
|
|
||||||
export function useMediaStatus(machineId?: string) {
|
export function useMediaStatus(jellyfinServiceId?: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["media", "status", machineId ?? "default"],
|
queryKey: ["media", "status", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchMediaStatus(machineId),
|
queryFn: () => fetchMediaStatus(jellyfinServiceId),
|
||||||
staleTime: 5_000,
|
staleTime: 5_000,
|
||||||
refetchInterval: (query) =>
|
refetchInterval: (query) =>
|
||||||
query.state.data?.build_running ? 1000 : false,
|
query.state.data?.build_running ? 1000 : false,
|
||||||
@@ -27,7 +27,7 @@ export function useMediaQuery(params: {
|
|||||||
sort_order?: string;
|
sort_order?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
offset?: number;
|
offset?: number;
|
||||||
machineId?: string;
|
jellyfinServiceId?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { enabled = true, ...queryParams } = params;
|
const { enabled = true, ...queryParams } = params;
|
||||||
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
|
|||||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useBuildIndex(machineId?: string) {
|
export function useBuildIndex(jellyfinServiceId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => buildMediaIndex(machineId),
|
mutationFn: () => buildMediaIndex(jellyfinServiceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateMedia(queryClient);
|
invalidateMedia(queryClient);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useStopBuildIndex(machineId?: string) {
|
export function useStopBuildIndex(jellyfinServiceId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => stopMediaIndexBuild(machineId),
|
mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateMedia(queryClient);
|
invalidateMedia(queryClient);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useForceStopBuildIndex(machineId?: string) {
|
export function useForceStopBuildIndex(jellyfinServiceId?: string) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: () => forceStopMediaIndexBuild(machineId),
|
mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
invalidateMedia(queryClient);
|
invalidateMedia(queryClient);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
createServiceInstance,
|
||||||
|
deleteServiceInstance,
|
||||||
|
fetchServiceInstances,
|
||||||
|
fetchServiceTypes,
|
||||||
|
updateServiceInstance,
|
||||||
|
} from "../api/services";
|
||||||
|
import type { ServiceInstanceInput } from "../types";
|
||||||
|
|
||||||
|
export function useServiceTypes() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services", "types"],
|
||||||
|
queryFn: fetchServiceTypes,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useServiceInstances(serviceType?: string) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["services", "instances", serviceType ?? "all"],
|
||||||
|
queryFn: () => fetchServiceInstances(serviceType),
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSaveServiceInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (input: ServiceInstanceInput) =>
|
||||||
|
input.id ? updateServiceInstance(input) : createServiceInstance(input),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteServiceInstance() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (serviceId: string) => deleteServiceInstance(serviceId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { fetchUsers } from "../api/client";
|
import { fetchUsers } from "../api/client";
|
||||||
import type { UserDirectoryResponse } from "../types";
|
import type { UserDirectoryResponse } from "../types";
|
||||||
|
|
||||||
export function useUsers(machineId?: string) {
|
export function useUsers(jellyfinServiceId?: string) {
|
||||||
return useQuery<UserDirectoryResponse>({
|
return useQuery<UserDirectoryResponse>({
|
||||||
queryKey: ["users", machineId ?? "default"],
|
queryKey: ["users", jellyfinServiceId ?? "default"],
|
||||||
queryFn: () => fetchUsers(machineId),
|
queryFn: () => fetchUsers(jellyfinServiceId),
|
||||||
staleTime: 30_000,
|
staleTime: 30_000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,10 +2,9 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import {
|
import {
|
||||||
createWidgetInstance,
|
createWidgetInstance,
|
||||||
deleteWidgetInstance,
|
deleteWidgetInstance,
|
||||||
|
fetchBuiltinWidgetKinds,
|
||||||
fetchWidgetData,
|
fetchWidgetData,
|
||||||
fetchWidgetInstances,
|
fetchWidgetInstances,
|
||||||
fetchWidgetSources,
|
|
||||||
fetchWidgetTypes,
|
|
||||||
updateWidgetInstance,
|
updateWidgetInstance,
|
||||||
} from "../api/widgets";
|
} from "../api/widgets";
|
||||||
import type { WidgetInstanceInput } from "../types";
|
import type { WidgetInstanceInput } from "../types";
|
||||||
@@ -49,16 +48,10 @@ export function useDeleteWidgetInstance() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWidgetSources() {
|
export function useBuiltinWidgetKinds() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ["widgets", "sources"],
|
queryKey: ["widgets", "builtin"],
|
||||||
queryFn: fetchWidgetSources,
|
queryFn: fetchBuiltinWidgetKinds,
|
||||||
});
|
staleTime: 5 * 60 * 1000,
|
||||||
}
|
|
||||||
|
|
||||||
export function useWidgetTypes() {
|
|
||||||
return useQuery({
|
|
||||||
queryKey: ["widgets", "types"],
|
|
||||||
queryFn: fetchWidgetTypes,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
BUILTIN_WIDGETS,
|
||||||
|
SERVICE_REGISTRY,
|
||||||
|
getBuiltinBinding,
|
||||||
|
getServiceBinding,
|
||||||
|
resolveWidget,
|
||||||
|
} from "./registry";
|
||||||
|
import type { ServiceInstance, WidgetInstance } from "../types";
|
||||||
|
|
||||||
|
describe("service registry", () => {
|
||||||
|
it("registers the five backend service types", () => {
|
||||||
|
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
|
||||||
|
"grafana",
|
||||||
|
"jellyfin",
|
||||||
|
"nextcloud",
|
||||||
|
"prometheus",
|
||||||
|
"ssh_tasks",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("binds widget kinds per service", () => {
|
||||||
|
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([
|
||||||
|
"link",
|
||||||
|
]);
|
||||||
|
expect(SERVICE_REGISTRY.ssh_tasks.widgets.map((w) => w.kind)).toEqual([
|
||||||
|
"task_output",
|
||||||
|
]);
|
||||||
|
expect(SERVICE_REGISTRY.nextcloud.widgets).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers the two built-in widget kinds", () => {
|
||||||
|
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a service-bound widget via the services list", () => {
|
||||||
|
const widget: WidgetInstance = {
|
||||||
|
id: "w1",
|
||||||
|
service_id: "s1",
|
||||||
|
widget_kind: "link",
|
||||||
|
title: "Dashboard",
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
const services: ServiceInstance[] = [
|
||||||
|
{
|
||||||
|
id: "s1",
|
||||||
|
service_type: "grafana",
|
||||||
|
name: "Grafana",
|
||||||
|
config: { base_url: "https://grafana.example.com" },
|
||||||
|
secrets_set: { api_key: true },
|
||||||
|
enabled: true,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
const resolved = resolveWidget(widget, services);
|
||||||
|
expect(resolved).toBeDefined();
|
||||||
|
expect(resolved?.refreshIntervalMs).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves a built-in widget without a service", () => {
|
||||||
|
const widget: WidgetInstance = {
|
||||||
|
id: "w2",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "static",
|
||||||
|
title: "Note",
|
||||||
|
config: { text: "hi" },
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
const resolved = resolveWidget(widget, []);
|
||||||
|
expect(resolved).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for an unknown widget kind", () => {
|
||||||
|
const widget: WidgetInstance = {
|
||||||
|
id: "w3",
|
||||||
|
service_id: null,
|
||||||
|
widget_kind: "bogus",
|
||||||
|
title: "x",
|
||||||
|
config: {},
|
||||||
|
enabled: true,
|
||||||
|
sort_order: 0,
|
||||||
|
created_at: 0,
|
||||||
|
updated_at: 0,
|
||||||
|
};
|
||||||
|
expect(resolveWidget(widget, [])).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lookups return undefined for unknown types", () => {
|
||||||
|
expect(getServiceBinding("nope")).toBeUndefined();
|
||||||
|
expect(getBuiltinBinding("nope")).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import type { ComponentType } from "react";
|
||||||
|
import { BackupsWidget } from "../widgets/BackupsWidget";
|
||||||
|
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget";
|
||||||
|
import { JellyfinWidget } from "../widgets/JellyfinWidget";
|
||||||
|
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
|
||||||
|
import { SshTaskWidget } from "../widgets/SshTaskWidget";
|
||||||
|
import { StaticWidget } from "../widgets/StaticWidget";
|
||||||
|
import type {
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
WidgetInstance,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closed frontend registry mirroring the backend service definitions.
|
||||||
|
*
|
||||||
|
* Each service type maps its widget kinds to a presentational component and a
|
||||||
|
* refresh interval. Built-in (service-less) kinds are listed separately.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface WidgetComponentProps {
|
||||||
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceWidgetBinding {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
defaultConfig: Record<string, unknown>;
|
||||||
|
configSchema: Record<string, unknown>;
|
||||||
|
component: ComponentType<WidgetComponentProps>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceBinding {
|
||||||
|
serviceType: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
widgets: ServiceWidgetBinding[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
|
||||||
|
grafana: {
|
||||||
|
serviceType: "grafana",
|
||||||
|
name: "Grafana",
|
||||||
|
description: "Dashboards, metrics, and logs.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "link",
|
||||||
|
name: "Dashboard link",
|
||||||
|
description: "Deep-link to a Grafana dashboard or panel.",
|
||||||
|
refreshIntervalMs: 0,
|
||||||
|
defaultConfig: { dashboard_uid: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
dashboard_uid: { type: "string" },
|
||||||
|
panel_id: { type: "integer" },
|
||||||
|
},
|
||||||
|
required: ["dashboard_uid"],
|
||||||
|
},
|
||||||
|
component: GrafanaLinkWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
prometheus: {
|
||||||
|
serviceType: "prometheus",
|
||||||
|
name: "Prometheus",
|
||||||
|
description: "Metrics storage and PromQL queries.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "metric",
|
||||||
|
name: "Metric",
|
||||||
|
description: "Instant query result rendered as a metric.",
|
||||||
|
refreshIntervalMs: 30_000,
|
||||||
|
defaultConfig: { promql: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { promql: { type: "string" } },
|
||||||
|
required: ["promql"],
|
||||||
|
},
|
||||||
|
component: PrometheusMetricWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
jellyfin: {
|
||||||
|
serviceType: "jellyfin",
|
||||||
|
name: "Jellyfin",
|
||||||
|
description: "Media server with live session activity.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "activity",
|
||||||
|
name: "Activity",
|
||||||
|
description: "Live sessions and idle users.",
|
||||||
|
refreshIntervalMs: 30_000,
|
||||||
|
defaultConfig: {},
|
||||||
|
configSchema: { type: "object", properties: {}, required: [] },
|
||||||
|
component: JellyfinWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
nextcloud: {
|
||||||
|
serviceType: "nextcloud",
|
||||||
|
name: "Nextcloud",
|
||||||
|
description: "Self-hosted files and collaboration.",
|
||||||
|
widgets: [],
|
||||||
|
},
|
||||||
|
ssh_tasks: {
|
||||||
|
serviceType: "ssh_tasks",
|
||||||
|
name: "SSH task runner",
|
||||||
|
description: "Run reusable saved tasks over SSH and keep run history.",
|
||||||
|
widgets: [
|
||||||
|
{
|
||||||
|
kind: "task_output",
|
||||||
|
name: "Task output",
|
||||||
|
description: "Output of a saved task run.",
|
||||||
|
refreshIntervalMs: 0,
|
||||||
|
defaultConfig: { task_id: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { task_id: { type: "string" } },
|
||||||
|
required: ["task_id"],
|
||||||
|
},
|
||||||
|
component: SshTaskWidget,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BUILTIN_WIDGETS: Record<string, ServiceWidgetBinding> = {
|
||||||
|
backups: {
|
||||||
|
kind: "backups",
|
||||||
|
name: "Backups",
|
||||||
|
description: "Backup job summary and active alerts.",
|
||||||
|
refreshIntervalMs: 60_000,
|
||||||
|
defaultConfig: {},
|
||||||
|
configSchema: { type: "object", properties: {}, required: [] },
|
||||||
|
component: BackupsWidget,
|
||||||
|
},
|
||||||
|
static: {
|
||||||
|
kind: "static",
|
||||||
|
name: "Static text",
|
||||||
|
description: "Plain text or markdown note.",
|
||||||
|
refreshIntervalMs: 0,
|
||||||
|
defaultConfig: { text: "" },
|
||||||
|
configSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { text: { type: "string" } },
|
||||||
|
required: ["text"],
|
||||||
|
},
|
||||||
|
component: StaticWidget,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getServiceBinding(
|
||||||
|
serviceType: string,
|
||||||
|
): ServiceBinding | undefined {
|
||||||
|
return SERVICE_REGISTRY[serviceType];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBuiltinBinding(
|
||||||
|
kind: string,
|
||||||
|
): ServiceWidgetBinding | undefined {
|
||||||
|
return BUILTIN_WIDGETS[kind];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResolvedWidget {
|
||||||
|
component: ComponentType<WidgetComponentProps>;
|
||||||
|
description: string;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a widget instance to its component + metadata.
|
||||||
|
*
|
||||||
|
* Service-bound widgets are resolved via the parent service's type (looked up
|
||||||
|
* from the services list); built-in widgets are resolved directly.
|
||||||
|
*/
|
||||||
|
export function resolveWidget(
|
||||||
|
widget: WidgetInstance,
|
||||||
|
services: ServiceInstance[],
|
||||||
|
): ResolvedWidget | undefined {
|
||||||
|
if (widget.service_id) {
|
||||||
|
const service = services.find((s) => s.id === widget.service_id);
|
||||||
|
if (!service) return undefined;
|
||||||
|
const binding = getServiceBinding(service.service_type);
|
||||||
|
const widgetBinding = binding?.widgets.find(
|
||||||
|
(w) => w.kind === widget.widget_kind,
|
||||||
|
);
|
||||||
|
if (!widgetBinding) return undefined;
|
||||||
|
return {
|
||||||
|
component: widgetBinding.component,
|
||||||
|
description: widgetBinding.description,
|
||||||
|
refreshIntervalMs: widgetBinding.refreshIntervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const builtin = getBuiltinBinding(widget.widget_kind);
|
||||||
|
if (!builtin) return undefined;
|
||||||
|
return {
|
||||||
|
component: builtin.component,
|
||||||
|
description: builtin.description,
|
||||||
|
refreshIntervalMs: builtin.refreshIntervalMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge backend type metadata (config_schema, secret_fields) onto bindings. */
|
||||||
|
export function enrichServiceTypes(
|
||||||
|
types: ServiceTypeInfo[],
|
||||||
|
): ServiceTypeInfo[] {
|
||||||
|
return types;
|
||||||
|
}
|
||||||
@@ -1,28 +1,23 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { TabsTrigger } from "@/components/ui/tabs";
|
import { TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { Media } from "./Media";
|
import { Media } from "./Media";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { TabbedCard } from "../components/TabbedCard";
|
import { TabbedCard } from "../components/TabbedCard";
|
||||||
|
|
||||||
function JellyfinLibraryStats() {
|
function JellyfinLibraryStats() {
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const { data: machines = [] } = useMonitoringSettings();
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const jellyfinMachines = useMemo(
|
const selectedServiceId =
|
||||||
() =>
|
searchParams.get("jellyfin_service_id") ||
|
||||||
machines.filter(
|
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
"";
|
||||||
),
|
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||||
[machines],
|
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||||
);
|
|
||||||
const selectedMachineId =
|
|
||||||
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
|
|
||||||
const { data: counts } = useCounts(selectedMachineId || undefined);
|
|
||||||
const { data: libraries } = useLibraries(selectedMachineId || undefined);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard
|
<SectionCard
|
||||||
@@ -30,7 +25,7 @@ function JellyfinLibraryStats() {
|
|||||||
description="Compact Jellyfin summary for the selected machine."
|
description="Compact Jellyfin summary for the selected machine."
|
||||||
action={
|
action={
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{selectedMachineId ? "Selected machine" : "Default machine"}
|
{selectedServiceId ? "Selected service" : "Default service"}
|
||||||
</Badge>
|
</Badge>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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 { WidgetInstanceCard } 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"
|
<WidgetInstanceCard 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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ import {
|
|||||||
} from "../hooks/useMedia";
|
} from "../hooks/useMedia";
|
||||||
import { usePersistentState } from "../hooks/usePersistentState";
|
import { usePersistentState } from "../hooks/usePersistentState";
|
||||||
import type { MediaItem } from "../types";
|
import type { MediaItem } from "../types";
|
||||||
import { useMonitoringSettings } from "../hooks/useSettings";
|
import { useServiceInstances } from "../hooks/useServices";
|
||||||
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
import { useCounts, useLibraries } from "../hooks/useDashboard";
|
||||||
|
|
||||||
function formatDuration(seconds: number | null | undefined): string {
|
function formatDuration(seconds: number | null | undefined): string {
|
||||||
@@ -178,23 +178,18 @@ export function Media() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const isSmall = usePrefersSmallScreen();
|
const isSmall = usePrefersSmallScreen();
|
||||||
const { data: machines } = useMonitoringSettings();
|
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
|
||||||
const jellyfinMachines = useMemo(
|
const selectedServiceId =
|
||||||
() =>
|
searchParams.get("jellyfin_service_id") ||
|
||||||
(machines ?? []).filter(
|
jellyfinServices.find((s) => s.enabled)?.id ||
|
||||||
(machine) => machine.enabled && machine.services.includes("jellyfin"),
|
"";
|
||||||
),
|
const { data: counts } = useCounts(selectedServiceId || undefined);
|
||||||
[machines],
|
const { data: libraries } = useLibraries(selectedServiceId || undefined);
|
||||||
);
|
const { data: status } = useMediaStatus(selectedServiceId || undefined);
|
||||||
const selectedMachineId =
|
const buildIndex = useBuildIndex(selectedServiceId || undefined);
|
||||||
searchParams.get("machine_id") || jellyfinMachines[0]?.id || "";
|
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
|
||||||
const { data: counts } = useCounts(selectedMachineId || undefined);
|
|
||||||
const { data: libraries } = useLibraries(selectedMachineId || undefined);
|
|
||||||
const { data: status } = useMediaStatus(selectedMachineId || undefined);
|
|
||||||
const buildIndex = useBuildIndex(selectedMachineId || undefined);
|
|
||||||
const stopBuildIndex = useStopBuildIndex(selectedMachineId || undefined);
|
|
||||||
const forceStopBuildIndex = useForceStopBuildIndex(
|
const forceStopBuildIndex = useForceStopBuildIndex(
|
||||||
selectedMachineId || undefined,
|
selectedServiceId || undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
|
||||||
@@ -215,17 +210,17 @@ export function Media() {
|
|||||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchParams.get("machine_id") && selectedMachineId) {
|
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(current) => {
|
(current) => {
|
||||||
const next = new URLSearchParams(current);
|
const next = new URLSearchParams(current);
|
||||||
next.set("machine_id", selectedMachineId);
|
next.set("jellyfin_service_id", selectedServiceId);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [searchParams, selectedMachineId, setSearchParams]);
|
}, [searchParams, selectedServiceId, setSearchParams]);
|
||||||
|
|
||||||
const { data: queryResult, isLoading } = useMediaDataQuery({
|
const { data: queryResult, isLoading } = useMediaDataQuery({
|
||||||
types,
|
types,
|
||||||
@@ -235,7 +230,7 @@ export function Media() {
|
|||||||
sort_order: sortOrder,
|
sort_order: sortOrder,
|
||||||
limit: pageSize,
|
limit: pageSize,
|
||||||
offset,
|
offset,
|
||||||
machineId: selectedMachineId || undefined,
|
jellyfinServiceId: selectedServiceId || undefined,
|
||||||
enabled: status?.exists ?? false,
|
enabled: status?.exists ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -323,27 +318,27 @@ export function Media() {
|
|||||||
<div className="flex flex-row flex-wrap items-center gap-2">
|
<div className="flex flex-row flex-wrap items-center gap-2">
|
||||||
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
<h2 className="text-lg font-semibold">Jellyfin</h2>
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<Label htmlFor="media-machine">Machine</Label>
|
<Label htmlFor="media-service">Service</Label>
|
||||||
<Select
|
<Select
|
||||||
value={selectedMachineId}
|
value={selectedServiceId}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(current) => {
|
(current) => {
|
||||||
const next = new URLSearchParams(current);
|
const next = new URLSearchParams(current);
|
||||||
next.set("machine_id", value);
|
next.set("jellyfin_service_id", value);
|
||||||
return next;
|
return next;
|
||||||
},
|
},
|
||||||
{ replace: true },
|
{ replace: true },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
|
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
|
||||||
<SelectValue placeholder="Select a machine" />
|
<SelectValue placeholder="Select a service" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{jellyfinMachines.map((machine) => (
|
{jellyfinServices.map((service) => (
|
||||||
<SelectItem key={machine.id} value={machine.id}>
|
<SelectItem key={service.id} value={service.id}>
|
||||||
{machine.name}
|
{service.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
|
|||||||
@@ -0,0 +1,255 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
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 {
|
||||||
|
useDeleteServiceInstance,
|
||||||
|
useSaveServiceInstance,
|
||||||
|
useServiceInstances,
|
||||||
|
} from "../hooks/useServices";
|
||||||
|
import type { ServiceInstance, ServiceInstanceInput } from "../types";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicePage() {
|
||||||
|
const { serviceType = "", serviceId = "" } = useParams<{
|
||||||
|
serviceType: string;
|
||||||
|
serviceId: string;
|
||||||
|
}>();
|
||||||
|
const { data: services = [] } = useServiceInstances(serviceType || undefined);
|
||||||
|
const saveService = useSaveServiceInstance();
|
||||||
|
const deleteService = useDeleteServiceInstance();
|
||||||
|
|
||||||
|
const instance = useMemo(
|
||||||
|
() => services.find((s) => s.id === serviceId),
|
||||||
|
[services, serviceId],
|
||||||
|
);
|
||||||
|
const binding = getServiceBinding(serviceType);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [enabled, setEnabled] = useState(true);
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [hydrated, setHydrated] = useState(false);
|
||||||
|
|
||||||
|
// Hydrate local form state once the instance loads.
|
||||||
|
if (instance && !hydrated) {
|
||||||
|
setName(instance.name);
|
||||||
|
setEnabled(instance.enabled);
|
||||||
|
setHydrated(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Unknown service type: {serviceType}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!instance) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>Service not found.</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInput(): ServiceInstanceInput {
|
||||||
|
return {
|
||||||
|
id: instance!.id,
|
||||||
|
service_type: instance!.service_type,
|
||||||
|
name,
|
||||||
|
config: instance!.config,
|
||||||
|
secrets: {}, // secrets are managed via the dedicated inputs below
|
||||||
|
enabled,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
await saveService.mutateAsync(buildInput());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold">{instance.name}</h2>
|
||||||
|
<p className="text-sm text-muted-foreground">{binding.description}</p>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{binding.name}</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SectionCard title="General">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<Field label="Name" htmlFor="service-name">
|
||||||
|
<Input
|
||||||
|
id="service-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="service-enabled"
|
||||||
|
checked={enabled}
|
||||||
|
onCheckedChange={setEnabled}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<Button onClick={save} disabled={saveService.isPending}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<ServiceSecretsCard instance={instance} />
|
||||||
|
|
||||||
|
{binding.widgets.length > 0 ? (
|
||||||
|
<SectionCard
|
||||||
|
title="Widgets"
|
||||||
|
description="Widget kinds this service provides."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{binding.widgets.map((w) => (
|
||||||
|
<div
|
||||||
|
key={w.kind}
|
||||||
|
className="flex items-center justify-between rounded border p-2"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{w.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{w.description}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge variant="outline">{w.kind}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Add these to the dashboard from the dashboard's edit dialog.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={deleteOpen}
|
||||||
|
title="Delete service?"
|
||||||
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
deleteService.mutate(instance.id);
|
||||||
|
setDeleteOpen(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
|
||||||
|
const saveService = useSaveServiceInstance();
|
||||||
|
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
|
||||||
|
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SectionCard
|
||||||
|
title="Connection"
|
||||||
|
description="Non-secret config is read-only here for now; edit secret values below."
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(instance.config).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No connection config.</p>
|
||||||
|
) : (
|
||||||
|
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
|
||||||
|
{Object.entries(instance.config).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex flex-col">
|
||||||
|
<dt className="text-xs text-muted-foreground">{key}</dt>
|
||||||
|
<dd className="truncate font-mono text-xs">{String(value)}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(instance.secrets_set).length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">No secret fields.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
|
||||||
|
<div key={key} className="flex flex-col gap-1.5">
|
||||||
|
<Field
|
||||||
|
label={key}
|
||||||
|
htmlFor={`secret-${key}`}
|
||||||
|
helper="Leave blank to keep the current value."
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`secret-${key}`}
|
||||||
|
type="password"
|
||||||
|
placeholder={isSet ? "•••••• (set)" : "Not set"}
|
||||||
|
value={draftSecrets[key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setDraftSecrets({
|
||||||
|
...draftSecrets,
|
||||||
|
[key]: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{isSet ? <Badge variant="secondary">set</Badge> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
const onlyChanged = Object.fromEntries(
|
||||||
|
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
|
||||||
|
);
|
||||||
|
saveService.mutate({
|
||||||
|
id: instance.id,
|
||||||
|
service_type: instance.service_type,
|
||||||
|
name: instance.name,
|
||||||
|
config: instance.config,
|
||||||
|
secrets: onlyChanged,
|
||||||
|
enabled: instance.enabled,
|
||||||
|
});
|
||||||
|
setDraftSecrets({});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Update secrets
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
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 {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { ExternalLink, Plus, Trash2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
useDeleteServiceInstance,
|
||||||
|
useSaveServiceInstance,
|
||||||
|
useServiceInstances,
|
||||||
|
} from "../hooks/useServices";
|
||||||
|
import { useServiceTypes } from "../hooks/useServices";
|
||||||
|
import type {
|
||||||
|
SecretFieldInfo,
|
||||||
|
ServiceInstance,
|
||||||
|
ServiceInstanceInput,
|
||||||
|
ServiceTypeInfo,
|
||||||
|
} from "../types";
|
||||||
|
import { SectionCard } from "../components/SectionCard";
|
||||||
|
import { ConfirmDialog } from "../components/ConfirmDialog";
|
||||||
|
import { DialogFooter } from "../components/DialogFooter";
|
||||||
|
import { getServiceBinding } from "../integrations/registry";
|
||||||
|
|
||||||
|
interface CreateDraft {
|
||||||
|
serviceType: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyDraft(serviceType: string): CreateDraft {
|
||||||
|
return { serviceType, name: "", config: {}, secrets: {}, enabled: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
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 ServiceConfigFields({
|
||||||
|
type,
|
||||||
|
config,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
type: ServiceTypeInfo;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
onChange: (config: Record<string, unknown>) => void;
|
||||||
|
}) {
|
||||||
|
const properties =
|
||||||
|
(
|
||||||
|
type.config_schema as {
|
||||||
|
properties?: Record<string, { type?: string; description?: string }>;
|
||||||
|
}
|
||||||
|
).properties ?? {};
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{Object.entries(properties).map(([key, schema]) => {
|
||||||
|
const isNumber = schema.type === "integer" || schema.type === "number";
|
||||||
|
return (
|
||||||
|
<Field
|
||||||
|
key={key}
|
||||||
|
label={key}
|
||||||
|
htmlFor={`cfg-${key}`}
|
||||||
|
helper={schema.description}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`cfg-${key}`}
|
||||||
|
type={isNumber ? "number" : "text"}
|
||||||
|
value={String(config[key] ?? "")}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({
|
||||||
|
...config,
|
||||||
|
[key]: isNumber
|
||||||
|
? e.target.value === ""
|
||||||
|
? undefined
|
||||||
|
: Number(e.target.value)
|
||||||
|
: e.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceSecretFields({
|
||||||
|
fields,
|
||||||
|
secrets,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
fields: SecretFieldInfo[];
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
onChange: (secrets: Record<string, string>) => void;
|
||||||
|
}) {
|
||||||
|
if (fields.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
{fields.map((field) => (
|
||||||
|
<Field
|
||||||
|
key={field.key}
|
||||||
|
label={field.label}
|
||||||
|
htmlFor={`secret-${field.key}`}
|
||||||
|
helper={field.helper ?? (field.required ? "Required" : undefined)}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id={`secret-${field.key}`}
|
||||||
|
type="password"
|
||||||
|
value={secrets[field.key] ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...secrets, [field.key]: e.target.value })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CreateServiceDialog({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { data: types = [] } = useServiceTypes();
|
||||||
|
const saveService = useSaveServiceInstance();
|
||||||
|
const [draft, setDraft] = useState<CreateDraft | null>(null);
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setDraft(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
if (!draft) return;
|
||||||
|
if (!draft.name.trim()) return;
|
||||||
|
const input: ServiceInstanceInput = {
|
||||||
|
service_type: draft.serviceType,
|
||||||
|
name: draft.name.trim(),
|
||||||
|
config: draft.config,
|
||||||
|
secrets: draft.secrets,
|
||||||
|
enabled: draft.enabled,
|
||||||
|
};
|
||||||
|
await saveService.mutateAsync(input);
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedType = types.find((t) => t.service_type === draft?.serviceType);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!next) {
|
||||||
|
reset();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>New service</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{!draft ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{types.map((t) => (
|
||||||
|
<Button
|
||||||
|
key={t.service_type}
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setDraft(emptyDraft(t.service_type))}
|
||||||
|
>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
{t.name}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{selectedType?.description}
|
||||||
|
</p>
|
||||||
|
<Field label="Name" htmlFor="service-name">
|
||||||
|
<Input
|
||||||
|
id="service-name"
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
{selectedType ? (
|
||||||
|
<ServiceConfigFields
|
||||||
|
type={selectedType}
|
||||||
|
config={draft.config}
|
||||||
|
onChange={(config) => setDraft({ ...draft, config })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{selectedType ? (
|
||||||
|
<ServiceSecretFields
|
||||||
|
fields={selectedType.secret_fields}
|
||||||
|
secrets={draft.secrets}
|
||||||
|
onChange={(secrets) => setDraft({ ...draft, secrets })}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Switch
|
||||||
|
id="service-enabled"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setDraft({ ...draft, enabled: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="service-enabled">Enabled</Label>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{draft ? (
|
||||||
|
<DialogFooter
|
||||||
|
onCancel={reset}
|
||||||
|
onConfirm={save}
|
||||||
|
confirmLabel="Create service"
|
||||||
|
confirmDisabled={!draft.name.trim() || saveService.isPending}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ServicesPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: services = [] } = useServiceInstances();
|
||||||
|
const { data: types = [] } = useServiceTypes();
|
||||||
|
const deleteService = useDeleteServiceInstance();
|
||||||
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const grouped = useMemo(() => {
|
||||||
|
const map = new Map<string, ServiceInstance[]>();
|
||||||
|
for (const s of services) {
|
||||||
|
const list = map.get(s.service_type) ?? [];
|
||||||
|
list.push(s);
|
||||||
|
map.set(s.service_type, list);
|
||||||
|
}
|
||||||
|
return [...map.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
||||||
|
}, [services]);
|
||||||
|
|
||||||
|
const typeName = (t: string) =>
|
||||||
|
types.find((x) => x.service_type === t)?.name ??
|
||||||
|
getServiceBinding(t)?.name ??
|
||||||
|
t;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<SectionCard
|
||||||
|
title="Services"
|
||||||
|
description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest."
|
||||||
|
action={
|
||||||
|
<Button variant="outline" onClick={() => setCreateOpen(true)}>
|
||||||
|
<Plus className="mr-1 h-3 w-3" />
|
||||||
|
Add service
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud,
|
||||||
|
or SSH task runner.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
{grouped.map(([serviceType, instances]) => (
|
||||||
|
<div key={serviceType} className="flex flex-col gap-2">
|
||||||
|
<div className="text-sm font-medium">
|
||||||
|
{typeName(serviceType)}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{instances.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.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">{s.name}</span>
|
||||||
|
<Badge variant="outline">{s.service_type}</Badge>
|
||||||
|
{!s.enabled ? (
|
||||||
|
<Badge variant="secondary">disabled</Badge>
|
||||||
|
) : null}
|
||||||
|
{Object.entries(s.secrets_set).some(([, v]) => v) ? (
|
||||||
|
<Badge variant="outline">secrets set</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/services/${s.service_type}/${s.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Open <ExternalLink className="ml-1 h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive"
|
||||||
|
onClick={() => setDeleteId(s.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</SectionCard>
|
||||||
|
|
||||||
|
<CreateServiceDialog
|
||||||
|
open={createOpen}
|
||||||
|
onClose={() => setCreateOpen(false)}
|
||||||
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={Boolean(deleteId)}
|
||||||
|
title="Delete service?"
|
||||||
|
message="This removes the service and any widgets that reference it. This cannot be undone."
|
||||||
|
confirmLabel="Delete"
|
||||||
|
onCancel={() => setDeleteId(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (deleteId) deleteService.mutate(deleteId);
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,8 +52,6 @@ import { Textarea } from "@/components/ui/textarea";
|
|||||||
const SERVICE_OPTIONS = [
|
const SERVICE_OPTIONS = [
|
||||||
{ value: "monitoring", label: "Monitoring" },
|
{ value: "monitoring", label: "Monitoring" },
|
||||||
{ value: "files", label: "Files" },
|
{ value: "files", label: "Files" },
|
||||||
{ value: "jellyfin", label: "Jellyfin" },
|
|
||||||
{ value: "jellyseerr", label: "Jellyseerr" },
|
|
||||||
{ value: "nextcloud", label: "Nextcloud" },
|
{ value: "nextcloud", label: "Nextcloud" },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -114,7 +112,7 @@ function emptyMachine(
|
|||||||
name: mode === "local" ? "This machine" : "",
|
name: mode === "local" ? "This machine" : "",
|
||||||
mode,
|
mode,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
services: mode === "local" ? ["monitoring", "files", "jellyfin"] : [],
|
services: mode === "local" ? ["monitoring", "files"] : [],
|
||||||
host: "",
|
host: "",
|
||||||
port: 22,
|
port: 22,
|
||||||
username: "",
|
username: "",
|
||||||
@@ -126,11 +124,6 @@ function emptyMachine(
|
|||||||
password: "",
|
password: "",
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key: "",
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key: "",
|
|
||||||
notes: "",
|
notes: "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -169,8 +162,6 @@ function MachineEditor({
|
|||||||
const isLocal = draft.mode === "local";
|
const isLocal = draft.mode === "local";
|
||||||
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
const selectedSSHKey = sshKeys.find((key) => key.id === draft.ssh_key_id);
|
||||||
const enabledServices = draft.services.length;
|
const enabledServices = draft.services.length;
|
||||||
const hasJellyfin = draft.services.includes("jellyfin");
|
|
||||||
const hasJellyseerr = draft.services.includes("jellyseerr");
|
|
||||||
const placeholderIfSet = (isSet: boolean | undefined) =>
|
const placeholderIfSet = (isSet: boolean | undefined) =>
|
||||||
isSet ? "Set, not shown" : undefined;
|
isSet ? "Set, not shown" : undefined;
|
||||||
return (
|
return (
|
||||||
@@ -398,99 +389,6 @@ function MachineEditor({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
</div>
|
</div>
|
||||||
{hasJellyfin && (
|
|
||||||
<>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Jellyfin"
|
|
||||||
description="Library host and user selection for media browsing."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyfin URL">
|
|
||||||
<Input
|
|
||||||
value={draft.jellyfin_url}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyfin_url: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyfin user ID">
|
|
||||||
<Input
|
|
||||||
value={draft.jellyfin_user_id}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyfin_user_id: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyfin API key">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder={placeholderIfSet(
|
|
||||||
editingMachine?.jellyfin_api_key_set,
|
|
||||||
)}
|
|
||||||
value={draft.jellyfin_api_key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyfin_api_key: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{hasJellyseerr && (
|
|
||||||
<>
|
|
||||||
<div className="col-span-12">
|
|
||||||
<SectionLabel
|
|
||||||
title="Jellyseerr"
|
|
||||||
description="Optional request-manager enrichment for users and requests."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyseerr URL">
|
|
||||||
<Input
|
|
||||||
value={draft.jellyseerr_url}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyseerr_url: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-12 md:col-span-6">
|
|
||||||
<FormField label="Jellyseerr API key">
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
placeholder={placeholderIfSet(
|
|
||||||
editingMachine?.jellyseerr_api_key_set,
|
|
||||||
)}
|
|
||||||
value={draft.jellyseerr_api_key}
|
|
||||||
onChange={(e) =>
|
|
||||||
setDraft((current) => ({
|
|
||||||
...current,
|
|
||||||
jellyseerr_api_key: e.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{isLocal && (
|
{isLocal && (
|
||||||
<div className="col-span-12 md:col-span-6">
|
<div className="col-span-12 md:col-span-6">
|
||||||
<FormField label="Local hint">
|
<FormField label="Local hint">
|
||||||
@@ -538,7 +436,7 @@ function MachineEditor({
|
|||||||
</AlertDescription>
|
</AlertDescription>
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
{!isLocal && !hasJellyfin && (
|
{!isLocal && enabledServices === 0 && (
|
||||||
<Alert>
|
<Alert>
|
||||||
<AlertDescription>
|
<AlertDescription>
|
||||||
SSH machines usually need monitoring or files enabled.
|
SSH machines usually need monitoring or files enabled.
|
||||||
@@ -584,13 +482,6 @@ function MachineEditor({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{hasJellyseerr && !draft.jellyseerr_url && (
|
|
||||||
<Alert>
|
|
||||||
<AlertDescription>
|
|
||||||
Jellyseerr is enabled, but no URL is configured yet.
|
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
@@ -1146,11 +1037,6 @@ export function Settings() {
|
|||||||
ssh_private_key_passphrase: "",
|
ssh_private_key_passphrase: "",
|
||||||
password: "",
|
password: "",
|
||||||
media_root: machine.media_root,
|
media_root: machine.media_root,
|
||||||
jellyfin_url: machine.jellyfin_url,
|
|
||||||
jellyfin_user_id: machine.jellyfin_user_id,
|
|
||||||
jellyfin_api_key: "",
|
|
||||||
jellyseerr_url: machine.jellyseerr_url,
|
|
||||||
jellyseerr_api_key: "",
|
|
||||||
notes: machine.notes,
|
notes: machine.notes,
|
||||||
},
|
},
|
||||||
machine,
|
machine,
|
||||||
@@ -1237,12 +1123,6 @@ export function Settings() {
|
|||||||
ssh_private_key_passphrase: "",
|
ssh_private_key_passphrase: "",
|
||||||
password: "",
|
password: "",
|
||||||
media_root: selectedMachine.media_root,
|
media_root: selectedMachine.media_root,
|
||||||
jellyfin_url: selectedMachine.jellyfin_url,
|
|
||||||
jellyfin_user_id:
|
|
||||||
selectedMachine.jellyfin_user_id,
|
|
||||||
jellyfin_api_key: "",
|
|
||||||
jellyseerr_url: selectedMachine.jellyseerr_url,
|
|
||||||
jellyseerr_api_key: "",
|
|
||||||
notes: selectedMachine.notes,
|
notes: selectedMachine.notes,
|
||||||
},
|
},
|
||||||
selectedMachine,
|
selectedMachine,
|
||||||
|
|||||||
@@ -48,11 +48,6 @@ function machine(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "",
|
notes: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
} as MonitoringMachine;
|
} as MonitoringMachine;
|
||||||
|
|||||||
@@ -26,6 +26,14 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useDashboard", () => ({
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
useCounts: () => ({
|
useCounts: () => ({
|
||||||
data: { movies: 10, series: 5, episodes: 100 },
|
data: { movies: 10, series: 5, episodes: 100 },
|
||||||
|
|||||||
@@ -5,12 +5,12 @@ import { Dashboard } from "../Dashboard";
|
|||||||
import type { DashboardShortcut } from "../../types";
|
import type { DashboardShortcut } from "../../types";
|
||||||
|
|
||||||
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
// Stub the composed widgets so the test exercises Dashboard's own behavior
|
||||||
// (shortcut CRUD) without rendering the session panel or the backup query.
|
// (shortcut CRUD) without rendering widgets or their data queries.
|
||||||
vi.mock("../../components/NowPlaying", () => ({
|
vi.mock("../../components/WidgetInstance", () => ({
|
||||||
NowPlaying: () => <div data-testid="now-playing-stub" />,
|
WidgetInstanceCard: () => <div data-testid="widget-stub" />,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../components/BackupDashboardWidget", () => ({
|
vi.mock("../../components/WidgetConfigDialog", () => ({
|
||||||
default: () => <div data-testid="backup-widget-stub" />,
|
WidgetConfigDialog: () => <div data-testid="widget-config-stub" />,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const navigate = vi.fn();
|
const navigate = vi.fn();
|
||||||
@@ -21,6 +21,9 @@ vi.mock("react-router-dom", () => ({
|
|||||||
vi.mock("../../hooks/useSettings", () => ({
|
vi.mock("../../hooks/useSettings", () => ({
|
||||||
useMonitoringSettings: () => ({ data: [] }),
|
useMonitoringSettings: () => ({ data: [] }),
|
||||||
}));
|
}));
|
||||||
|
vi.mock("../../hooks/useWidgets", () => ({
|
||||||
|
useWidgetInstances: () => ({ data: [] }),
|
||||||
|
}));
|
||||||
|
|
||||||
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
const saveShortcutMutate = vi.fn().mockResolvedValue({});
|
||||||
const deleteShortcutMutate = vi.fn();
|
const deleteShortcutMutate = vi.fn();
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ function machineFixture(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "",
|
notes: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -34,11 +34,6 @@ function machineFixture(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "",
|
media_root: "",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "",
|
notes: "",
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
@@ -103,7 +98,10 @@ let queryResult: MediaQueryResponse;
|
|||||||
|
|
||||||
vi.mock("react-router-dom", () => ({
|
vi.mock("react-router-dom", () => ({
|
||||||
useNavigate: () => navigate,
|
useNavigate: () => navigate,
|
||||||
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()],
|
useSearchParams: () => [
|
||||||
|
new URLSearchParams("jellyfin_service_id=jfs1"),
|
||||||
|
vi.fn(),
|
||||||
|
],
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useMedia", () => ({
|
vi.mock("../../hooks/useMedia", () => ({
|
||||||
@@ -118,6 +116,14 @@ vi.mock("../../hooks/useSettings", () => ({
|
|||||||
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
useMonitoringSettings: () => ({ data: [machineFixture()] }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useServices", () => ({
|
||||||
|
useServiceInstances: () => ({
|
||||||
|
data: [
|
||||||
|
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../../hooks/useDashboard", () => ({
|
vi.mock("../../hooks/useDashboard", () => ({
|
||||||
useCounts: () => ({ data: undefined }),
|
useCounts: () => ({ data: undefined }),
|
||||||
useLibraries: () => ({ data: undefined }),
|
useLibraries: () => ({ data: undefined }),
|
||||||
|
|||||||
@@ -53,11 +53,6 @@ function localMachine(
|
|||||||
password_set: false,
|
password_set: false,
|
||||||
media_root: "/mnt/media",
|
media_root: "/mnt/media",
|
||||||
path_prefix: "",
|
path_prefix: "",
|
||||||
jellyfin_url: "",
|
|
||||||
jellyfin_user_id: "",
|
|
||||||
jellyfin_api_key_set: false,
|
|
||||||
jellyseerr_url: "",
|
|
||||||
jellyseerr_api_key_set: false,
|
|
||||||
notes: "Primary node",
|
notes: "Primary node",
|
||||||
...overrides,
|
...overrides,
|
||||||
} as MonitoringMachine;
|
} as MonitoringMachine;
|
||||||
|
|||||||
+58
-24
@@ -175,11 +175,6 @@ export interface MonitoringMachine {
|
|||||||
password_set: boolean;
|
password_set: boolean;
|
||||||
media_root: string;
|
media_root: string;
|
||||||
path_prefix: string;
|
path_prefix: string;
|
||||||
jellyfin_url: string;
|
|
||||||
jellyfin_user_id: string;
|
|
||||||
jellyfin_api_key_set: boolean;
|
|
||||||
jellyseerr_url: string;
|
|
||||||
jellyseerr_api_key_set: boolean;
|
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,11 +195,6 @@ export interface MonitoringMachineInput {
|
|||||||
password: string;
|
password: string;
|
||||||
media_root: string;
|
media_root: string;
|
||||||
path_prefix: string;
|
path_prefix: string;
|
||||||
jellyfin_url: string;
|
|
||||||
jellyfin_user_id: string;
|
|
||||||
jellyfin_api_key: string;
|
|
||||||
jellyseerr_url: string;
|
|
||||||
jellyseerr_api_key: string;
|
|
||||||
notes: string;
|
notes: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,8 +436,8 @@ export interface PrometheusTarget {
|
|||||||
|
|
||||||
export interface WidgetInstance {
|
export interface WidgetInstance {
|
||||||
id: string;
|
id: string;
|
||||||
addon_id: string;
|
service_id: string | null;
|
||||||
widget_type: string;
|
widget_kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
@@ -458,27 +448,71 @@ export interface WidgetInstance {
|
|||||||
|
|
||||||
export interface WidgetInstanceInput {
|
export interface WidgetInstanceInput {
|
||||||
id?: string | null;
|
id?: string | null;
|
||||||
addon_id: string;
|
service_id: string | null;
|
||||||
widget_type: string;
|
widget_kind: string;
|
||||||
title: string;
|
title: string;
|
||||||
config: Record<string, unknown>;
|
config: Record<string, unknown>;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sort_order: number;
|
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 {
|
export interface WidgetDataResponse {
|
||||||
widget_id: string;
|
widget_id: string;
|
||||||
widget_type: string;
|
|
||||||
data: Record<string, unknown> | null;
|
data: Record<string, unknown> | null;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
fetched_at: number;
|
fetched_at: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SecretFieldInfo {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
required: boolean;
|
||||||
|
helper?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceWidgetKindInfo {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
default_config: Record<string, unknown>;
|
||||||
|
refresh_interval_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceTypeInfo {
|
||||||
|
service_type: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
secret_fields: SecretFieldInfo[];
|
||||||
|
widget_kinds: ServiceWidgetKindInfo[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInstance {
|
||||||
|
id: string;
|
||||||
|
service_type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets_set: Record<string, boolean>;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ServiceInstanceInput {
|
||||||
|
id?: string | null;
|
||||||
|
service_type: string;
|
||||||
|
name: string;
|
||||||
|
config: Record<string, unknown>;
|
||||||
|
secrets: Record<string, string>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuiltinWidgetKindInfo {
|
||||||
|
kind: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
config_schema: Record<string, unknown>;
|
||||||
|
default_config: Record<string, unknown>;
|
||||||
|
refresh_interval_ms: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,22 +5,23 @@ import { SectionCard } from "../components/SectionCard";
|
|||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { BackupDashboardSummary } from "../types/backups";
|
import type { BackupDashboardSummary } from "../types/backups";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BackupsWidget({ widget }: Props) {
|
export function BackupsWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const summary = data?.data as BackupDashboardSummary | undefined;
|
const summary = data?.data as BackupDashboardSummary | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="flex flex-row flex-wrap gap-6">
|
<div className="flex flex-row flex-wrap gap-6">
|
||||||
<Skeleton className="h-10 w-20" />
|
<Skeleton className="h-10 w-20" />
|
||||||
|
|||||||
@@ -5,22 +5,23 @@ import { ExternalLink } from "lucide-react";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GrafanaLinkWidget({ widget }: Props) {
|
export function GrafanaLinkWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const url = data?.data?.url as string | undefined;
|
const url = data?.data?.url as string | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<Skeleton className="h-10 w-48" />
|
<Skeleton className="h-10 w-48" />
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
|
|||||||
@@ -4,22 +4,23 @@ import { SessionActivityPanel } from "../components/SessionActivityPanel";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { NowPlayingSession, WidgetInstance } from "../types";
|
import type { NowPlayingSession, WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function JellyfinWidget({ widget }: Props) {
|
export function JellyfinWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
const sessions = data?.data?.sessions as NowPlayingSession[] | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Skeleton className="h-4 w-3/4" />
|
<Skeleton className="h-4 w-3/4" />
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PromQLResult = {
|
type PromQLResult = {
|
||||||
@@ -35,16 +36,16 @@ function formatPrometheusValue(result: PromQLResult | undefined): string {
|
|||||||
return JSON.stringify(result, null, 2);
|
return JSON.stringify(result, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PrometheusMetricWidget({ widget }: Props) {
|
export function PrometheusMetricWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const result = data?.data?.result as PromQLResult | undefined;
|
const result = data?.data?.result as PromQLResult | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<Skeleton className="h-10 w-32" />
|
<Skeleton className="h-10 w-32" />
|
||||||
) : data?.error ? (
|
) : data?.error ? (
|
||||||
|
|||||||
@@ -3,10 +3,11 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type SshTaskResult = {
|
type SshTaskResult = {
|
||||||
@@ -15,16 +16,16 @@ type SshTaskResult = {
|
|||||||
stderr: string;
|
stderr: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SshTaskWidget({ widget }: Props) {
|
export function SshTaskWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data, isLoading } = useWidgetData(
|
refreshIntervalMs,
|
||||||
widget.id,
|
description,
|
||||||
def?.refreshInterval ?? 0,
|
}: Props) {
|
||||||
);
|
const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const result = data?.data as SshTaskResult | undefined;
|
const result = data?.data as SshTaskResult | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{isLoading && !data ? (
|
{isLoading && !data ? (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Skeleton className="h-4 w-full" />
|
<Skeleton className="h-4 w-full" />
|
||||||
|
|||||||
@@ -1,19 +1,23 @@
|
|||||||
import { SectionCard } from "../components/SectionCard";
|
import { SectionCard } from "../components/SectionCard";
|
||||||
import { useWidgetData } from "../hooks/useWidgets";
|
import { useWidgetData } from "../hooks/useWidgets";
|
||||||
import type { WidgetInstance } from "../types";
|
import type { WidgetInstance } from "../types";
|
||||||
import { getWidgetDefinition } from "./registry";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
widget: WidgetInstance;
|
widget: WidgetInstance;
|
||||||
|
refreshIntervalMs: number;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function StaticWidget({ widget }: Props) {
|
export function StaticWidget({
|
||||||
const def = getWidgetDefinition(widget.widget_type);
|
widget,
|
||||||
const { data } = useWidgetData(widget.id, def?.refreshInterval ?? 0);
|
refreshIntervalMs,
|
||||||
|
description,
|
||||||
|
}: Props) {
|
||||||
|
const { data } = useWidgetData(widget.id, refreshIntervalMs);
|
||||||
const text = data?.data?.text as string | undefined;
|
const text = data?.data?.text as string | undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionCard title={widget.title} description={def?.description}>
|
<SectionCard title={widget.title} description={description}>
|
||||||
{text ? (
|
{text ? (
|
||||||
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
<p className="whitespace-pre-wrap text-sm">{text}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -4,9 +4,3 @@ export { JellyfinWidget } from "./JellyfinWidget";
|
|||||||
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
export { PrometheusMetricWidget } from "./PrometheusMetricWidget";
|
||||||
export { SshTaskWidget } from "./SshTaskWidget";
|
export { SshTaskWidget } from "./SshTaskWidget";
|
||||||
export { StaticWidget } from "./StaticWidget";
|
export { StaticWidget } from "./StaticWidget";
|
||||||
export {
|
|
||||||
getWidgetDefinition,
|
|
||||||
listWidgetTypes,
|
|
||||||
WIDGET_REGISTRY,
|
|
||||||
} from "./registry";
|
|
||||||
export type { WidgetConfigField, WidgetDefinition } from "./registry";
|
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -141,9 +141,55 @@ npm run test -- src/widgets/registry.test.ts # 3 passed
|
|||||||
- 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`.
|
- 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.
|
- `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 4: Dashboard loop + configuration UI + addon pages
|
- 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.
|
||||||
|
|
||||||
## PR boundary
|
## PR boundary
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Apply Progress: Runtime Service Registry
|
||||||
|
|
||||||
|
**Change:** `service-registry`
|
||||||
|
**Apply run:** PRs #7–#10 (Slices 1–4a)
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## Slices 1–3 (MERGED)
|
||||||
|
|
||||||
|
- Slice 1 (#7): backend service foundation — encryption, integrations registry,
|
||||||
|
services + service_task_runs tables, `/api/services*` CRUD.
|
||||||
|
- Slice 2 (#8): backend widget rebind — service_id + widget_kind, ServiceRecord
|
||||||
|
adapters, built-ins, SSH run logging, retired old widget registry.
|
||||||
|
- Slice 3 (#9): frontend services runtime — types/API/hooks, frontend registry,
|
||||||
|
ServicePage, route swap, reconciled widget components + config dialog.
|
||||||
|
|
||||||
|
## Slice 4a — Cleanup + services admin UI + docs (this PR)
|
||||||
|
|
||||||
|
### Completed tasks
|
||||||
|
|
||||||
|
- [x] Removed addon pages (`/addons/:addonId`, `AddonPage.tsx`, `addons/*`) —
|
||||||
|
superseded by service pages.
|
||||||
|
- [x] Removed `grafana_url` / `prometheus_url` from `config.py`, both compose
|
||||||
|
files, `.env.example`, and README. (Frontend `VITE_GRAFANA_URL` /
|
||||||
|
`VITE_PROMETHEUS_URL` deep-link vars retained.)
|
||||||
|
- [x] Added a **Services page** (`/services`) with create/list/delete and a nav
|
||||||
|
entry, so service pages are reachable and services are configurable in the
|
||||||
|
tool itself.
|
||||||
|
- [x] Registered `/services` route in both route trees + sidebar nav.
|
||||||
|
- [x] Updated `docs/REQUIREMENTS.md` (service registry section) and added
|
||||||
|
`CHANGELOG.md` with the breaking-upgrade note.
|
||||||
|
|
||||||
|
### Decision resolved mid-slice
|
||||||
|
|
||||||
|
"Full machine migration" was scoped into **4a (cleanup) + 4b (Jellyfin/Jellyseerr
|
||||||
|
migration)** because removing machine-level Jellyfin/Jellyseerr fields is deeply
|
||||||
|
coupled to the Media/Users/Files pages (load-bearing) and there is no
|
||||||
|
`jellyseerr` service definition yet. 4a ships the safe cleanup + the services
|
||||||
|
admin UI; 4b does the machine-app-field migration as its own reviewable change.
|
||||||
|
|
||||||
|
### Files changed (Slice 4a)
|
||||||
|
|
||||||
|
- Backend: `config.py` (removed grafana_url/prometheus_url).
|
||||||
|
- Compose/env/docs: `docker-compose.yml`, `docker-compose.dev.yml`,
|
||||||
|
`.env.example`, `README.md`, `docs/REQUIREMENTS.md`, `CHANGELOG.md` (new).
|
||||||
|
- Frontend: new `pages/ServicesPage.tsx`; `App.tsx` (routes + nav); removed
|
||||||
|
`pages/AddonPage.tsx`, `addons/*`.
|
||||||
|
|
||||||
|
### Verification (Slice 4a)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
.venv/bin/ruff check . # clean
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint # 0 errors
|
||||||
|
npm run build # success
|
||||||
|
npm run test # 70 passed
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slice 4b — Jellyfin/Jellyseerr → services migration (in progress)
|
||||||
|
|
||||||
|
### Completed (backend, this PR)
|
||||||
|
|
||||||
|
- [x] Added `jellyseerr` service definition (`integrations/jellyseerr.py`) and
|
||||||
|
registered it (6 service types total).
|
||||||
|
- [x] Added `user_id` to the Jellyfin service config.
|
||||||
|
- [x] `dependencies.py`: new `_request_jellyfin_service_id` + `_service_record`
|
||||||
|
(decrypt-on-read). Rewrote `get_jellyfin_client`, `get_jellyseerr_client`,
|
||||||
|
and `get_user_id` to resolve against the service registry via the
|
||||||
|
`jellyfin_service_id` query param (first enabled instance as fallback).
|
||||||
|
- [x] SSH/Files transport (`get_ssh_client`) unchanged — still uses
|
||||||
|
`machine_id`.
|
||||||
|
- [x] Updated service-registry tests for 6 types.
|
||||||
|
|
||||||
|
### Selection model (decided)
|
||||||
|
|
||||||
|
Split query params: `?jellyfin_service_id=` selects the Jellyfin/Jellyseerr
|
||||||
|
instance; `?machine_id=` selects SSH/Files transport. Pages that need both pass
|
||||||
|
both.
|
||||||
|
|
||||||
|
### Remaining (frontend, next PR)
|
||||||
|
|
||||||
|
- Thread `jellyfinServiceId` through Media / Applications / Dashboard / Users:
|
||||||
|
list `jellyfin` service instances instead of `useMonitoringSettings()`
|
||||||
|
Jellyfin machines; pass `jellyfin_service_id` to Jellyfin API calls.
|
||||||
|
- Files page keeps `machine_id`.
|
||||||
|
- Settings UI: remove machine-level Jellyfin/Jellyseerr fields.
|
||||||
|
- Remove machine app fields from `settings_store.py` + `routers/settings.py`
|
||||||
|
once the UI no longer writes them.
|
||||||
|
|
||||||
|
### Frontend half (this PR)
|
||||||
|
|
||||||
|
- [x] `api/client.ts`: Jellyfin-backed calls (`fetchCounts`, `fetchLibraries`,
|
||||||
|
`fetchActivity`, `fetchUsers`, Media status/build/stop/force-stop, and
|
||||||
|
`queryMedia`) now send `jellyfin_service_id` instead of `machine_id`.
|
||||||
|
- [x] `hooks/useDashboard.ts`, `hooks/useUsers.ts`, `hooks/useMedia.ts`: renamed
|
||||||
|
the selector param to `jellyfinServiceId`.
|
||||||
|
- [x] `pages/Media.tsx` + `pages/Applications.tsx`: select a `jellyfin` service
|
||||||
|
instance via `useServiceInstances("jellyfin")` and persist
|
||||||
|
`jellyfin_service_id` in the URL.
|
||||||
|
- [x] Dashboard (widget-based) and Users (default-instance) need no selector
|
||||||
|
change.
|
||||||
|
- [x] Updated Applications + Media tests for the new hook/param.
|
||||||
|
|
||||||
|
### Deferred (explicit follow-up)
|
||||||
|
|
||||||
|
- Remove machine-level Jellyfin/Jellyseerr fields from `settings_store.py`,
|
||||||
|
`routers/settings.py`, and the Settings UI. Low urgency now that the runtime
|
||||||
|
reads from services; the machine fields are simply unused for Jellyfin.
|
||||||
|
|
||||||
|
### Verification (backend half)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
.venv/bin/ruff check . # clean
|
||||||
|
PYTHONPATH=src .venv/bin/python -m pytest # 222 passed
|
||||||
|
cd ../frontend
|
||||||
|
npm run lint && npm run build && npm run test # green (unchanged)
|
||||||
|
```
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
# Design: Runtime Service Registry
|
||||||
|
|
||||||
|
**Change:** `service-registry`
|
||||||
|
**Phase:** design
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## 1. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Browser │
|
||||||
|
│ /services/:type/:id ─► ServicePage ─► frontend SERVICE_REGISTRY
|
||||||
|
│ Dashboard ─► WidgetInstance ─► widget component │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ FastAPI /api/services + /api/widgets │
|
||||||
|
│ CRUD service instances · registry metadata · widget data │
|
||||||
|
└────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────────┼───────────────────────────┐
|
||||||
|
▼ ▼ ▼
|
||||||
|
ServiceStore (SQLite) integrations/ definitions source adapters
|
||||||
|
services table (Pydantic, closed registry) (resolve service
|
||||||
|
dashboard_widgets table grafana/prometheus/jellyfin/ → decrypt → call)
|
||||||
|
nextcloud/ssh_tasks
|
||||||
|
```
|
||||||
|
|
||||||
|
Two closed, compile-time registries cooperate:
|
||||||
|
|
||||||
|
- **`integrations.registry.SERVICE_DEFINITIONS`** maps `service_type → ServiceDefinition`.
|
||||||
|
Each definition declares config schema, secret fields, and widget kinds.
|
||||||
|
- The widget types available to the dashboard are **derived** from
|
||||||
|
`SERVICE_DEFINITIONS` at startup, not hand-maintained.
|
||||||
|
|
||||||
|
## 2. Backend data model
|
||||||
|
|
||||||
|
### 2.1 New `services` table
|
||||||
|
|
||||||
|
Extend `SettingsStore.init_schema()`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS services (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
service_type TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
config_json TEXT NOT NULL DEFAULT '{}',
|
||||||
|
secrets_json TEXT NOT NULL DEFAULT '{}', -- encrypted blob (Fernet)
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
updated_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type);
|
||||||
|
```
|
||||||
|
|
||||||
|
- `config_json` — non-secret config validated against the service definition's
|
||||||
|
`config_schema`.
|
||||||
|
- `secrets_json` — a JSON object of `{field_name: ciphertext}` produced by the
|
||||||
|
encryption helper. Never returned to the client in plaintext; only the boolean
|
||||||
|
"is set" flags are surfaced.
|
||||||
|
|
||||||
|
### 2.2 `dashboard_widgets` schema change
|
||||||
|
|
||||||
|
The existing table gains two columns and loses the global meaning of `widget_type`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT;
|
||||||
|
ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT;
|
||||||
|
```
|
||||||
|
|
||||||
|
- `widget_kind` is the kind declared by the service definition (e.g. `"link"`,
|
||||||
|
`"metric"`, `"activity"`).
|
||||||
|
- `service_id` references `services.id`.
|
||||||
|
- `widget_type` is retained temporarily as `"{service_type}.{widget_kind}"` for
|
||||||
|
backwards-compatible reads during the transition, then dropped in the final slice.
|
||||||
|
- The old `addon_id` column is dropped; addon identity is now `service_type`.
|
||||||
|
|
||||||
|
## 3. Service definitions (Pydantic, in repo)
|
||||||
|
|
||||||
|
New package: `backend/src/media_library_viewer_api/integrations/`
|
||||||
|
(chosen to avoid collision with the existing `services/` infra package).
|
||||||
|
|
||||||
|
### 3.1 Base classes — `integrations/base.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from typing import Any, ClassVar
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
class SecretField(BaseModel):
|
||||||
|
key: str
|
||||||
|
label: str
|
||||||
|
required: bool = False
|
||||||
|
helper: str | None = None
|
||||||
|
|
||||||
|
class WidgetKind(BaseModel):
|
||||||
|
kind: str # e.g. "link", "metric", "activity"
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
config_schema: dict[str, Any] # JSON schema for widget config
|
||||||
|
default_config: dict[str, Any] = {}
|
||||||
|
refresh_interval_ms: int = 0
|
||||||
|
|
||||||
|
class ServiceConfigBase(BaseModel):
|
||||||
|
"""Subclass per service to define non-secret config fields."""
|
||||||
|
|
||||||
|
class ServiceDefinition(BaseModel):
|
||||||
|
service_type: ClassVar[str]
|
||||||
|
name: ClassVar[str]
|
||||||
|
description: ClassVar[str]
|
||||||
|
config_schema: ClassVar[dict[str, Any]]
|
||||||
|
secret_fields: ClassVar[list[SecretField]]
|
||||||
|
widget_kinds: ClassVar[list[WidgetKind]]
|
||||||
|
|
||||||
|
# Adapters are referenced by dotted path or registered separately;
|
||||||
|
# see §4. The definition itself stays a pure data/schema object.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Example — `integrations/grafana.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
class GrafanaConfig(ServiceConfigBase):
|
||||||
|
base_url: str = Field(..., description="Grafana base URL, e.g. https://grafana.example.com")
|
||||||
|
|
||||||
|
GRAFANA_DEFINITION = ServiceDefinition(
|
||||||
|
service_type="grafana",
|
||||||
|
name="Grafana",
|
||||||
|
description="Dashboards, metrics, and logs.",
|
||||||
|
config_schema=GrafanaConfig.model_json_schema(),
|
||||||
|
secret_fields=[SecretField(key="api_key", label="API key", helper="Service account token")],
|
||||||
|
widget_kinds=[
|
||||||
|
WidgetKind(
|
||||||
|
kind="link",
|
||||||
|
name="Dashboard link",
|
||||||
|
description="Deep-link to a Grafana dashboard or panel.",
|
||||||
|
config_schema={
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"dashboard_uid": {"type": "string"},
|
||||||
|
"panel_id": {"type": "integer"},
|
||||||
|
},
|
||||||
|
"required": ["dashboard_uid"],
|
||||||
|
},
|
||||||
|
default_config={"dashboard_uid": ""},
|
||||||
|
refresh_interval_ms=0,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Other definition modules follow the same shape: `prometheus.py`, `jellyfin.py`,
|
||||||
|
`nextcloud.py`, `ssh_tasks.py`.
|
||||||
|
|
||||||
|
### 3.3 Registry — `integrations/registry.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
|
||||||
|
"grafana": GRAFANA_DEFINITION,
|
||||||
|
"prometheus": PROMETHEUS_DEFINITION,
|
||||||
|
"jellyfin": JELLYFIN_DEFINITION,
|
||||||
|
"nextcloud": NEXTCLOUD_DEFINITION,
|
||||||
|
"ssh_tasks": SSH_TASKS_DEFINITION,
|
||||||
|
}
|
||||||
|
|
||||||
|
def list_service_types() -> list[str]: ...
|
||||||
|
def get_service_definition(service_type: str) -> ServiceDefinition | None: ...
|
||||||
|
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
The closed `widgets/registry.py` from Phase 1 is **retired**; its metadata is now derived
|
||||||
|
from `SERVICE_DEFINITIONS`.
|
||||||
|
|
||||||
|
## 4. Source adapters
|
||||||
|
|
||||||
|
`widgets/sources.py` is refactored so each adapter resolves a **service instance** rather
|
||||||
|
than reading `get_settings()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WidgetSource(Protocol):
|
||||||
|
async def fetch(
|
||||||
|
self,
|
||||||
|
service: ServiceRecord, # config + decrypted secrets
|
||||||
|
widget_kind: str,
|
||||||
|
config: dict[str, Any],
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `ServiceRecord` is a runtime object built by `ServiceStore` carrying the decrypted
|
||||||
|
secret dict in memory for the duration of the fetch.
|
||||||
|
- `SOURCE_ADAPTERS` is keyed by `service_type`.
|
||||||
|
- The data endpoint loads the widget's `service_id`, builds the `ServiceRecord`, then
|
||||||
|
calls `adapter.fetch(service, widget_kind, widget_config)`.
|
||||||
|
|
||||||
|
## 5. Encryption — `services/secrets.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from cryptography.fernet import Fernet, InvalidToken
|
||||||
|
|
||||||
|
def get_encryption_key() -> bytes:
|
||||||
|
raw = os.environ.get("MANAGE_ENCRYPTION_KEY")
|
||||||
|
if not raw:
|
||||||
|
raise RuntimeError("MANAGE_ENCRYPTION_KEY is required")
|
||||||
|
return raw.encode()
|
||||||
|
|
||||||
|
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]: ...
|
||||||
|
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `cryptography.fernet.Fernet` (already a transitive dependency to verify).
|
||||||
|
- Startup validation: `validate_auth_settings` is extended to require
|
||||||
|
`MANAGE_ENCRYPTION_KEY` and to reject an obviously invalid key.
|
||||||
|
- Secrets are encrypted field-by-field so the "which secrets are set" metadata is cheap
|
||||||
|
to compute without decrypting.
|
||||||
|
|
||||||
|
## 6. REST API
|
||||||
|
|
||||||
|
### Services
|
||||||
|
|
||||||
|
| Method | Path | Handler |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/services/types` | List service definitions (metadata + config schema + widget kinds). |
|
||||||
|
| GET | `/api/services` | List service instances (no plaintext secrets; only "set" flags). |
|
||||||
|
| POST | `/api/services` | Create instance (validates type, config, secret schema). |
|
||||||
|
| PUT | `/api/services/{id}` | Update instance. |
|
||||||
|
| DELETE | `/api/services/{id}` | Delete instance; **cascade-deletes** widgets referencing it in the same transaction. |
|
||||||
|
|
||||||
|
### Widgets (unchanged paths, new semantics)
|
||||||
|
|
||||||
|
| Method | Path | Handler |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/widgets/instances` | List widgets; each carries `service_id`, `widget_kind`. |
|
||||||
|
| POST/PUT/DELETE | `/api/widgets/instances/{id}` | CRUD; validation uses service definition's widget schema. |
|
||||||
|
| GET | `/api/widgets/instances/{id}/data` | Resolve service → adapter → fetch. |
|
||||||
|
|
||||||
|
`GET /api/widgets/types` and `/api/widgets/sources` are removed; widget metadata is
|
||||||
|
served via `/api/services/types` (widget kinds under each service).
|
||||||
|
|
||||||
|
## 7. Frontend
|
||||||
|
|
||||||
|
### 7.1 New `frontend/src/integrations/registry.ts`
|
||||||
|
|
||||||
|
Closed frontend registry mirroring the backend: `serviceType → ServiceDefinition`
|
||||||
|
(config fields, secret fields with `secret: true`, widget kinds, default refresh
|
||||||
|
intervals, and a `component` for the service page).
|
||||||
|
|
||||||
|
### 7.2 Service pages
|
||||||
|
|
||||||
|
- Route: `/services/:serviceType/:serviceId` (replaces `/addons/:addonId`).
|
||||||
|
- `ServicePage` looks up the definition and renders the service-specific component,
|
||||||
|
a config editor, and the list of widget kinds that can be added to the dashboard.
|
||||||
|
- `App.tsx` removes the `/addons/:addonId` route; old addon URLs redirect to the
|
||||||
|
default service of that type (or a not-found alert).
|
||||||
|
|
||||||
|
### 7.3 Dashboard config dialog
|
||||||
|
|
||||||
|
- "Add widget" flow becomes: **pick service → pick widget kind → configure**.
|
||||||
|
- The widget card shows the parent service name.
|
||||||
|
|
||||||
|
### 7.4 Types / API / hooks
|
||||||
|
|
||||||
|
- `frontend/src/api/services.ts` + `hooks/useServices.ts` for the services API.
|
||||||
|
- `frontend/src/types/index.ts` gains `ServiceInstance`, `ServiceInstanceInput`,
|
||||||
|
`ServiceTypeInfo`, `ServiceWidgetKind`.
|
||||||
|
|
||||||
|
## 8. Migration and breaking changes
|
||||||
|
|
||||||
|
- **DB migration on startup:** add `services` table; add `service_id` / `widget_kind`
|
||||||
|
columns to `dashboard_widgets`; drop `addon_id`.
|
||||||
|
- **Machine app fields removed:** `jellyfin_url`, `jellyfin_user_id`, `jellyfin_api_key`,
|
||||||
|
`jellyseerr_url`, `jellyseerr_api_key` are dropped from machine records and the
|
||||||
|
`MonitoringMachine` model. Machines keep SSH + node_exporter transport fields only.
|
||||||
|
- **Env vars removed from `config.py`:** `grafana_url`, `prometheus_url`. (Grafana/Prometheus
|
||||||
|
URLs now live on service records.) `MANAGE_ENCRYPTION_KEY` is added as required.
|
||||||
|
- **Default widget seeding** is removed; a fresh install starts with no widgets. The user
|
||||||
|
adds Jellyfin/Backups widgets after configuring the corresponding services.
|
||||||
|
- **`docs/REQUIREMENTS.md` and `README.md`** updated to describe services, the
|
||||||
|
`MANAGE_ENCRYPTION_KEY` requirement, and the breaking upgrade note.
|
||||||
|
|
||||||
|
## 9. File-level plan
|
||||||
|
|
||||||
|
### Create (backend)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `integrations/__init__.py` | Package marker. |
|
||||||
|
| `integrations/base.py` | `ServiceDefinition`, `WidgetKind`, `SecretField`, `ServiceConfigBase`. |
|
||||||
|
| `integrations/registry.py` | Closed `SERVICE_DEFINITIONS` + helpers. |
|
||||||
|
| `integrations/grafana.py`, `prometheus.py`, `jellyfin.py`, `nextcloud.py`, `ssh_tasks.py` | One module per service. |
|
||||||
|
| `services/secrets.py` | Fernet encrypt/decrypt + key validation. |
|
||||||
|
| `services/service_store.py` | CRUD for `services` table; decrypt-on-read for adapters. |
|
||||||
|
| `routers/services.py` | `/api/services*` endpoints. |
|
||||||
|
| `models/services.py` | Pydantic request/response models. |
|
||||||
|
|
||||||
|
### Modify (backend)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|-------|--------|
|
||||||
|
| `services/settings_store.py` | `services` table; widget columns; drop machine app fields. |
|
||||||
|
| `widgets/sources.py` | Adapters take a `ServiceRecord`. |
|
||||||
|
| `widgets/registry.py` | Retired (metadata served by `integrations/registry.py`). |
|
||||||
|
| `routers/widgets.py` | Validate against service widget schema; resolve service on data fetch. |
|
||||||
|
| `config.py` | Remove `grafana_url`/`prometheus_url`; document `MANAGE_ENCRYPTION_KEY` (read in `secrets.py`). |
|
||||||
|
| `main.py` | Register `services_router`; validate encryption key on startup. |
|
||||||
|
| `dependencies.py` | Jellyfin/SSH resolution now goes via services, not machine app fields. |
|
||||||
|
|
||||||
|
### Create (frontend)
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `integrations/registry.ts` | Closed frontend service registry. |
|
||||||
|
| `api/services.ts`, `hooks/useServices.ts` | Services API + hooks. |
|
||||||
|
| `pages/ServicePage.tsx` | Generic `/services/:type/:id` page. |
|
||||||
|
| `integrations/components/*` | Per-service page components. |
|
||||||
|
|
||||||
|
### Modify (frontend)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|-------|--------|
|
||||||
|
| `App.tsx` | Replace `/addons/:addonId` with `/services/:serviceType/:serviceId`. |
|
||||||
|
| `components/WidgetConfigDialog.tsx` | Service → widget-kind picker. |
|
||||||
|
| `widgets/registry.ts` | Retired; widgets derived from service registry. |
|
||||||
|
| `types/index.ts` | Service types; widget gains `service_id` + `widget_kind`. |
|
||||||
|
| `pages/Settings.tsx` | Remove machine Jellyfin/Jellyseerr fields. |
|
||||||
|
|
||||||
|
## 10. Slice boundaries (chained PRs)
|
||||||
|
|
||||||
|
Each slice keeps `pytest`, `ruff`, `npm run lint`, and `npm run build` green.
|
||||||
|
|
||||||
|
1. **Backend foundation** — encryption helper, `integrations/` base + 5 definitions +
|
||||||
|
registry, `services` table + store, `/api/services*` endpoints, tests. No widget
|
||||||
|
changes yet.
|
||||||
|
2. **Backend widget rebind** — add `service_id`/`widget_kind` to widgets, refactor
|
||||||
|
adapters to take a `ServiceRecord`, retire old `widgets/registry.py`, update data
|
||||||
|
endpoint.
|
||||||
|
3. **Frontend services runtime** — types, API, hooks, `integrations/registry.ts`,
|
||||||
|
service pages, route swap, remove addon pages.
|
||||||
|
4. **Frontend dashboard + settings rework** — service-based widget picker, drop machine
|
||||||
|
app fields from Settings, remove `grafana_url`/`prometheus_url` from config,
|
||||||
|
re-seed behavior, docs (`README.md`, `REQUIREMENTS.md`), changelog breaking-change
|
||||||
|
note.
|
||||||
|
|
||||||
|
Estimated total: ~2,000–2,400 changed lines across four PRs.
|
||||||
|
|
||||||
|
## 11. Decisions resolved
|
||||||
|
|
||||||
|
1. **Deleting a service that still has widgets** → **cascade delete.** The store deletes
|
||||||
|
every `dashboard_widgets` row referencing the service inside the same transaction as
|
||||||
|
the service delete. Simple and safe in SQLite; no 409 pre-check.
|
||||||
|
2. **`MANAGE_ENCRYPTION_KEY` dev default** → **always required.** No fallback, even when
|
||||||
|
`AUTH_ENABLED=false`. Startup fails fast if it is missing or not a valid Fernet key.
|
||||||
|
3. **SSH task runner shape** → **multi-instance, reusable tasks, persisted run history.**
|
||||||
|
See §12 for the full model.
|
||||||
|
|
||||||
|
## 12. SSH task runner model
|
||||||
|
|
||||||
|
The SSH task runner is the most involved service type. Instances absorb the SSH task
|
||||||
|
execution role currently held by machines; tasks stay global and reusable; every
|
||||||
|
invocation is logged.
|
||||||
|
|
||||||
|
### 12.1 Instances
|
||||||
|
|
||||||
|
- `service_type = "ssh_tasks"`.
|
||||||
|
- Each instance is an SSH endpoint: `host`, `port`, `username`, `ssh_key_id`, optional
|
||||||
|
`passphrase`. Connection config lives on the service record; the SSH key itself stays
|
||||||
|
in the existing saved-key registry (referenced by `ssh_key_id`).
|
||||||
|
- Multi-instance by design ("home server", "media box", …).
|
||||||
|
|
||||||
|
### 12.2 Tasks (global, reusable)
|
||||||
|
|
||||||
|
- Saved tasks remain a **global** registry (`name`, `task_type` shell/python, `content`,
|
||||||
|
`enabled`). A task is **not** owned by an instance.
|
||||||
|
- Each task gains `default_service_id` (replaces the old `default_machine_id`) — the
|
||||||
|
instance it targets by default. At run time the caller may override the target
|
||||||
|
instance.
|
||||||
|
- A task can therefore run against any instance; the link is captured per-run.
|
||||||
|
|
||||||
|
### 12.3 Run history (logs)
|
||||||
|
|
||||||
|
A new `service_task_runs` table records every invocation:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS service_task_runs (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
task_id TEXT NOT NULL,
|
||||||
|
service_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL, -- success | failure | timeout | error
|
||||||
|
exit_status INTEGER,
|
||||||
|
duration_ms INTEGER,
|
||||||
|
stdout_tail TEXT,
|
||||||
|
stderr_tail TEXT,
|
||||||
|
error TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_task_runs_service ON service_task_runs(service_id, created_at DESC);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC);
|
||||||
|
```
|
||||||
|
|
||||||
|
- Populated by the SSH task adapter on every widget data fetch and by the Actions
|
||||||
|
runner on manual runs.
|
||||||
|
- Surfaced on the instance's service page as a log/history list, and on the task detail
|
||||||
|
as recent runs.
|
||||||
|
- Replaces the legacy `saved_task_runs` concept once the Actions page is rebuilt on
|
||||||
|
services (Slice 4 / a follow-up).
|
||||||
|
|
||||||
|
### 12.4 SSH task widget
|
||||||
|
|
||||||
|
Widget config for `ssh_tasks` becomes `{ task_id, service_id? }`:
|
||||||
|
|
||||||
|
- If `service_id` is omitted, the task's `default_service_id` is used.
|
||||||
|
- The adapter loads the task, resolves the instance, runs it, appends a
|
||||||
|
`service_task_runs` row, and returns the trimmed stdout/stderr/exit status.
|
||||||
|
|
||||||
|
### 12.5 Relationship to machines
|
||||||
|
|
||||||
|
- The SSH task execution role moves **out of machines** into `ssh_tasks` instances.
|
||||||
|
- Machines **keep** their role for the File Browser and node_exporter monitoring
|
||||||
|
transport in this change, to avoid also reworking Files/Monitoring here.
|
||||||
|
- Practical consequence: an SSH host used for both files and tasks may be defined twice
|
||||||
|
(once as a machine, once as an ssh_tasks instance) during the transition. Unifying
|
||||||
|
machines under services is an explicit **follow-up change**, not part of this one.
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
# Proposal: Runtime Service Registry
|
||||||
|
|
||||||
|
**Change:** `service-registry`
|
||||||
|
**Phase:** proposal
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Status:** awaiting review (design only — no implementation yet)
|
||||||
|
|
||||||
|
## Context and problem
|
||||||
|
|
||||||
|
Phase 1 shipped a configurable dashboard widget system whose service URLs (Grafana,
|
||||||
|
Prometheus) and app credentials (Jellyfin, Jellyseerr) are driven by environment
|
||||||
|
variables and machine-level fields. This has three problems:
|
||||||
|
|
||||||
|
1. **Operators cannot change services without a redeploy.** Adding a second Grafana,
|
||||||
|
pointing Prometheus at a different host, or rotating a Jellyfin API key requires
|
||||||
|
editing env vars and restarting containers.
|
||||||
|
2. **Configuration is split across three places.** Service URLs live in env vars
|
||||||
|
(`GRAFANA_URL`, `PROMETHEUS_URL`), Jellyfin/Jellyseerr live on machine records, and
|
||||||
|
widget instances live in the widget table. There is no single "what is configured"
|
||||||
|
view.
|
||||||
|
3. **The widget registry is decoupled from the services it depends on.** A Grafana
|
||||||
|
widget does not know which Grafana instance it talks to; the widget config holds a
|
||||||
|
`dashboard_uid` while the base URL is global.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Introduce a **runtime service registry** persisted in the backend SQLite database:
|
||||||
|
|
||||||
|
- Each **service instance** (e.g. "Production Grafana", "Home Jellyfin") is a DB record
|
||||||
|
carrying its non-secret config and encrypted secret fields.
|
||||||
|
- **Service definitions** live as Python modules with Pydantic classes in the repo. Each
|
||||||
|
definition declares its config schema, its secret fields, and the **widget kinds** it
|
||||||
|
provides (with their own config schemas).
|
||||||
|
- **Service pages** at `/services/:serviceType/:serviceId` render the service-specific UI
|
||||||
|
and list the widgets that service can contribute to the dashboard. These replace the
|
||||||
|
existing addon pages.
|
||||||
|
- **Dashboard widgets** become service-bound: a widget instance references a `service_id`
|
||||||
|
and a `widget_kind` drawn from that service's definition.
|
||||||
|
- Machine records are reduced to **transport only** (SSH + node_exporter); the
|
||||||
|
machine-level Jellyfin/Jellyseerr app fields are removed.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- One source of truth for every external service the app talks to.
|
||||||
|
- Add/reconfigure/rotate a service from the UI with no redeploy.
|
||||||
|
- Multiple instances per service type (two Grafanas, two Jellyfins).
|
||||||
|
- Centralized, version-controlled service definitions that are easy to extend.
|
||||||
|
- Widgets discoverable per-service and individually addable to the dashboard.
|
||||||
|
- Secrets (API keys / tokens) encrypted at rest.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No general-purpose plugin/marketplace system.** Service definitions are closed,
|
||||||
|
compile-time code. Adding a brand-new service still requires a backend deploy and a
|
||||||
|
Python module.
|
||||||
|
- **No OAuth token exchange per service in this change.** Only API keys / tokens are
|
||||||
|
stored (encrypted). OAuth-proxy flows (e.g. Grafana behind Authentik) continue to be
|
||||||
|
handled externally.
|
||||||
|
- **No drag-and-drop dashboard layout, no grid, no per-user dashboards.** This change
|
||||||
|
keeps the existing single stacked-column dashboard model.
|
||||||
|
- **No in-app charting.** The thin-dashboard observability rule still holds; service
|
||||||
|
pages surface deep-links and metadata only.
|
||||||
|
- **No silent data migration.** Machine-level Jellyfin/Jellyseerr config is removed
|
||||||
|
without an automatic converter (see Decisions).
|
||||||
|
|
||||||
|
## Decisions (from grilling)
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Scope of services | All current services: Grafana, Prometheus, Jellyfin, Nextcloud, and the SSH task runner. Definitions centralized in repo. |
|
||||||
|
| Definition format | Python modules with Pydantic classes for service config and widget config, combined under each service definition. |
|
||||||
|
| Auth storage | API keys / tokens only, encrypted at rest. |
|
||||||
|
| Encryption key | Single env-provided master key (`MANAGE_ENCRYPTION_KEY`). |
|
||||||
|
| Machine app config | Services **replace** machine-level Jellyfin/Jellyseerr app config. Machines become SSH/monitoring transport only. |
|
||||||
|
| Migration | **Break backwards compatibility.** Users re-enter service config after upgrade; no automatic converter. |
|
||||||
|
| Multi-instance | Yes — multiple service records per service type. |
|
||||||
|
| Addon pages | Replaced by generic service pages at `/services/:serviceType/:serviceId`. |
|
||||||
|
| Widget binding | The service definition **owns** its widget config schemas. Widgets are instantiated from a service instance + a widget kind. |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Breaking upgrade.** Existing deployments lose their Jellyfin config and must re-enter
|
||||||
|
it. We must document this loudly in the changelog and README.
|
||||||
|
- **Encryption key management.** Losing `MANAGE_ENCRYPTION_KEY` makes all stored secrets
|
||||||
|
unrecoverable. Key rotation requires re-encrypting every service record.
|
||||||
|
- **Large surface area.** This change touches backend models, settings store, widget
|
||||||
|
registry, adapters, frontend routing, dashboard config UI, and docs. It must be split
|
||||||
|
into reviewable PRs (see `tasks.md`).
|
||||||
|
- **SSH task runner as a service** needs care: saved tasks already have their own
|
||||||
|
registry. The service record should hold connection/auth; the task registry stays.
|
||||||
|
- **Env vars are not fully eliminated.** The encryption key and core auth/OIDC settings
|
||||||
|
still require env vars; only service URLs/credentials move to the DB.
|
||||||
|
|
||||||
|
## Out of scope for this proposal
|
||||||
|
|
||||||
|
- Automatic migration tooling from machine app config to service records.
|
||||||
|
- Secret rotation UI or key-rotation workflow.
|
||||||
|
- Per-user or multi-dashboard support.
|
||||||
|
- Runtime/hot-reload of service definition files (definitions are loaded at startup).
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
# Tasks: Runtime Service Registry
|
||||||
|
|
||||||
|
**Change:** `service-registry`
|
||||||
|
**Phase:** tasks
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## Review workload forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | ~2,000–2,400 |
|
||||||
|
| 400-line budget risk | High |
|
||||||
|
| Chained PRs recommended | Yes (4 PRs) |
|
||||||
|
| Chain strategy | stacked-to-main |
|
||||||
|
|
||||||
|
```text
|
||||||
|
Decision needed before apply: Yes (see design §11 open questions)
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
Chain strategy: stacked-to-main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Slice 1: Backend service foundation (no widget changes)
|
||||||
|
|
||||||
|
**Goal:** Persist service instances with encrypted secrets and expose CRUD + metadata.
|
||||||
|
|
||||||
|
- [ ] **1.1 Add encryption helper**
|
||||||
|
- Files: `backend/src/media_library_viewer_api/services/secrets.py` (new)
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: Fernet-based `encrypt_secrets` / `decrypt_secrets` / `get_encryption_key`.
|
||||||
|
Raise on missing `MANAGE_ENCRYPTION_KEY`. Add `cryptography` dependency if missing.
|
||||||
|
- [ ] **1.2 Add integrations base classes**
|
||||||
|
- Files: `integrations/__init__.py`, `integrations/base.py` (new)
|
||||||
|
- Lines: ~80
|
||||||
|
- Details: `ServiceDefinition`, `WidgetKind`, `SecretField`, `ServiceConfigBase`.
|
||||||
|
- [ ] **1.3 Add five service definitions + registry**
|
||||||
|
- Files: `integrations/grafana.py`, `prometheus.py`, `jellyfin.py`, `nextcloud.py`,
|
||||||
|
`ssh_tasks.py`, `integrations/registry.py` (new)
|
||||||
|
- Lines: ~220
|
||||||
|
- Details: One `ServiceDefinition` per service with config schema, secret fields, and
|
||||||
|
widget kinds. `SERVICE_DEFINITIONS` + `get_service_definition` /
|
||||||
|
`get_widget_kind` helpers.
|
||||||
|
- [ ] **1.4 Add service store + `services` table**
|
||||||
|
- Files: `services/settings_store.py` (modify), `services/service_store.py` (new)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: `services` table in `init_schema`; CRUD helpers; decrypt-on-read for
|
||||||
|
adapters; "set" flags for the API without plaintext. **Cascade delete:** removing a
|
||||||
|
service deletes its widgets in the same transaction. Also add the
|
||||||
|
`service_task_runs` table (design §12.3) now so later slices can populate it.
|
||||||
|
- [ ] **1.5 Add service Pydantic models + router**
|
||||||
|
- Files: `models/services.py` (new), `routers/services.py` (new), `main.py` (modify)
|
||||||
|
- Lines: ~110
|
||||||
|
- Details: `GET /api/services/types`, `GET /api/services`, `POST/PUT/DELETE
|
||||||
|
/api/services/{id}`. Validate type, config, and secret schema against the definition.
|
||||||
|
- [ ] **1.6 Validate encryption key on startup**
|
||||||
|
- Files: `auth.py` or `main.py` lifespan (modify)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: Extend startup validation to require `MANAGE_ENCRYPTION_KEY`.
|
||||||
|
- [ ] **1.7 Add backend tests**
|
||||||
|
- Files: `backend/tests/test_services.py` (new)
|
||||||
|
- Lines: ~140
|
||||||
|
- Details: Registry contents, CRUD round-trip, secret encryption/decryption,
|
||||||
|
unknown service type → 422, missing/invalid encryption key → startup error,
|
||||||
|
cascade-delete removes a service's widgets.
|
||||||
|
- [ ] **1.8 Verify**
|
||||||
|
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||||
|
|
||||||
|
**Slice 1 total:** ~720 changed lines (smallest coherent backend foundation).
|
||||||
|
|
||||||
|
## Slice 2: Backend widget rebind to services
|
||||||
|
|
||||||
|
**Goal:** Widgets reference a service instance + widget kind; adapters resolve services.
|
||||||
|
|
||||||
|
- [ ] **2.1 Add widget columns + migrate table**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: Add `service_id`, `widget_kind` to `dashboard_widgets`; keep `widget_type`
|
||||||
|
as `{service_type}.{kind}` during transition; drop `addon_id`.
|
||||||
|
- [ ] **2.2 Refactor source adapters**
|
||||||
|
- Files: `widgets/sources.py` (modify)
|
||||||
|
- Lines: ~160
|
||||||
|
- Details: Each adapter takes `(service: ServiceRecord, widget_kind, config)`.
|
||||||
|
`SOURCE_ADAPTERS` keyed by `service_type`. Jellyfin/Grafana/Prometheus/SSH adapters
|
||||||
|
resolve connection from the service record. The SSH adapter resolves the task +
|
||||||
|
instance, runs it, and **appends a `service_task_runs` row** (design §12.3).
|
||||||
|
- [ ] **2.3 Retire old widget registry**
|
||||||
|
- Files: `widgets/registry.py` (delete or hollow out), `widgets/__init__.py`
|
||||||
|
- Lines: ~-60
|
||||||
|
- Details: Widget metadata now comes from `integrations/registry.py`.
|
||||||
|
- [ ] **2.4 Update widgets router + models**
|
||||||
|
- Files: `routers/widgets.py`, `models/widgets.py` (modify)
|
||||||
|
- Lines: ~90
|
||||||
|
- Details: Validation uses the service definition's widget schema; data endpoint
|
||||||
|
loads service, builds `ServiceRecord`, calls adapter.
|
||||||
|
- [ ] **2.5 Update widget tests**
|
||||||
|
- Files: `backend/tests/test_widgets.py` (modify)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: Rewrite adapter/data tests around service instances; cover
|
||||||
|
service-missing, wrong-kind, and encrypted-secret resolution.
|
||||||
|
- [ ] **2.6 Verify**
|
||||||
|
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||||
|
|
||||||
|
**Slice 2 total:** ~330 changed lines.
|
||||||
|
|
||||||
|
## Slice 3: Frontend services runtime
|
||||||
|
|
||||||
|
**Goal:** Service types/API/hooks, frontend service registry, service pages, route swap.
|
||||||
|
|
||||||
|
- [ ] **3.1 Add service types**
|
||||||
|
- Files: `frontend/src/types/index.ts` (modify)
|
||||||
|
- Lines: ~50
|
||||||
|
- Details: `ServiceInstance`, `ServiceInstanceInput`, `ServiceTypeInfo`,
|
||||||
|
`ServiceWidgetKind`. Widget gains `service_id`, `widget_kind`.
|
||||||
|
- [ ] **3.2 Add services API + hooks**
|
||||||
|
- Files: `frontend/src/api/services.ts`, `frontend/src/hooks/useServices.ts` (new)
|
||||||
|
- Lines: ~110
|
||||||
|
- Details: Fetch/create/update/delete service instances and types.
|
||||||
|
- [ ] **3.3 Add frontend service registry**
|
||||||
|
- Files: `frontend/src/integrations/registry.ts` (new)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: Closed registry mirroring backend: config fields, secret fields
|
||||||
|
(`secret: true`), widget kinds, service page components.
|
||||||
|
- [ ] **3.4 Add service page + components**
|
||||||
|
- Files: `frontend/src/pages/ServicePage.tsx`, `frontend/src/integrations/components/*`
|
||||||
|
(new)
|
||||||
|
- Lines: ~180
|
||||||
|
- Details: Generic page dispatches by service type; renders config editor + widget
|
||||||
|
kinds. Add per-service components (Grafana, Prometheus, Jellyfin, Nextcloud,
|
||||||
|
SSH tasks).
|
||||||
|
- [ ] **3.5 Swap routes; remove addon pages**
|
||||||
|
- Files: `frontend/src/App.tsx`, `frontend/src/pages/AddonPage.tsx`,
|
||||||
|
`frontend/src/addons/*` (modify/delete)
|
||||||
|
- Lines: ~-40 net
|
||||||
|
- Details: `/services/:serviceType/:serviceId`; redirect old `/addons/*` to the
|
||||||
|
default service of that type.
|
||||||
|
- [ ] **3.6 Add frontend registry test**
|
||||||
|
- Files: `frontend/src/integrations/registry.test.ts` (new)
|
||||||
|
- Lines: ~40
|
||||||
|
- Details: Assert all five service types and their widget kinds.
|
||||||
|
- [ ] **3.7 Verify**
|
||||||
|
- Run: `cd frontend && npm run lint && npm run build && npm run test -- src/integrations/registry.test.ts`
|
||||||
|
|
||||||
|
**Slice 3 total:** ~460 changed lines.
|
||||||
|
|
||||||
|
## Slice 4: Dashboard picker, settings rework, cleanup, docs
|
||||||
|
|
||||||
|
**Goal:** End-to-end service-based dashboard; remove legacy machine app config + env vars.
|
||||||
|
|
||||||
|
- [ ] **4.1 Rework widget config dialog**
|
||||||
|
- Files: `frontend/src/components/WidgetConfigDialog.tsx` (modify)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: "Add widget" = pick service → pick widget kind → configure. Widget cards
|
||||||
|
show parent service name.
|
||||||
|
- [ ] **4.2 Update widget components to service model**
|
||||||
|
- Files: `frontend/src/widgets/*` (modify)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: Components read `widget_kind`; data shapes unchanged but sourced from the
|
||||||
|
service adapter. SSH task widget shows last run status from `service_task_runs`.
|
||||||
|
- [ ] **4.3 Remove machine Jellyfin/Jellyseerr fields**
|
||||||
|
- Files: `frontend/src/pages/Settings.tsx`, `frontend/src/types/index.ts`
|
||||||
|
(modify)
|
||||||
|
- Lines: ~-60
|
||||||
|
- Details: Machines are SSH/monitoring transport only.
|
||||||
|
- [ ] **4.4 Remove grafana_url / prometheus_url from backend config**
|
||||||
|
- Files: `backend/src/media_library_viewer_api/config.py`,
|
||||||
|
`docker-compose.yml`, `docker-compose.dev.yml`, `.env.example`
|
||||||
|
- Lines: ~-10
|
||||||
|
- Details: URLs now live on service records. Add `MANAGE_ENCRYPTION_KEY` to compose
|
||||||
|
- `.env.example`.
|
||||||
|
- [ ] **4.5 Stop default widget seeding**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~-20
|
||||||
|
- Details: Fresh installs start with no widgets; user adds them after configuring
|
||||||
|
services.
|
||||||
|
- [ ] **4.6 Docs + changelog**
|
||||||
|
- Files: `docs/REQUIREMENTS.md`, `README.md`, `docs/CHANGELOG.md` (new or modify)
|
||||||
|
- Lines: ~80
|
||||||
|
- Details: Service registry section; `MANAGE_ENCRYPTION_KEY` requirement; breaking
|
||||||
|
upgrade note (re-enter Jellyfin config).
|
||||||
|
- [ ] **4.7 Verify full stack**
|
||||||
|
- Run: backend `ruff` + `pytest`; frontend `lint` + `build` + `test`.
|
||||||
|
|
||||||
|
**Slice 4 total:** ~330 changed lines.
|
||||||
|
|
||||||
|
## Integration and acceptance
|
||||||
|
|
||||||
|
- [ ] **5.1 Backend full test run** — `PYTHONPATH=src pytest`, all green.
|
||||||
|
- [ ] **5.2 Frontend full build/lint/test** — `npm run lint && npm run build && npm run test`.
|
||||||
|
- [ ] **5.3 Manual dev-stack check** — `docker compose -f docker-compose.dev.yml up --build`:
|
||||||
|
- Create a Grafana service from the UI; verify the dashboard link widget works.
|
||||||
|
- Create a Jellyfin service; verify the activity widget resolves it.
|
||||||
|
- Delete a service with widgets → widgets are cascade-deleted and the service is gone.
|
||||||
|
- Restart the stack; secrets remain usable (key stable).
|
||||||
|
- Missing `MANAGE_ENCRYPTION_KEY` → backend refuses to start.
|
||||||
|
- SSH task runner: define two instances, run the same reusable task against each,
|
||||||
|
and see both runs in the instance's history log.
|
||||||
|
|
||||||
|
## Guards
|
||||||
|
|
||||||
|
```text
|
||||||
|
Decision needed before apply: No (design §11 resolved)
|
||||||
|
Chained PRs recommended: Yes
|
||||||
|
Chain strategy: stacked-to-main
|
||||||
|
400-line budget risk: High
|
||||||
|
```
|
||||||
|
|
||||||
|
## Explicit follow-ups (out of scope for this change)
|
||||||
|
|
||||||
|
- Rebuild the Actions page UI on top of services (global reusable tasks +
|
||||||
|
`default_service_id`), replacing the current machine-based saved-task runner.
|
||||||
|
- Unify machines under services so an SSH host is defined once (today machines still
|
||||||
|
own File Browser + node_exporter transport; see design §12.5).
|
||||||
|
- Key rotation / re-encrypt workflow for `MANAGE_ENCRYPTION_KEY`.
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
# Design: Unify Saved Tasks on SSH Services
|
||||||
|
|
||||||
|
**Change:** `unify-tasks-on-services`
|
||||||
|
**Phase:** design
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## 1. Architecture overview
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ saved_tasks (global, reusable) │
|
||||||
|
│ default_service_id → ssh_tasks │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│ │
|
||||||
|
Actions page │ │ SSH task widget
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ run_saved_task(store, task, svc) │ ← shared helper
|
||||||
|
│ build client → run → log │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ service_task_runs (one history) │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Both the Actions runner and the SSH task widget call one shared helper, so there
|
||||||
|
is a single execution path and a single history table.
|
||||||
|
|
||||||
|
## 2. Shared execution helper
|
||||||
|
|
||||||
|
New: `backend/src/media_library_viewer_api/services/task_runner.py`
|
||||||
|
|
||||||
|
```python
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||||
|
from media_library_viewer_api.widgets.sources import ServiceRecord, _build_ssh_client
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TaskRunResult:
|
||||||
|
exit_status: int
|
||||||
|
stdout: str
|
||||||
|
stderr: str
|
||||||
|
duration_ms: int
|
||||||
|
status: str # "success" | "failure" | "timeout" | "error"
|
||||||
|
error: str
|
||||||
|
|
||||||
|
def run_saved_task(
|
||||||
|
store: SettingsStore,
|
||||||
|
task: dict,
|
||||||
|
service: ServiceRecord,
|
||||||
|
*,
|
||||||
|
request_id: str = "",
|
||||||
|
) -> TaskRunResult:
|
||||||
|
"""Run a saved task on an ssh_tasks service instance and log it.
|
||||||
|
|
||||||
|
Builds the SSH client from the service record, renders the command (shell or
|
||||||
|
python3 -c), runs it with the service's timeout, appends a service_task_runs
|
||||||
|
row, and returns the result.
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
- The widget adapter (`SshTaskWidgetSource.fetch`) is refactored to call
|
||||||
|
`run_saved_task`, removing its inline copy.
|
||||||
|
- `routers/tasks.py` `run_task` calls `run_saved_task` instead of
|
||||||
|
`_client_for_machine` + `record_task_run`.
|
||||||
|
- `_build_ssh_client` (currently private in `widgets/sources.py`) is promoted to
|
||||||
|
the helper module or a shared location so both callers use it.
|
||||||
|
|
||||||
|
## 3. Data model changes
|
||||||
|
|
||||||
|
### 3.1 `saved_tasks`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- default_machine_id replaced by default_service_id
|
||||||
|
ALTER TABLE saved_tasks RENAME COLUMN default_machine_id TO default_service_id;
|
||||||
|
```
|
||||||
|
|
||||||
|
In SQLite (3.25+) `RENAME COLUMN` is supported. The column still stores an id,
|
||||||
|
now pointing at `services.id` (an `ssh_tasks` instance) instead of a machine.
|
||||||
|
|
||||||
|
### 3.2 `saved_task_runs` dropped
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS saved_task_runs;
|
||||||
|
```
|
||||||
|
|
||||||
|
All history lives in `service_task_runs` (added in the service-registry change).
|
||||||
|
The `record_task_run` / `list_task_runs` methods on `SettingsStore` are removed.
|
||||||
|
|
||||||
|
## 4. Backend API
|
||||||
|
|
||||||
|
### `routers/tasks.py`
|
||||||
|
|
||||||
|
| Method | Path | Change |
|
||||||
|
|--------|------|--------|
|
||||||
|
| GET | `/api/tasks` | Unchanged (task now carries `default_service_id`). |
|
||||||
|
| POST | `/api/tasks` | `TaskInput.default_service_id` replaces `default_machine_id`. |
|
||||||
|
| PUT | `/api/tasks/{id}` | Same field rename. |
|
||||||
|
| DELETE | `/api/tasks/{id}` | Unchanged. |
|
||||||
|
| GET | `/api/tasks/{id}/runs` | Reads `service_task_runs` (filtered by `task_id`). |
|
||||||
|
| POST | `/api/tasks/run?service_id=...` | `service_id` replaces `machine_id`; resolves an `ssh_tasks` service (override) or the task's `default_service_id`; calls `run_saved_task`. |
|
||||||
|
|
||||||
|
`_resolve_machine_for_task` and `_client_for_machine` are removed (replaced by
|
||||||
|
service resolution + the shared helper).
|
||||||
|
|
||||||
|
### Resolution + validation
|
||||||
|
|
||||||
|
- `run_task`: load the task; if `service_id` query param is given, use it
|
||||||
|
(override), else use `task.default_service_id`; load the `ssh_tasks` service
|
||||||
|
record; build a `ServiceRecord` (decrypt secrets); call `run_saved_task`.
|
||||||
|
- 400 if the task is disabled; 400 if no service resolves; 404 if the task or
|
||||||
|
service is missing.
|
||||||
|
|
||||||
|
## 5. Frontend
|
||||||
|
|
||||||
|
### 5.1 Types
|
||||||
|
|
||||||
|
`SavedTask` / `SavedTaskInput` / `SavedTaskRun` (`frontend/src/types/index.ts`):
|
||||||
|
|
||||||
|
- `default_machine_id` → `default_service_id`.
|
||||||
|
- `SavedTaskRun` fields align with `service_task_runs` (`service_id`,
|
||||||
|
`exit_status`, `stdout_tail`, …).
|
||||||
|
|
||||||
|
### 5.2 API client + hooks
|
||||||
|
|
||||||
|
- `runTask(taskId, serviceId?)` sends `service_id`.
|
||||||
|
- `fetchSavedTaskRuns(taskId)` reads `/api/tasks/{id}/runs` (now
|
||||||
|
`service_task_runs`-backed).
|
||||||
|
|
||||||
|
### 5.3 Actions page
|
||||||
|
|
||||||
|
- Task editor: "Default service" `<Select>` lists `ssh_tasks` service instances
|
||||||
|
(via `useServiceInstances("ssh_tasks")`), not machines.
|
||||||
|
- Run dialog: "Run on" `<Select>` lists `ssh_tasks` instances (override).
|
||||||
|
- Run history: reads the task's `service_task_runs`.
|
||||||
|
- `useMonitoringSettings` removed from the Actions page (no longer needed).
|
||||||
|
|
||||||
|
## 6. Migration and breaking changes
|
||||||
|
|
||||||
|
- **DB:** `saved_tasks.default_machine_id` renamed to `default_service_id`
|
||||||
|
(existing values become stale references to machine ids; inert — the user
|
||||||
|
re-points). `saved_task_runs` dropped.
|
||||||
|
- **Local execution removed.** Deployments relying on local tasks must use an
|
||||||
|
`ssh_tasks` service (e.g. pointing at localhost with a key).
|
||||||
|
- **Changelog + README** note the breaking change.
|
||||||
|
|
||||||
|
## 7. File-level plan
|
||||||
|
|
||||||
|
### Create (backend)
|
||||||
|
|
||||||
|
- `services/task_runner.py` — `run_saved_task` shared helper.
|
||||||
|
|
||||||
|
### Modify (backend)
|
||||||
|
|
||||||
|
- `services/settings_store.py` — rename column; drop `saved_task_runs` +
|
||||||
|
`record_task_run` / `list_task_runs` (task-run flavor).
|
||||||
|
- `routers/tasks.py` — service resolution; call `run_saved_task`; `service_id`
|
||||||
|
param; read `service_task_runs`.
|
||||||
|
- `widgets/sources.py` — `SshTaskWidgetSource.fetch` delegates to
|
||||||
|
`run_saved_task`.
|
||||||
|
|
||||||
|
### Modify (frontend)
|
||||||
|
|
||||||
|
- `types/index.ts` — field rename + `SavedTaskRun` alignment.
|
||||||
|
- `api/client.ts` — `runTask` sends `service_id`.
|
||||||
|
- `pages/Actions.tsx` — service selectors + history source.
|
||||||
|
|
||||||
|
## 8. Slice boundaries
|
||||||
|
|
||||||
|
1. **Backend** — `run_saved_task` helper; saved_tasks column rename; tasks router
|
||||||
|
rewired; widget delegates; `saved_task_runs` dropped; tests.
|
||||||
|
2. **Frontend** — types + API + Actions page rewire; tests.
|
||||||
|
|
||||||
|
Estimated ~600–800 changed lines across two PRs.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Proposal: Unify Saved Tasks on SSH Services
|
||||||
|
|
||||||
|
**Change:** `unify-tasks-on-services`
|
||||||
|
**Phase:** proposal
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
**Status:** awaiting review (design only — no implementation yet)
|
||||||
|
|
||||||
|
## Context and problem
|
||||||
|
|
||||||
|
Saved tasks (the Actions page) currently have **two execution paths**:
|
||||||
|
|
||||||
|
1. **Actions page** → resolves a *machine* (`default_machine_id`) → runs via
|
||||||
|
`_client_for_machine` → logs to `saved_task_runs`.
|
||||||
|
2. **SSH task widget** → resolves an `ssh_tasks` *service instance* → runs via
|
||||||
|
`_build_ssh_client` → logs to `service_task_runs`.
|
||||||
|
|
||||||
|
Same saved-task records, two runners, two history tables, two target models. This
|
||||||
|
is the leftover inconsistency from the service-registry change (design §12): the
|
||||||
|
widget was migrated to services but the Actions page was not.
|
||||||
|
|
||||||
|
## Proposal
|
||||||
|
|
||||||
|
Migrate the Actions page onto the same `ssh_tasks` service model the widget
|
||||||
|
already uses, so there is **one execution path** and **one history table**.
|
||||||
|
|
||||||
|
- Saved tasks gain `default_service_id` (replaces `default_machine_id`), pointing
|
||||||
|
at an `ssh_tasks` service instance.
|
||||||
|
- The Actions runner resolves an `ssh_tasks` service (the task's default, or an
|
||||||
|
explicit run-time override), builds the SSH client from the service record, runs
|
||||||
|
the task, and logs to `service_task_runs`.
|
||||||
|
- `saved_task_runs` is dropped; both the Actions page and the widget read
|
||||||
|
`service_task_runs`.
|
||||||
|
- Local (API-host) task execution is dropped — all tasks run over SSH against
|
||||||
|
`ssh_tasks` services.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- One execution path for saved tasks (Actions page + widget share it).
|
||||||
|
- One run-history table (`service_task_runs`).
|
||||||
|
- Tasks target `ssh_tasks` service instances, consistent with the rest of the
|
||||||
|
service registry.
|
||||||
|
- Run-time override preserved: a task can be run against any `ssh_tasks` instance.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- **No change to the jobs router** (`/api/jobs/run`, the `disk_usage` template,
|
||||||
|
etc.). That stays machine-based for the File Browser's on-demand SSH checks.
|
||||||
|
- **No machine/service unification** (follow-up #3). Machines still own File
|
||||||
|
Browser + node_exporter transport.
|
||||||
|
- **No local execution mode.** Dropped per decision; tasks are SSH-only.
|
||||||
|
- **No automatic data migration** of `default_machine_id` → `default_service_id`.
|
||||||
|
Break backwards compatibility (consistent with the service-registry change):
|
||||||
|
existing tasks lose their default target and the user re-points them.
|
||||||
|
|
||||||
|
## Decisions (from grilling)
|
||||||
|
|
||||||
|
| Topic | Decision |
|
||||||
|
|-------|----------|
|
||||||
|
| Local execution | **SSH-only.** Drop local mode; `ssh_tasks` services handle all task execution. |
|
||||||
|
| Run history | **`service_task_runs` only.** Drop `saved_task_runs`. |
|
||||||
|
| Run-time override | **Keep.** A task can run against any `ssh_tasks` instance at run time. |
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Breaking upgrade.** Existing tasks lose `default_machine_id`; users re-point
|
||||||
|
to an `ssh_tasks` service. Document in changelog.
|
||||||
|
- **Local-mode loss.** Any deployment relying on local task execution must set up
|
||||||
|
an SSH loopback (or an ssh_tasks service pointing at localhost with a key) to
|
||||||
|
keep running local tasks.
|
||||||
|
- **Shared execution code.** The Actions runner and the widget must share one
|
||||||
|
`run_saved_task` helper to avoid divergence; extracting it is the core refactor.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Machine/service unification (follow-up #3).
|
||||||
|
- Migrating the jobs router (`/api/jobs`) off machines.
|
||||||
|
- A UI for browsing `service_task_runs` across all services (the service page
|
||||||
|
already shows per-instance history; the Actions page shows per-task history).
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Tasks: Unify Saved Tasks on SSH Services
|
||||||
|
|
||||||
|
**Change:** `unify-tasks-on-services`
|
||||||
|
**Phase:** tasks
|
||||||
|
**Date:** 2026-06-19
|
||||||
|
|
||||||
|
## Review workload forecast
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Estimated changed lines | ~600–800 |
|
||||||
|
| Chained PRs recommended | Yes (2 PRs) |
|
||||||
|
| Chain strategy | stacked-to-main |
|
||||||
|
|
||||||
|
## Slice 1: Backend — shared runner + service-based tasks
|
||||||
|
|
||||||
|
**Goal:** One execution path; tasks target ssh_tasks services; one history table.
|
||||||
|
|
||||||
|
- [ ] **1.1 Add shared `run_saved_task` helper**
|
||||||
|
- Files: `backend/src/media_library_viewer_api/services/task_runner.py` (new)
|
||||||
|
- Lines: ~90
|
||||||
|
- Details: `run_saved_task(store, task, service, *, request_id)` builds the SSH
|
||||||
|
client from the service record (promote `_build_ssh_client`), renders the
|
||||||
|
command, runs with the service timeout, appends a `service_task_runs` row,
|
||||||
|
returns a `TaskRunResult`.
|
||||||
|
- [ ] **1.2 Rename saved_tasks column**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~20
|
||||||
|
- Details: `default_machine_id` → `default_service_id` (ALTER TABLE RENAME
|
||||||
|
COLUMN on startup; update `_row_to_task`, `_normalize_task_payload`,
|
||||||
|
`upsert_task`).
|
||||||
|
- [ ] **1.3 Drop saved_task_runs**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~-60
|
||||||
|
- Details: `DROP TABLE IF EXISTS saved_task_runs`; remove `record_task_run`
|
||||||
|
and `list_task_runs` (task flavor).
|
||||||
|
- [ ] **1.4 Rewire tasks router**
|
||||||
|
- Files: `routers/tasks.py` (modify)
|
||||||
|
- Lines: ~70
|
||||||
|
- Details: `TaskInput.default_service_id`; `run_task` takes `service_id`
|
||||||
|
(override), resolves an ssh_tasks service, calls `run_saved_task`;
|
||||||
|
`/api/tasks/{id}/runs` reads `service_task_runs`. Remove
|
||||||
|
`_resolve_machine_for_task` and `_client_for_machine`.
|
||||||
|
- [ ] **1.5 Widget delegates to shared helper**
|
||||||
|
- Files: `widgets/sources.py` (modify)
|
||||||
|
- Lines: ~-40
|
||||||
|
- Details: `SshTaskWidgetSource.fetch` calls `run_saved_task` instead of its
|
||||||
|
inline run+log block.
|
||||||
|
- [ ] **1.6 Add `list_service_task_runs` by task (if not present)**
|
||||||
|
- Files: `services/settings_store.py` (modify)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: Confirm `list_service_task_runs(task_id=...)` covers the tasks
|
||||||
|
router needs.
|
||||||
|
- [ ] **1.7 Update backend tests**
|
||||||
|
- Files: `backend/tests/test_jobs.py`, `test_api.py` (modify)
|
||||||
|
- Lines: ~60
|
||||||
|
- Details: Update task-run tests to the service model; cover override +
|
||||||
|
default + disabled-service paths.
|
||||||
|
- [ ] **1.8 Verify**
|
||||||
|
- Run: `cd backend && .venv/bin/ruff check . && PYTHONPATH=src .venv/bin/python -m pytest`
|
||||||
|
|
||||||
|
**Slice 1 total:** ~250 changed lines.
|
||||||
|
|
||||||
|
## Slice 2: Frontend — Actions page on services
|
||||||
|
|
||||||
|
**Goal:** Actions page targets ssh_tasks services; reads service_task_runs.
|
||||||
|
|
||||||
|
- [ ] **2.1 Update types**
|
||||||
|
- Files: `frontend/src/types/index.ts` (modify)
|
||||||
|
- Lines: ~15
|
||||||
|
- Details: `SavedTask` / `SavedTaskInput` `default_service_id`;
|
||||||
|
`SavedTaskRun` aligned to `service_task_runs`.
|
||||||
|
- [ ] **2.2 Update API client**
|
||||||
|
- Files: `frontend/src/api/client.ts` (modify)
|
||||||
|
- Lines: ~10
|
||||||
|
- Details: `runTask(taskId, serviceId?)` sends `service_id`.
|
||||||
|
- [ ] **2.3 Rewire Actions page**
|
||||||
|
- Files: `frontend/src/pages/Actions.tsx` (modify)
|
||||||
|
- Lines: ~120
|
||||||
|
- Details: Task editor "Default service" select lists ssh_tasks services via
|
||||||
|
`useServiceInstances("ssh_tasks")`; run dialog "Run on" selects an instance;
|
||||||
|
run history reads `service_task_runs`. Remove `useMonitoringSettings`.
|
||||||
|
- [ ] **2.4 Update Actions tests**
|
||||||
|
- Files: `frontend/src/pages/__tests__/Actions.test.tsx` (modify)
|
||||||
|
- Lines: ~30
|
||||||
|
- Details: Mock `useServiceInstances`; update fixtures.
|
||||||
|
- [ ] **2.5 Docs + changelog**
|
||||||
|
- Files: `docs/REQUIREMENTS.md`, `CHANGELOG.md` (modify)
|
||||||
|
- Lines: ~30
|
||||||
|
- Details: Saved-actions section: tasks target ssh_tasks services; local mode
|
||||||
|
dropped; breaking-upgrade note.
|
||||||
|
- [ ] **2.6 Verify**
|
||||||
|
- Run: `cd frontend && npm run lint && npm run build && npm run test`
|
||||||
|
|
||||||
|
**Slice 2 total:** ~200 changed lines.
|
||||||
|
|
||||||
|
## Integration and acceptance
|
||||||
|
|
||||||
|
- [ ] **3.1 Backend full test run** — `PYTHONPATH=src pytest`, all green.
|
||||||
|
- [ ] **3.2 Frontend full build/lint/test**.
|
||||||
|
- [ ] **3.3 Manual dev-stack check**:
|
||||||
|
- Create an ssh_tasks service; create a task with that default; run from
|
||||||
|
Actions; see the run in both the Actions history and the service page.
|
||||||
|
- Override the target at run time.
|
||||||
|
- SSH task widget uses the same history.
|
||||||
Reference in New Issue
Block a user