Compare commits
55 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 | |||
| 2557185fb7 | |||
| e1356b20f1 | |||
| e6d333ef7b | |||
| 1cd8e926de | |||
| 1a52dfb087 | |||
| 9dfe62eb6f | |||
| 200d319fb0 | |||
| 24427b4869 | |||
| bb8b040657 | |||
| a8eb751322 | |||
| 08a3b616f6 | |||
| 1c29299e8c | |||
| 0ec2a8806b | |||
| 9cae5fc98c | |||
| 7646f3236f | |||
| 9de2d5b8d2 | |||
| 3dc1b31fc3 | |||
| e8b0f1144b | |||
| 04f2e59c92 | |||
| 1e23c07a20 | |||
| b6da7df7f9 | |||
| c721f0dece | |||
| 77c6b62ee2 | |||
| 109e74db41 | |||
| dd778d8850 |
@@ -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_VERSION=0.1.0
|
||||
APP_BUILD_INFO=dev
|
||||
@@ -23,6 +27,9 @@ PROMETHEUS_ENABLED=true
|
||||
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL=http://alertmanager:9093
|
||||
ALERTMANAGER_WEBHOOK_URL=
|
||||
# Required: master key for 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
|
||||
BACKEND_CACHE_DIR=./backend-cache
|
||||
|
||||
# Auth
|
||||
@@ -41,6 +48,8 @@ VITE_OIDC_SCOPE=openid profile email
|
||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
VITE_GRAFANA_URL=https://grafana.example.com
|
||||
VITE_PROMETHEUS_URL=https://prometheus.example.com
|
||||
|
||||
# SMTP
|
||||
SMTP_HOST=smtp.example.com
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Layout
|
||||
|
||||
- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`.
|
||||
- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`).
|
||||
- Frontend entrypoint: `frontend/src/main.tsx`.
|
||||
- Backend uses a `src/` layout; tests live in `backend/tests/`.
|
||||
|
||||
## Commands
|
||||
|
||||
- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'`
|
||||
- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`.
|
||||
- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`.
|
||||
@@ -17,12 +19,13 @@
|
||||
- Production stack: `docker compose up --build`
|
||||
|
||||
## Repo-Specific Gotchas
|
||||
|
||||
- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them.
|
||||
- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars).
|
||||
- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines.
|
||||
- `backend_cache` persists the media index and the managed `known_hosts` file.
|
||||
- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`.
|
||||
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and monitoring poller.
|
||||
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and backup alert poller.
|
||||
- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically.
|
||||
- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact.
|
||||
- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11.
|
||||
|
||||
@@ -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
|
||||
|
||||
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts
|
||||
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines
|
||||
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine
|
||||
- Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, SSH task output, static text) and shortcuts
|
||||
- Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
|
||||
- Per-machine settings for Jellyfin, Jellyseerr, SSH, and monitoring targets
|
||||
- SQLite-indexed media table with full-library sort/filter
|
||||
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
|
||||
- Remote file browser with ffprobe preview and job execution
|
||||
- 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
|
||||
|
||||
@@ -39,7 +40,9 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
|
||||
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:
|
||||
|
||||
@@ -47,9 +50,9 @@ Local development with hot reload:
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000.
|
||||
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.
|
||||
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 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
|
||||
|
||||
@@ -76,13 +79,17 @@ The Compose files use environment-variable interpolation. Export the required va
|
||||
Production-style example with shell exports:
|
||||
|
||||
```bash
|
||||
export BACKEND_APP_HOST=manage.example.com
|
||||
export BACKEND_APP_HOST=api.manage.example.com
|
||||
export FRONTEND_APP_HOST=manage.example.com
|
||||
export GRAFANA_APP_HOST=grafana.manage.example.com
|
||||
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_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_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
|
||||
```
|
||||
@@ -90,7 +97,7 @@ docker compose up --build
|
||||
Inline one-liner example:
|
||||
|
||||
```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:
|
||||
@@ -124,27 +131,35 @@ SMTP_TIMEOUT=30
|
||||
|
||||
# Authentik / OIDC
|
||||
AUTH_ENABLED=true
|
||||
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/
|
||||
OIDC_AUDIENCE=media-library-viewer
|
||||
OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
|
||||
OIDC_AUDIENCE=manage
|
||||
OIDC_JWKS_URL=
|
||||
OIDC_CLOCK_SKEW_SECONDS=30
|
||||
|
||||
# Frontend OIDC settings
|
||||
VITE_OIDC_ENABLED=true
|
||||
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/
|
||||
VITE_OIDC_CLIENT_ID=media-library-viewer
|
||||
VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
|
||||
VITE_OIDC_CLIENT_ID=manage
|
||||
VITE_OIDC_SCOPE=openid profile email
|
||||
VITE_OIDC_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
|
||||
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
|
||||
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
|
||||
|
||||
The remote server needs:
|
||||
|
||||
- Linux `/proc` and `/sys/block` for monitoring
|
||||
- `/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:
|
||||
|
||||
@@ -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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- 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",
|
||||
"prometheus-client>=0.21",
|
||||
"python-json-logger>=2.0",
|
||||
"cryptography>=42.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -52,11 +52,6 @@ class Settings(BaseSettings):
|
||||
ssh_password: str = ""
|
||||
ssh_known_hosts_path: str = ""
|
||||
|
||||
# Monitoring poller
|
||||
monitoring_poll_interval_seconds: int = 300
|
||||
monitoring_poll_initial_delay_seconds: int = 20
|
||||
monitoring_action_retention_days: int = 30
|
||||
|
||||
# Observability
|
||||
prometheus_enabled: bool = True
|
||||
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
"""Dependency injection for FastAPI.
|
||||
|
||||
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request
|
||||
context. The selected machine can be chosen with a ``machine_id`` query
|
||||
parameter; otherwise the backend falls back to the first enabled machine that
|
||||
matches the requested service.
|
||||
Provides access to service-specific Jellyfin/Jellyseerr clients and
|
||||
machine-specific SSH clients via FastAPI's request context.
|
||||
|
||||
- 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
|
||||
@@ -21,12 +24,6 @@ from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.mail_queue import MailQueue
|
||||
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
|
||||
from media_library_viewer_api.services.monitoring_poller import (
|
||||
MonitoringPoller,
|
||||
)
|
||||
from media_library_viewer_api.services.monitoring_poller import (
|
||||
get_monitoring_poller as _get_monitoring_poller,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.settings_store import get_settings_store as _get_settings_store
|
||||
|
||||
@@ -40,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | 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)
|
||||
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||
machine_id, url, api_key = cache_key
|
||||
@@ -49,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
|
||||
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)
|
||||
def _ssh_client_for(
|
||||
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
|
||||
@@ -122,6 +139,10 @@ def _ssh_client_for(
|
||||
|
||||
|
||||
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()
|
||||
machine_id = _request_machine_id(request)
|
||||
if machine_id:
|
||||
@@ -129,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"):
|
||||
return machine
|
||||
return machine
|
||||
if service == "jellyfin":
|
||||
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":
|
||||
if service == "ssh":
|
||||
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
|
||||
else:
|
||||
machines = store.list_machines_for_service(service)
|
||||
@@ -141,37 +158,34 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
|
||||
|
||||
|
||||
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()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
resolved = _resolve_machine("jellyfin", request)
|
||||
if resolved:
|
||||
machine = store.get_machine_config(resolved["id"])
|
||||
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"):
|
||||
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "")
|
||||
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."
|
||||
)
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyfin", service_id)
|
||||
if service is None:
|
||||
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
|
||||
cache_key = (service["id"], base_url, api_key)
|
||||
return _jellyfin_client_for(cache_key)
|
||||
|
||||
|
||||
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
|
||||
"""Return a cached Jellyseerr client when configured, otherwise None."""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
resolved = _resolve_machine("jellyseerr", request)
|
||||
if resolved:
|
||||
machine = store.get_machine_config(resolved["id"])
|
||||
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"):
|
||||
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "")
|
||||
|
||||
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)")
|
||||
return None
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyseerr", service_id)
|
||||
if service is None:
|
||||
logger.info("Jellyseerr client not configured (no jellyseerr service)")
|
||||
return None
|
||||
base_url = str(service.get("config", {}).get("base_url") or "")
|
||||
api_key = str(service.get("secrets", {}).get("api_key") or "")
|
||||
if not base_url or not api_key:
|
||||
logger.info("Jellyseerr service is missing base_url or api_key")
|
||||
return None
|
||||
return JellyseerrClient(base_url, api_key)
|
||||
|
||||
|
||||
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
|
||||
@@ -251,11 +265,6 @@ def get_mail_queue() -> MailQueue:
|
||||
return _get_mail_queue()
|
||||
|
||||
|
||||
def get_monitoring_poller() -> MonitoringPoller:
|
||||
"""Return the singleton background monitoring poller."""
|
||||
return _get_monitoring_poller()
|
||||
|
||||
|
||||
def get_settings_store() -> SettingsStore:
|
||||
"""Return the singleton persistent settings store."""
|
||||
return _get_settings_store()
|
||||
@@ -264,16 +273,12 @@ def get_settings_store() -> SettingsStore:
|
||||
def get_user_id(request: Request = None) -> str:
|
||||
"""Return the configured Jellyfin user ID or discover the first available one."""
|
||||
store = get_settings_store()
|
||||
machine_id = _request_machine_id(request)
|
||||
machine = store.get_machine_config(machine_id) if machine_id else None
|
||||
if machine is None:
|
||||
resolved = _resolve_machine("jellyfin", request)
|
||||
if resolved:
|
||||
machine = store.get_machine_config(resolved["id"])
|
||||
if machine and machine.get("jellyfin_user_id"):
|
||||
return str(machine["jellyfin_user_id"])
|
||||
service_id = _request_jellyfin_service_id(request)
|
||||
service = _service_record(store, "jellyfin", service_id)
|
||||
if service and service.get("config", {}).get("user_id"):
|
||||
return str(service["config"]["user_id"])
|
||||
client = get_jellyfin_client(request)
|
||||
users = client.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"]
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Dashboard domain helpers shared between routers and widget adapters."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
|
||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown"))
|
||||
if has_item
|
||||
else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
else:
|
||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type: list[str] = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary:
|
||||
"""Compute the backup summary shown on the dashboard."""
|
||||
jobs = store.list_backup_jobs()
|
||||
total_jobs = len(jobs)
|
||||
|
||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||
recent_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||
if runs and runs[0]["started_at"] >= cutoff:
|
||||
recent_runs.append(runs[0])
|
||||
|
||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||
|
||||
alerts = store.list_backup_alerts(acknowledged=False)
|
||||
active_alerts = len(alerts)
|
||||
|
||||
failed_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||
if runs:
|
||||
failed_runs.append(runs[0])
|
||||
|
||||
last_failed_at = None
|
||||
if failed_runs:
|
||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||
|
||||
return BackupDashboardSummary(
|
||||
total_jobs=total_jobs,
|
||||
success_rate_24h=round(success_rate, 1),
|
||||
active_alerts=active_alerts,
|
||||
last_failed_at=last_failed_at,
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -13,7 +13,7 @@ from fastapi.responses import Response as FastAPIResponse
|
||||
|
||||
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_monitoring_poller, get_settings_store
|
||||
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
|
||||
from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra
|
||||
from media_library_viewer_api.observability import (
|
||||
get_request_id,
|
||||
@@ -23,6 +23,8 @@ 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 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.settings import router as settings_router
|
||||
|
||||
from .services.backup_poller import get_backup_poller
|
||||
@@ -37,6 +39,9 @@ async def lifespan(app: FastAPI):
|
||||
settings = get_settings()
|
||||
configure_logging(settings.log_level, settings.log_format)
|
||||
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("Managed known_hosts will be populated lazily on first successful SSH connection")
|
||||
try:
|
||||
@@ -45,14 +50,15 @@ async def lifespan(app: FastAPI):
|
||||
write_prometheus_targets(get_settings_store())
|
||||
except Exception:
|
||||
logger.exception("Failed to write Prometheus file-SD targets during startup")
|
||||
try:
|
||||
get_settings_store().ensure_defaults()
|
||||
except Exception:
|
||||
logger.exception("Failed to seed default settings during startup")
|
||||
mail_queue = get_mail_queue()
|
||||
monitoring_poller = get_monitoring_poller()
|
||||
backup_poller = get_backup_poller()
|
||||
mail_queue.start()
|
||||
monitoring_poller.start()
|
||||
backup_poller.start()
|
||||
yield
|
||||
monitoring_poller.stop()
|
||||
backup_poller.stop()
|
||||
mail_queue.stop()
|
||||
logger.info("Backend shutdown complete")
|
||||
@@ -140,6 +146,8 @@ app.include_router(users.router)
|
||||
app.include_router(tasks.router)
|
||||
app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
app.include_router(services_router.router)
|
||||
|
||||
|
||||
@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]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""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 pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
FORBIDDEN_CONFIG_KEYS = {
|
||||
"password",
|
||||
"token",
|
||||
"secret",
|
||||
"api_key",
|
||||
"apikey",
|
||||
"private_key",
|
||||
"passphrase",
|
||||
"credential",
|
||||
}
|
||||
|
||||
|
||||
def _looks_secret(value: Any) -> bool:
|
||||
"""Heuristic to detect values that look like secrets/tokens."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
lowered = value.lower()
|
||||
if value.startswith("sk-") or value.startswith("eyJ"):
|
||||
return True
|
||||
if len(value) > 64 and lowered.isalnum():
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Recursively reject credential keys and secret-looking values."""
|
||||
for key, value in config.items():
|
||||
if key.lower() in FORBIDDEN_CONFIG_KEYS:
|
||||
raise ValueError(f"Credential key '{key}' is not allowed in widget config")
|
||||
if _looks_secret(value):
|
||||
raise ValueError(f"Value for '{key}' looks like a secret")
|
||||
if isinstance(value, dict):
|
||||
_validate_config_keys(value)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
if isinstance(item, dict):
|
||||
_validate_config_keys(item)
|
||||
return config
|
||||
|
||||
|
||||
class _WidgetInstanceBase(BaseModel):
|
||||
"""Shared fields between input and output widget models."""
|
||||
|
||||
service_id: str | None = None
|
||||
widget_kind: str = Field(..., min_length=1)
|
||||
title: str = Field(..., min_length=1)
|
||||
config: dict[str, Any] = Field(default_factory=dict)
|
||||
enabled: bool = True
|
||||
sort_order: int = Field(default=0, ge=0)
|
||||
|
||||
@field_validator("config")
|
||||
@classmethod
|
||||
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
|
||||
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):
|
||||
"""Payload for creating or updating a widget instance."""
|
||||
|
||||
id: str | None = None
|
||||
|
||||
|
||||
class WidgetInstance(_WidgetInstanceBase):
|
||||
"""Persisted widget instance returned by the API."""
|
||||
|
||||
id: str
|
||||
created_at: int
|
||||
updated_at: int
|
||||
|
||||
|
||||
class BuiltinWidgetKindInfo(BaseModel):
|
||||
"""Metadata about a built-in (service-less) widget kind."""
|
||||
|
||||
kind: str
|
||||
name: str
|
||||
description: str
|
||||
config_schema: dict[str, Any]
|
||||
default_config: dict[str, Any]
|
||||
refresh_interval_ms: int
|
||||
|
||||
|
||||
class WidgetDataResponse(BaseModel):
|
||||
"""Response from the per-widget data endpoint."""
|
||||
|
||||
widget_id: str
|
||||
data: dict[str, Any] | None = None
|
||||
error: str | None = None
|
||||
fetched_at: int
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
|
||||
get_settings_store,
|
||||
get_user_id,
|
||||
)
|
||||
from media_library_viewer_api.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.models.backups import BackupDashboardSummary
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
@@ -85,50 +88,6 @@ def delete_shortcut(
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""Normalize Jellyfin sessions into dashboard activity rows."""
|
||||
results: list[dict[str, Any]] = []
|
||||
for session in sessions:
|
||||
item = session.get("NowPlayingItem") or {}
|
||||
play_state = session.get("PlayState") or {}
|
||||
transcoding = session.get("TranscodingInfo") or {}
|
||||
|
||||
has_item = bool(item)
|
||||
series = item.get("SeriesName") or ""
|
||||
title = (
|
||||
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
|
||||
)
|
||||
|
||||
if not has_item:
|
||||
state_label = "idle"
|
||||
else:
|
||||
state_label = "paused" if play_state.get("IsPaused") else "playing"
|
||||
|
||||
is_transcoding = bool(transcoding)
|
||||
transcode_type: list[str] = []
|
||||
if is_transcoding:
|
||||
if transcoding.get("IsVideoDirect") is False:
|
||||
transcode_type.append("video")
|
||||
if transcoding.get("IsAudioDirect") is False:
|
||||
transcode_type.append("audio")
|
||||
if not transcode_type:
|
||||
transcode_type.append("active")
|
||||
|
||||
results.append(
|
||||
{
|
||||
"user": session.get("UserName") or "Unknown",
|
||||
"title": title,
|
||||
"type": item.get("Type", "") if has_item else "",
|
||||
"state": state_label,
|
||||
"transcoding": "yes" if is_transcoding else "no",
|
||||
"transcoding_type": ", ".join(transcode_type),
|
||||
"device": session.get("DeviceName") or session.get("Client") or "",
|
||||
"session_id": session.get("Id") or "",
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
|
||||
@router.get("/activity")
|
||||
def get_activity(
|
||||
client: JellyfinClient = Depends(get_jellyfin_client),
|
||||
@@ -154,38 +113,4 @@ def get_now_playing(
|
||||
def get_backup_dashboard(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> BackupDashboardSummary:
|
||||
jobs = store.list_backup_jobs()
|
||||
total_jobs = len(jobs)
|
||||
|
||||
# Calculate 24h success rate
|
||||
cutoff = int(time.time()) - (24 * 60 * 60)
|
||||
recent_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], limit=1)
|
||||
if runs and runs[0]["started_at"] >= cutoff:
|
||||
recent_runs.append(runs[0])
|
||||
|
||||
successful = sum(1 for r in recent_runs if r["status"] == "success")
|
||||
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
|
||||
|
||||
# Active alerts
|
||||
alerts = store.list_backup_alerts(acknowledged=False)
|
||||
active_alerts = len(alerts)
|
||||
|
||||
# Last failed
|
||||
failed_runs = []
|
||||
for job in jobs:
|
||||
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
|
||||
if runs:
|
||||
failed_runs.append(runs[0])
|
||||
|
||||
last_failed_at = None
|
||||
if failed_runs:
|
||||
last_failed_at = max(r["started_at"] for r in failed_runs)
|
||||
|
||||
return BackupDashboardSummary(
|
||||
total_jobs=total_jobs,
|
||||
success_rate_24h=round(success_rate, 1),
|
||||
active_alerts=active_alerts,
|
||||
last_failed_at=last_failed_at,
|
||||
)
|
||||
return build_backup_dashboard_summary(store)
|
||||
|
||||
@@ -1,18 +1,14 @@
|
||||
"""Monitoring router — disk checks, action history, and observability stack status."""
|
||||
"""Monitoring router — observability stack status (Alertmanager + Prometheus)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Body, Depends
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.monitoring_actions import (
|
||||
disk_space,
|
||||
run_machine_operation,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.services.targets import build_node_exporter_targets
|
||||
|
||||
@@ -68,80 +64,12 @@ def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
|
||||
|
||||
|
||||
def _resolve_machine(store: SettingsStore, machine_id: str | None) -> dict[str, Any]:
|
||||
"""Return the requested machine or the first enabled machine.
|
||||
|
||||
Monitoring is treated as a machine-by-machine view. If a machine is
|
||||
explicitly requested but disabled, we surface that as a user-facing error so
|
||||
the Settings tab can be used to re-enable it.
|
||||
"""
|
||||
machines = store.list_machines()
|
||||
if machine_id:
|
||||
machine = next((item for item in machines if item["id"] == machine_id), None)
|
||||
if not machine:
|
||||
raise HTTPException(status_code=404, detail="Monitoring machine not found")
|
||||
if not machine.get("enabled"):
|
||||
raise HTTPException(status_code=409, detail=f"Monitoring machine '{machine['name']}' is disabled")
|
||||
return machine
|
||||
|
||||
for machine in machines:
|
||||
if machine.get("enabled"):
|
||||
return machine
|
||||
raise HTTPException(status_code=404, detail="No enabled monitoring machines configured")
|
||||
|
||||
|
||||
@router.get("/machines")
|
||||
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
"""Return enabled monitoring machines for the UI."""
|
||||
return [m for m in store.list_machines() if m.get("enabled")]
|
||||
|
||||
|
||||
@router.get("/poller")
|
||||
def get_poller_status() -> dict[str, Any]:
|
||||
"""Return the backend poller status and configuration."""
|
||||
from media_library_viewer_api.dependencies import get_monitoring_poller
|
||||
|
||||
poller = get_monitoring_poller().snapshot()
|
||||
logger.info(
|
||||
"Monitoring poller status requested running=%s poll_count=%s",
|
||||
poller.get("worker_running"),
|
||||
poller.get("poll_count"),
|
||||
)
|
||||
return poller
|
||||
|
||||
|
||||
@router.get("/machines/{machine_id}/actions")
|
||||
def get_machine_actions(
|
||||
machine_id: str,
|
||||
limit: int = 20,
|
||||
action: str | None = None,
|
||||
status: str | None = None,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return recent action history for a single machine."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
actions = store.list_machine_actions(machine["id"], limit=limit, action=action, status=status)
|
||||
return {"items": actions, "total": len(actions)}
|
||||
|
||||
|
||||
@router.get("/disk")
|
||||
def get_disk_space(
|
||||
machine_id: str | None = Query(default=None),
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Return disk space for the configured path of a given machine."""
|
||||
machine = _resolve_machine(store, machine_id)
|
||||
app_settings = get_settings()
|
||||
path = str(machine.get("media_root") or app_settings.media_root or "/")
|
||||
logger.info("Monitoring disk requested machine_id=%s path=%s", machine["id"], path)
|
||||
return run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
f"disk lookup for {path}",
|
||||
lambda client: disk_space(client, path),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/prometheus-targets")
|
||||
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
|
||||
"""Return Prometheus file-SD targets for remote Node Exporters.
|
||||
|
||||
@@ -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"}
|
||||
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
|
||||
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.dependencies import get_monitoring_poller, get_settings_store
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
|
||||
from media_library_viewer_api.services.known_hosts import has_known_host
|
||||
from media_library_viewer_api.services.media_index import MediaIndex
|
||||
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
|
||||
password: str = ""
|
||||
media_root: str = ""
|
||||
path_prefix: str = ""
|
||||
jellyfin_url: str = ""
|
||||
jellyfin_user_id: str = ""
|
||||
jellyfin_api_key: str = ""
|
||||
jellyseerr_url: str = ""
|
||||
jellyseerr_api_key: str = ""
|
||||
notes: str = ""
|
||||
|
||||
|
||||
@@ -195,13 +190,8 @@ def post_machine(
|
||||
) -> dict[str, Any]:
|
||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
|
||||
_write_prometheus_targets(store)
|
||||
poller = get_monitoring_poller()
|
||||
try:
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
finally:
|
||||
poller.start()
|
||||
poller.kick()
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
return saved
|
||||
|
||||
|
||||
@@ -215,13 +205,8 @@ def put_machine(
|
||||
raise HTTPException(status_code=404, detail="Machine not found")
|
||||
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
|
||||
_write_prometheus_targets(store)
|
||||
poller = get_monitoring_poller()
|
||||
try:
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
finally:
|
||||
poller.start()
|
||||
poller.kick()
|
||||
saved_machine = MonitoringMachineInput.model_validate(saved)
|
||||
_validate_saved_machine_ssh(saved_machine, store)
|
||||
return saved
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""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 time
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from media_library_viewer_api.dependencies import get_settings_store
|
||||
from media_library_viewer_api.integrations.base import validate_config
|
||||
from media_library_viewer_api.integrations.registry import get_service_definition
|
||||
from media_library_viewer_api.models.widgets import (
|
||||
BuiltinWidgetKindInfo,
|
||||
WidgetDataResponse,
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.builtin import (
|
||||
BUILTIN_WIDGET_KINDS,
|
||||
is_builtin_kind,
|
||||
validate_builtin_config,
|
||||
)
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
build_service_record,
|
||||
get_builtin_adapter,
|
||||
get_service_adapter,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
|
||||
"""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
|
||||
|
||||
|
||||
@router.get("/builtin")
|
||||
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
|
||||
"""Return metadata for service-less built-in widget kinds."""
|
||||
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,
|
||||
)
|
||||
for wk in BUILTIN_WIDGET_KINDS.values()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
def list_instances(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return all persisted widget instances."""
|
||||
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
|
||||
|
||||
|
||||
@router.post("/instances", status_code=status.HTTP_201_CREATED)
|
||||
def create_instance(
|
||||
body: WidgetInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new widget instance."""
|
||||
_validate_widget_input(body, store)
|
||||
widget = store.upsert_widget(body.model_dump())
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
|
||||
@router.put("/instances/{widget_id}")
|
||||
def update_instance(
|
||||
widget_id: str,
|
||||
body: WidgetInstanceInput,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Update an existing widget instance."""
|
||||
existing = store.get_widget(widget_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
if body.id is not None and body.id != widget_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="ID in path does not match ID in body",
|
||||
)
|
||||
_validate_widget_input(body, store)
|
||||
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
|
||||
@router.delete("/instances/{widget_id}")
|
||||
def delete_instance(
|
||||
widget_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, str]:
|
||||
"""Delete a widget instance."""
|
||||
existing = store.get_widget(widget_id)
|
||||
if not existing:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
store.delete_widget(widget_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/instances/{widget_id}/data")
|
||||
async def fetch_data(
|
||||
widget_id: str,
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Fetch widget data through the registered source adapter."""
|
||||
widget = store.get_widget(widget_id)
|
||||
if not widget:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
|
||||
service_id = widget.get("service_id")
|
||||
widget_kind = widget.get("widget_kind") or ""
|
||||
|
||||
service: Any = None
|
||||
if service_id:
|
||||
service_row = store.get_service(service_id)
|
||||
if not service_row:
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
error=f"Service {service_id} not found",
|
||||
fetched_at=int(time.time()),
|
||||
).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:
|
||||
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
|
||||
except Exception as exc: # pragma: no cover - defensive
|
||||
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Widget data fetch failed",
|
||||
) from exc
|
||||
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
data=data if "error" not in data else None,
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
@@ -1,208 +0,0 @@
|
||||
"""Shared monitoring action helpers.
|
||||
|
||||
The router and the background poller both use these helpers so machine
|
||||
operations are recorded consistently whether they were triggered by a user
|
||||
request or by the backend's scheduled polling loop.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any, Callable
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from media_library_viewer_api.clients.local import LocalCommandClient
|
||||
from media_library_viewer_api.clients.ssh import RemoteSSHClient
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.observability import record_ssh_command
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_machine_client(machine: dict[str, Any], store: SettingsStore):
|
||||
"""Build the appropriate command client for a machine definition."""
|
||||
mode = str(machine.get("mode") or "local").strip().lower()
|
||||
if mode == "local":
|
||||
return LocalCommandClient()
|
||||
|
||||
key_directory = str(machine.get("key_directory") or "").strip()
|
||||
key_name = str(machine.get("key_name") or "").strip()
|
||||
key_path = f"{key_directory}/{key_name}" if key_directory and key_name else None
|
||||
private_key = str(machine.get("ssh_private_key") or "")
|
||||
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
|
||||
ssh_key_id = str(machine.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 private_key)
|
||||
passphrase = str(ssh_key.get("passphrase") or passphrase)
|
||||
|
||||
settings = get_settings()
|
||||
return RemoteSSHClient(
|
||||
host=str(machine.get("host") or ""),
|
||||
username=str(machine.get("username") or ""),
|
||||
port=int(machine.get("port") or 22),
|
||||
key_filename=key_path,
|
||||
private_key=private_key or None,
|
||||
private_key_passphrase=passphrase or None,
|
||||
password=str(machine.get("password") or "") or None,
|
||||
known_hosts_path=str(settings.ssh_known_hosts_file),
|
||||
)
|
||||
|
||||
|
||||
def disk_space(client: Any, path: str = "/") -> dict[str, Any]:
|
||||
"""Return df information for the filesystem containing ``path``.
|
||||
|
||||
Works against any client with a ``run`` method (local shell or SSH).
|
||||
"""
|
||||
command = (
|
||||
"df -P -B1 -- " + shlex.quote(path or "/") + " | awk 'NR==2 {printf "
|
||||
'"{\\"filesystem\\":\\"%s\\",\\"size\\":%s,"'
|
||||
'"\\"used\\":%s,\\"available\\":%s,"'
|
||||
'"\\"used_pct\\":\\"%s\\",\\"mount\\":\\"%s\\"}", "'
|
||||
"$1,$2,$3,$4,$5,$6}'"
|
||||
)
|
||||
logger.debug("Reading disk space for path=%s", path)
|
||||
result = client.run(command, timeout=20)
|
||||
if result.exit_status != 0 or not result.stdout.strip():
|
||||
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
|
||||
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
|
||||
data = json.loads(result.stdout)
|
||||
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
|
||||
return data
|
||||
|
||||
|
||||
def summarize_operation_result(action: str, result: Any) -> str:
|
||||
"""Turn an operation result into a compact human-readable summary."""
|
||||
if result is None:
|
||||
return action
|
||||
if isinstance(result, str):
|
||||
text = result.strip().splitlines()[0] if result.strip() else action
|
||||
return text[:200]
|
||||
if isinstance(result, list):
|
||||
return f"{action}: {len(result)} item(s)"
|
||||
if isinstance(result, dict):
|
||||
if action.startswith("disk lookup"):
|
||||
used_pct = result.get("used_pct")
|
||||
mount = result.get("mount") or result.get("filesystem")
|
||||
return f"disk {mount or ''} used {used_pct or '?'}".strip()
|
||||
if "message" in result and isinstance(result["message"], str):
|
||||
return result["message"][:200]
|
||||
return json_compact(result)
|
||||
return action
|
||||
|
||||
|
||||
def json_compact(value: Any) -> str:
|
||||
try:
|
||||
text = json.dumps(value, sort_keys=True, default=str)
|
||||
return text[:200]
|
||||
except Exception:
|
||||
return str(value)[:200]
|
||||
|
||||
|
||||
def run_machine_operation(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
action: str,
|
||||
callback: Callable[[Any], Any],
|
||||
*,
|
||||
summarize: Callable[[Any], str] | None = None,
|
||||
request_id: str = "",
|
||||
raise_http: bool = True,
|
||||
client: Any | None = None,
|
||||
) -> Any:
|
||||
"""Run a machine operation, record history, and optionally raise on failure."""
|
||||
started = time.perf_counter()
|
||||
if client is None:
|
||||
client = build_machine_client(machine, store)
|
||||
try:
|
||||
result = callback(client)
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
record_ssh_command(
|
||||
machine_id=machine.get("id") or "unknown",
|
||||
action=action,
|
||||
status="ok",
|
||||
duration_seconds=duration_ms / 1000.0,
|
||||
)
|
||||
store.record_machine_action(
|
||||
machine,
|
||||
action,
|
||||
"ok",
|
||||
duration_ms=duration_ms,
|
||||
request_id=request_id,
|
||||
message=(summarize(result) if summarize else summarize_operation_result(action, result)),
|
||||
)
|
||||
return result
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc: # pragma: no cover - transport/network fallback
|
||||
duration_ms = int((time.perf_counter() - started) * 1000)
|
||||
record_ssh_command(
|
||||
machine_id=machine.get("id") or "unknown",
|
||||
action=action,
|
||||
status="error",
|
||||
duration_seconds=duration_ms / 1000.0,
|
||||
)
|
||||
logger.exception(
|
||||
"Monitoring %s failed machine_id=%s machine_name=%s",
|
||||
action,
|
||||
machine["id"],
|
||||
machine["name"],
|
||||
)
|
||||
error_text = str(exc)
|
||||
store.record_machine_action(
|
||||
machine,
|
||||
action,
|
||||
"error",
|
||||
duration_ms=duration_ms,
|
||||
request_id=request_id,
|
||||
error=error_text,
|
||||
)
|
||||
if not raise_http:
|
||||
return None
|
||||
status_code = 503 if machine.get("mode") == "local" else 502
|
||||
raise HTTPException(
|
||||
status_code=status_code,
|
||||
detail=f"{machine['name']}: {action} failed: {exc}",
|
||||
) from exc
|
||||
|
||||
|
||||
def poll_machine_snapshot(
|
||||
machine: dict[str, Any],
|
||||
store: SettingsStore,
|
||||
*,
|
||||
metrics_limit: int = 70_000,
|
||||
request_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Collect a backend-scheduled snapshot for a machine.
|
||||
|
||||
The legacy POSIX collector has been removed; this now records a lightweight
|
||||
disk-space lookup on the same schedule so action history stays useful.
|
||||
"""
|
||||
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
|
||||
results: dict[str, Any] = {"request_id": request_id, "machine_id": machine.get("id"), "actions": []}
|
||||
|
||||
client = build_machine_client(machine, store)
|
||||
settings = get_settings()
|
||||
path = str(machine.get("media_root") or settings.media_root or "/")
|
||||
disk = run_machine_operation(
|
||||
machine,
|
||||
store,
|
||||
f"disk lookup for {path}",
|
||||
lambda client: disk_space(client, path),
|
||||
request_id=request_id,
|
||||
raise_http=False,
|
||||
client=client,
|
||||
)
|
||||
results["disk_mount"] = (disk or {}).get("mount") if isinstance(disk, dict) else None
|
||||
results["actions"].append("disk lookup")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
"""Background poller for monitoring machine snapshots.
|
||||
|
||||
The poller runs entirely inside the backend. It periodically reads the defined
|
||||
machines, collects a small snapshot from each enabled machine over SSH or local
|
||||
shell execution, and stores the resulting history rows in the settings DB.
|
||||
|
||||
This keeps the Monitoring page populated without any daemon or agent running on
|
||||
the remote machines.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollerConfig:
|
||||
interval_seconds: int = 300
|
||||
initial_delay_seconds: int = 20
|
||||
metrics_limit: int = 70_000
|
||||
retention_days: int = 30
|
||||
|
||||
|
||||
class MonitoringPoller:
|
||||
"""Single-worker background poller for monitoring snapshots."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._thread: threading.Thread | None = None
|
||||
self._stop_event = threading.Event()
|
||||
self._lock = threading.Lock()
|
||||
self._last_run_at: float | None = None
|
||||
self._last_success_at: float | None = None
|
||||
self._last_error: str = ""
|
||||
self._last_cycle_ms: int | None = None
|
||||
self._poll_count = 0
|
||||
self._error_count = 0
|
||||
|
||||
def _config(self) -> PollerConfig:
|
||||
settings = get_settings()
|
||||
return PollerConfig(
|
||||
interval_seconds=max(30, int(getattr(settings, "monitoring_poll_interval_seconds", 300) or 300)),
|
||||
initial_delay_seconds=max(0, int(getattr(settings, "monitoring_poll_initial_delay_seconds", 20) or 20)),
|
||||
metrics_limit=70_000,
|
||||
retention_days=max(1, int(getattr(settings, "monitoring_action_retention_days", 30) or 30)),
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the background worker if it is not already running."""
|
||||
with self._lock:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop_event.clear()
|
||||
self._thread = threading.Thread(target=self._run, name="monitoring-poller", daemon=True)
|
||||
self._thread.start()
|
||||
logger.info("Monitoring poller started")
|
||||
|
||||
def kick(self) -> None:
|
||||
"""Run one immediate snapshot cycle in the background."""
|
||||
store = get_settings_store()
|
||||
config = self._config()
|
||||
threading.Thread(
|
||||
target=self._run_cycle,
|
||||
args=(store, config),
|
||||
name="monitoring-poller-kick",
|
||||
daemon=True,
|
||||
).start()
|
||||
logger.info("Monitoring poller kick requested")
|
||||
|
||||
def stop(self, timeout: float = 5.0) -> None:
|
||||
"""Stop the worker thread and wait briefly for shutdown."""
|
||||
with self._lock:
|
||||
thread = self._thread
|
||||
if not thread:
|
||||
return
|
||||
self._stop_event.set()
|
||||
thread.join(timeout=timeout)
|
||||
if thread.is_alive():
|
||||
logger.warning("Monitoring poller did not stop within %.1fs", timeout)
|
||||
else:
|
||||
logger.info("Monitoring poller stopped")
|
||||
with self._lock:
|
||||
if self._thread is thread:
|
||||
self._thread = None
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
"""Return a small status snapshot for diagnostics and tests."""
|
||||
with self._lock:
|
||||
return {
|
||||
"worker_running": bool(self._thread and self._thread.is_alive()),
|
||||
"stop_requested": self._stop_event.is_set(),
|
||||
"last_run_at": self._last_run_at,
|
||||
"last_success_at": self._last_success_at,
|
||||
"last_error": self._last_error,
|
||||
"last_cycle_ms": self._last_cycle_ms,
|
||||
"poll_count": self._poll_count,
|
||||
"error_count": self._error_count,
|
||||
}
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
"""Return status plus the active polling configuration."""
|
||||
data = self.status()
|
||||
config = self._config()
|
||||
data.update(
|
||||
{
|
||||
"interval_seconds": config.interval_seconds,
|
||||
"initial_delay_seconds": config.initial_delay_seconds,
|
||||
"retention_days": config.retention_days,
|
||||
}
|
||||
)
|
||||
return data
|
||||
|
||||
def _run_cycle(self, store: SettingsStore, config: PollerConfig) -> None:
|
||||
start = time.perf_counter()
|
||||
machines = store.list_machines()
|
||||
enabled = [machine for machine in machines if machine.get("enabled")]
|
||||
logger.info("Monitoring poll cycle starting enabled_machines=%s", len(enabled))
|
||||
cycle_errors = 0
|
||||
for machine in enabled:
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
try:
|
||||
snapshot = poll_machine_snapshot(
|
||||
machine,
|
||||
store,
|
||||
metrics_limit=config.metrics_limit,
|
||||
request_id=f"poll:{machine['id']}:{int(time.time())}",
|
||||
)
|
||||
logger.info(
|
||||
"Monitoring poll snapshot machine_id=%s request_id=%s disk_mount=%s actions=%s",
|
||||
machine["id"],
|
||||
snapshot.get("request_id"),
|
||||
snapshot.get("disk_mount"),
|
||||
snapshot.get("actions"),
|
||||
)
|
||||
except Exception:
|
||||
cycle_errors += 1
|
||||
logger.exception(
|
||||
"Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"]
|
||||
)
|
||||
retention_seconds = config.retention_days * 24 * 60 * 60
|
||||
cutoff_ts = int(time.time()) - retention_seconds
|
||||
removed = store.prune_machine_actions(cutoff_ts)
|
||||
if removed:
|
||||
logger.info("Pruned %s old monitoring action rows older than %s", removed, cutoff_ts)
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
with self._lock:
|
||||
self._last_run_at = time.time()
|
||||
self._last_cycle_ms = duration_ms
|
||||
self._poll_count += 1
|
||||
if cycle_errors:
|
||||
self._error_count += cycle_errors
|
||||
self._last_error = f"{cycle_errors} machine(s) failed"
|
||||
else:
|
||||
self._last_success_at = self._last_run_at
|
||||
self._last_error = ""
|
||||
logger.info(
|
||||
"Monitoring poll cycle complete enabled_machines=%s errors=%s duration_ms=%s removed_rows=%s",
|
||||
len(enabled),
|
||||
cycle_errors,
|
||||
duration_ms,
|
||||
removed,
|
||||
)
|
||||
|
||||
def _run(self) -> None:
|
||||
config = self._config()
|
||||
if config.initial_delay_seconds:
|
||||
logger.info("Monitoring poller initial delay=%ss", config.initial_delay_seconds)
|
||||
if self._stop_event.wait(config.initial_delay_seconds):
|
||||
return
|
||||
store = get_settings_store()
|
||||
while not self._stop_event.is_set():
|
||||
try:
|
||||
self._run_cycle(store, config)
|
||||
except Exception:
|
||||
with self._lock:
|
||||
self._last_error = "poller cycle failed"
|
||||
self._error_count += 1
|
||||
logger.exception("Monitoring poller cycle failed")
|
||||
if self._stop_event.wait(config.interval_seconds):
|
||||
break
|
||||
|
||||
|
||||
_MONITORING_POLLER = MonitoringPoller()
|
||||
|
||||
|
||||
def get_monitoring_poller() -> MonitoringPoller:
|
||||
return _MONITORING_POLLER
|
||||
@@ -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
|
||||
@@ -18,6 +18,7 @@ from typing import Any
|
||||
import paramiko
|
||||
|
||||
from media_library_viewer_api.config import get_settings
|
||||
from media_library_viewer_api.models.widgets import _validate_config_keys
|
||||
|
||||
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
|
||||
LOCAL_MACHINE_ID = "local"
|
||||
@@ -43,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
|
||||
"password": "",
|
||||
"media_root": settings.media_root,
|
||||
"path_prefix": settings.path_prefix,
|
||||
"jellyfin_url": "",
|
||||
"jellyfin_user_id": "",
|
||||
"jellyfin_api_key": "",
|
||||
"jellyseerr_url": "",
|
||||
"jellyseerr_api_key": "",
|
||||
"node_exporter_enabled": False,
|
||||
"node_exporter_port": 9100,
|
||||
"node_exporter_scrape_host": "",
|
||||
@@ -85,25 +81,10 @@ class SettingsStore:
|
||||
"""
|
||||
)
|
||||
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS monitoring_machine_actions (
|
||||
id TEXT PRIMARY KEY,
|
||||
machine_id TEXT NOT NULL,
|
||||
machine_name TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
duration_ms INTEGER NOT NULL,
|
||||
request_id TEXT NOT NULL,
|
||||
message TEXT NOT NULL,
|
||||
error TEXT NOT NULL,
|
||||
stdout_tail TEXT NOT NULL,
|
||||
stderr_tail TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
|
||||
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
|
||||
# table on startup so existing databases get a clean slate.
|
||||
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS ssh_keys (
|
||||
@@ -180,16 +161,25 @@ class SettingsStore:
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time
|
||||
ON monitoring_machine_actions(machine_id, created_at DESC)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status
|
||||
ON monitoring_machine_actions(action, status)
|
||||
CREATE TABLE IF NOT EXISTS dashboard_widgets (
|
||||
id TEXT PRIMARY KEY,
|
||||
addon_id TEXT NOT NULL,
|
||||
widget_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
config_json TEXT NOT NULL DEFAULT '{}',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute("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("""
|
||||
CREATE TABLE IF NOT EXISTS backup_jobs (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -234,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_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
|
||||
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
|
||||
@@ -273,11 +301,6 @@ class SettingsStore:
|
||||
"password_set": bool(data.get("password")),
|
||||
"media_root": data.get("media_root", ""),
|
||||
"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_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
@@ -327,17 +350,6 @@ class SettingsStore:
|
||||
password = str(password or "")
|
||||
media_root = _current_str("media_root")
|
||||
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(
|
||||
payload.get("node_exporter_enabled")
|
||||
if payload.get("node_exporter_enabled") is not None
|
||||
@@ -369,23 +381,14 @@ class SettingsStore:
|
||||
"password": password,
|
||||
"media_root": media_root,
|
||||
"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_port": node_exporter_port,
|
||||
"node_exporter_scrape_host": node_exporter_scrape_host,
|
||||
"notes": notes,
|
||||
}
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if row and int(row[0]) > 0:
|
||||
return
|
||||
def _seed_local_machine(self) -> None:
|
||||
"""Seed the default local machine if none exists."""
|
||||
machine = _default_local_machine()
|
||||
now = int(time.time())
|
||||
config = {
|
||||
@@ -401,11 +404,6 @@ class SettingsStore:
|
||||
"password": "",
|
||||
"media_root": machine["media_root"],
|
||||
"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_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
@@ -428,6 +426,22 @@ class SettingsStore:
|
||||
),
|
||||
)
|
||||
|
||||
def _seed_dashboard_widgets(self) -> None:
|
||||
"""Default widget seeding was removed.
|
||||
|
||||
Widgets are now service-bound (or built-in). A fresh install starts with
|
||||
no widgets; the user configures services and adds widgets from the UI.
|
||||
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
|
||||
"""
|
||||
return None
|
||||
|
||||
def ensure_defaults(self) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
|
||||
if not row or int(row[0]) == 0:
|
||||
self._seed_local_machine()
|
||||
|
||||
def list_machines(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
@@ -475,11 +489,6 @@ class SettingsStore:
|
||||
"password": data.get("password", ""),
|
||||
"media_root": data.get("media_root", ""),
|
||||
"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_port": int(data.get("node_exporter_port", 9100) or 9100),
|
||||
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
|
||||
@@ -519,11 +528,6 @@ class SettingsStore:
|
||||
"password": machine["password"],
|
||||
"media_root": machine["media_root"],
|
||||
"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_port": machine["node_exporter_port"],
|
||||
"node_exporter_scrape_host": machine["node_exporter_scrape_host"],
|
||||
@@ -563,88 +567,6 @@ class SettingsStore:
|
||||
with self.connect() as conn:
|
||||
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
|
||||
|
||||
def record_machine_action(
|
||||
self,
|
||||
machine: dict[str, Any],
|
||||
action: str,
|
||||
status: str,
|
||||
*,
|
||||
duration_ms: int,
|
||||
request_id: str = "",
|
||||
message: str = "",
|
||||
error: str = "",
|
||||
stdout_tail: str = "",
|
||||
stderr_tail: str = "",
|
||||
) -> None:
|
||||
"""Store a compact action history row for a machine operation."""
|
||||
self.init_schema()
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO monitoring_machine_actions
|
||||
(
|
||||
id, machine_id, machine_name, mode, action, status,
|
||||
created_at, duration_ms, request_id, message, error,
|
||||
stdout_tail, stderr_tail
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
uuid.uuid4().hex,
|
||||
str(machine.get("id") or ""),
|
||||
str(machine.get("name") or ""),
|
||||
str(machine.get("mode") or "local"),
|
||||
action,
|
||||
status,
|
||||
now,
|
||||
duration_ms,
|
||||
request_id,
|
||||
message,
|
||||
error,
|
||||
stdout_tail,
|
||||
stderr_tail,
|
||||
),
|
||||
)
|
||||
|
||||
def list_machine_actions(
|
||||
self,
|
||||
machine_id: str,
|
||||
*,
|
||||
limit: int = 20,
|
||||
action: str | None = None,
|
||||
status: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
clauses = ["machine_id = ?"]
|
||||
params: list[Any] = [machine_id]
|
||||
if action:
|
||||
clauses.append("action = ?")
|
||||
params.append(action)
|
||||
if status:
|
||||
clauses.append("status = ?")
|
||||
params.append(status)
|
||||
sql = (
|
||||
"SELECT machine_id, machine_name, mode, action, status, "
|
||||
"created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail "
|
||||
f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} "
|
||||
"ORDER BY created_at DESC LIMIT ?"
|
||||
)
|
||||
params.append(max(1, min(int(limit), 200)))
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
|
||||
def prune_machine_actions(self, older_than_ts: int) -> int:
|
||||
"""Delete action history rows older than the given timestamp."""
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
cur = conn.execute(
|
||||
"DELETE FROM monitoring_machine_actions WHERE created_at < ?",
|
||||
(int(older_than_ts),),
|
||||
)
|
||||
return int(cur.rowcount or 0)
|
||||
|
||||
@staticmethod
|
||||
def _private_key_summary(private_key: str) -> dict[str, str]:
|
||||
if not private_key:
|
||||
@@ -1414,6 +1336,318 @@ class SettingsStore:
|
||||
(key, value, now),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Dashboard widgets
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
|
||||
keys = row.keys()
|
||||
return {
|
||||
"id": row["id"],
|
||||
"addon_id": row["addon_id"],
|
||||
"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"],
|
||||
"config": json.loads(row["config_json"] or "{}"),
|
||||
"enabled": bool(row["enabled"]),
|
||||
"sort_order": int(row["sort_order"]),
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
|
||||
def _normalize_widget_payload(
|
||||
self,
|
||||
payload: dict[str, Any],
|
||||
widget_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
current = self.get_widget(widget_id) if widget_id else None
|
||||
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
|
||||
service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
|
||||
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
|
||||
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
|
||||
config = payload.get("config", (current or {}).get("config", {}))
|
||||
if not isinstance(config, dict):
|
||||
config = {}
|
||||
# Defense-in-depth: reject credential keys at the store layer too.
|
||||
_validate_config_keys(config)
|
||||
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)
|
||||
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
|
||||
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
|
||||
return {
|
||||
"id": widget_id,
|
||||
"addon_id": "",
|
||||
"widget_type": widget_type,
|
||||
"service_id": service_id,
|
||||
"widget_kind": widget_kind,
|
||||
"title": title,
|
||||
"config": config,
|
||||
"enabled": enabled,
|
||||
"sort_order": sort_order,
|
||||
}
|
||||
|
||||
def list_widgets(self) -> list[dict[str, Any]]:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
|
||||
return [self._row_to_widget(row) for row in rows]
|
||||
|
||||
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
|
||||
if not widget_id:
|
||||
return None
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
row = conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone()
|
||||
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]:
|
||||
self.init_schema()
|
||||
widget = self._normalize_widget_payload(payload, widget_id)
|
||||
now = int(time.time())
|
||||
with self.connect() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT created_at FROM dashboard_widgets WHERE id = ?",
|
||||
(widget["id"],),
|
||||
).fetchone()
|
||||
created_at = int(existing[0]) if existing else now
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO dashboard_widgets (
|
||||
id, addon_id, widget_type, service_id, widget_kind, title,
|
||||
config_json, enabled, sort_order, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
addon_id = excluded.addon_id,
|
||||
widget_type = excluded.widget_type,
|
||||
service_id = excluded.service_id,
|
||||
widget_kind = excluded.widget_kind,
|
||||
title = excluded.title,
|
||||
config_json = excluded.config_json,
|
||||
enabled = excluded.enabled,
|
||||
sort_order = excluded.sort_order,
|
||||
updated_at = excluded.updated_at
|
||||
""",
|
||||
(
|
||||
widget["id"],
|
||||
widget["addon_id"],
|
||||
widget["widget_type"],
|
||||
widget["service_id"],
|
||||
widget["widget_kind"],
|
||||
widget["title"],
|
||||
json.dumps(widget["config"]),
|
||||
1 if widget["enabled"] else 0,
|
||||
widget["sort_order"],
|
||||
created_at,
|
||||
now,
|
||||
),
|
||||
)
|
||||
return self.get_widget(widget["id"]) or widget
|
||||
|
||||
def delete_widget(self, widget_id: str) -> None:
|
||||
self.init_schema()
|
||||
with self.connect() as conn:
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Widget subsystem package."""
|
||||
@@ -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)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""Widget source adapters.
|
||||
|
||||
Adapters translate a widget instance into dashboard data. Service-bound widgets
|
||||
are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
|
||||
built-in widgets (backups, static) take ``service=None``.
|
||||
|
||||
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
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import shlex
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Protocol
|
||||
|
||||
import requests
|
||||
|
||||
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.domain.dashboard import (
|
||||
_map_sessions_to_activity_rows,
|
||||
build_backup_dashboard_summary,
|
||||
)
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ServiceRecord:
|
||||
"""Runtime view of a service instance with decrypted secrets."""
|
||||
|
||||
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):
|
||||
"""Protocol for widget source adapters."""
|
||||
|
||||
async def fetch(
|
||||
self,
|
||||
service: ServiceRecord | None,
|
||||
widget_kind: str,
|
||||
config: dict[str, Any],
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Built-in (service-less) adapters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BackupsWidgetSource:
|
||||
"""Compute the backup dashboard summary from internal tables."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
store = get_settings_store()
|
||||
summary = build_backup_dashboard_summary(store)
|
||||
return summary.model_dump()
|
||||
except Exception as exc:
|
||||
logger.exception("backups adapter failed")
|
||||
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:
|
||||
"""Build a Grafana deep-link (no embedding)."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
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")
|
||||
if not dashboard_uid:
|
||||
return {"error": "dashboard_uid is required"}
|
||||
url = f"{base_url}/d/{dashboard_uid}"
|
||||
panel_id = config.get("panel_id")
|
||||
if panel_id is not None:
|
||||
url = f"{url}?viewPanel={panel_id}"
|
||||
return {"url": url}
|
||||
except Exception as exc:
|
||||
logger.exception("grafana adapter failed")
|
||||
return {"error": f"Grafana link failed: {exc}"}
|
||||
|
||||
|
||||
class PrometheusWidgetSource:
|
||||
"""Run a PromQL instant query against a Prometheus service."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
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")
|
||||
if not promql:
|
||||
return {"error": "promql is required"}
|
||||
url = f"{base_url}/api/v1/query"
|
||||
response = await asyncio.wait_for(
|
||||
asyncio.to_thread(
|
||||
requests.get,
|
||||
url,
|
||||
params={"query": promql},
|
||||
timeout=timeout,
|
||||
),
|
||||
timeout=timeout,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
return {"result": payload.get("data", {})}
|
||||
except asyncio.TimeoutError:
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except requests.RequestException as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
except Exception as exc:
|
||||
logger.exception("prometheus adapter failed")
|
||||
return {"error": f"Prometheus query failed: {exc}"}
|
||||
|
||||
|
||||
class JellyfinWidgetSource:
|
||||
"""Fetch Jellyfin sessions and map them to activity rows."""
|
||||
|
||||
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
timeout = 10
|
||||
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()
|
||||
task_id = config.get("task_id") or ""
|
||||
if not task_id:
|
||||
return {"error": "task_id is required"}
|
||||
task = store.get_task(task_id)
|
||||
if not task:
|
||||
return {"error": f"Task {task_id} not found"}
|
||||
if not task.get("enabled", True):
|
||||
return {"error": "Task is disabled"}
|
||||
|
||||
client = _build_ssh_client(store, service)
|
||||
timeout = int(service.config.get("timeout_seconds") or 30)
|
||||
task_type = str(task.get("task_type") or "shell").lower()
|
||||
command = str(task.get("content") or "")
|
||||
if task_type == "python":
|
||||
command = f"python3 -c {shlex.quote(command)}"
|
||||
elif task_type != "shell":
|
||||
return {"error": f"Unknown task type: {task_type}"}
|
||||
|
||||
start = time.perf_counter()
|
||||
result = await asyncio.wait_for(
|
||||
asyncio.to_thread(client.run, command, timeout),
|
||||
timeout=timeout,
|
||||
)
|
||||
duration_ms = int((time.perf_counter() - start) * 1000)
|
||||
stdout = result.stdout 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:
|
||||
_record_timeout(service, config, timeout)
|
||||
return {"error": "Widget data fetch timed out"}
|
||||
except Exception as exc:
|
||||
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}"}
|
||||
|
||||
|
||||
def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
|
||||
try:
|
||||
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": "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")
|
||||
|
||||
|
||||
def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient:
|
||||
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
|
||||
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(),
|
||||
"prometheus": PrometheusWidgetSource(),
|
||||
"jellyfin": JellyfinWidgetSource(),
|
||||
"ssh_tasks": SshTaskWidgetSource(),
|
||||
}
|
||||
|
||||
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
|
||||
"backups": BackupsWidgetSource(),
|
||||
"static": StaticWidgetSource(),
|
||||
}
|
||||
|
||||
|
||||
def get_service_adapter(service_type: str) -> WidgetSource | None:
|
||||
return SERVICE_ADAPTERS.get(service_type)
|
||||
|
||||
|
||||
def get_builtin_adapter(kind: str) -> WidgetSource | None:
|
||||
return BUILTIN_ADAPTERS.get(kind)
|
||||
@@ -598,34 +598,6 @@ class TestJobs:
|
||||
|
||||
|
||||
class TestMonitoring:
|
||||
def _ensure_machine(self):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
if not store.list_machines():
|
||||
store.upsert_machine(
|
||||
{
|
||||
"name": "Test Machine",
|
||||
"mode": "ssh",
|
||||
"enabled": True,
|
||||
"services": ["monitoring", "files", "jellyfin"],
|
||||
"host": "test-host",
|
||||
"username": "test-user",
|
||||
}
|
||||
)
|
||||
|
||||
def test_disk(self, test_client, mock_ssh):
|
||||
self._ensure_machine()
|
||||
mock_ssh.run.return_value = CommandResult(
|
||||
command="df ...",
|
||||
exit_status=0,
|
||||
stdout='{"filesystem":"/dev/sda1","size":1000000000,"used":500000000,"available":500000000,"used_pct":"50%","mount":"/"}',
|
||||
stderr="",
|
||||
)
|
||||
with patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=mock_ssh):
|
||||
response = test_client.get("/api/monitoring/disk")
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["used_pct"] == "50%"
|
||||
|
||||
def test_prometheus_targets_empty(self, test_client):
|
||||
response = test_client.get("/api/monitoring/prometheus-targets")
|
||||
assert response.status_code == 200
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
|
||||
|
||||
|
||||
def test_poll_machine_snapshot_records_disk_lookup():
|
||||
store = MagicMock()
|
||||
machine = {
|
||||
"id": "local",
|
||||
"name": "This machine",
|
||||
"mode": "local",
|
||||
"media_root": "/srv/media",
|
||||
}
|
||||
|
||||
with (
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.build_machine_client",
|
||||
return_value=object(),
|
||||
) as build_client,
|
||||
patch(
|
||||
"media_library_viewer_api.services.monitoring_actions.disk_space",
|
||||
return_value={"mount": "/srv/media", "used_pct": "12.5%"},
|
||||
) as disk_fn,
|
||||
):
|
||||
result = poll_machine_snapshot(machine, store, metrics_limit=123, request_id="poll:test")
|
||||
|
||||
assert result["request_id"] == "poll:test"
|
||||
assert result["disk_mount"] == "/srv/media"
|
||||
assert result["actions"] == ["disk lookup"]
|
||||
build_client.assert_called_once_with(machine, store)
|
||||
disk_fn.assert_called_once_with(build_client.return_value, "/srv/media")
|
||||
assert store.record_machine_action.call_count == 1
|
||||
recorded_action = store.record_machine_action.call_args
|
||||
assert recorded_action.args[1] == "disk lookup for /srv/media"
|
||||
assert recorded_action.kwargs["request_id"] == "poll:test"
|
||||
assert recorded_action.args[2] == "ok"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Tests for the dashboard widget system: service-bound + built-in widgets."""
|
||||
|
||||
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.main import app
|
||||
from media_library_viewer_api.services.settings_store import SettingsStore
|
||||
from media_library_viewer_api.widgets.sources import (
|
||||
BackupsWidgetSource,
|
||||
GrafanaWidgetSource,
|
||||
ServiceRecord,
|
||||
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
|
||||
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()
|
||||
|
||||
|
||||
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
|
||||
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
|
||||
kinds = {item["kind"] for item in response.json()}
|
||||
assert kinds == {"backups", "static"}
|
||||
|
||||
|
||||
def test_create_and_read_static_widget(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"widget_kind": "static",
|
||||
"title": "Note",
|
||||
"config": {"text": "hello"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["widget_kind"] == "static"
|
||||
assert created["service_id"] is None
|
||||
assert created["config"]["text"] == "hello"
|
||||
|
||||
listed = client.get("/api/widgets/instances").json()
|
||||
assert len(listed) == 1
|
||||
assert listed[0]["id"] == created["id"]
|
||||
|
||||
|
||||
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(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201
|
||||
created = response.json()
|
||||
assert created["service_id"] == service["id"]
|
||||
assert created["widget_kind"] == "link"
|
||||
|
||||
|
||||
def test_service_bound_widget_unknown_kind_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "metric",
|
||||
"title": "x",
|
||||
"config": {},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_service_bound_widget_service_not_found_rejected(client):
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": "missing",
|
||||
"widget_kind": "link",
|
||||
"title": "x",
|
||||
"config": {"dashboard_uid": "u"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
|
||||
|
||||
def test_service_bound_widget_invalid_config_rejected(client):
|
||||
service = _make_grafana_service(client)
|
||||
response = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "x",
|
||||
"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
|
||||
|
||||
|
||||
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(
|
||||
"/api/widgets/instances/missing",
|
||||
json={"widget_kind": "static", "title": "x", "config": {}},
|
||||
)
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
def test_update_id_mismatch_returns_400(client):
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={"widget_kind": "static", "title": "x", "config": {}},
|
||||
).json()
|
||||
response = client.put(
|
||||
f"/api/widgets/instances/{created['id']}",
|
||||
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_fetch_static_widget_data(client):
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["data"]["text"] == "hello"
|
||||
assert body["error"] is None
|
||||
|
||||
|
||||
def test_fetch_backups_widget_data(client):
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={"widget_kind": "backups", "title": "Backups", "config": {}},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert "total_jobs" in response.json()["data"]
|
||||
|
||||
|
||||
def test_fetch_grafana_link_widget_data(client):
|
||||
service = _make_grafana_service(client)
|
||||
created = client.post(
|
||||
"/api/widgets/instances",
|
||||
json={
|
||||
"service_id": service["id"],
|
||||
"widget_kind": "link",
|
||||
"title": "Dashboard",
|
||||
"config": {"dashboard_uid": "overview", "panel_id": 2},
|
||||
},
|
||||
).json()
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
|
||||
|
||||
|
||||
def test_fetch_widget_service_not_found(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()
|
||||
# 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,
|
||||
},
|
||||
)
|
||||
response = client.get(f"/api/widgets/instances/{created['id']}/data")
|
||||
assert response.status_code == 200
|
||||
assert "disabled" in response.json()["error"]
|
||||
|
||||
|
||||
def test_fetch_widget_not_found(client):
|
||||
assert client.get("/api/widgets/instances/missing/data").status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Adapter unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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"
|
||||
|
||||
|
||||
@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
|
||||
async def test_static_adapter():
|
||||
adapter = StaticWidgetSource()
|
||||
result = await adapter.fetch(None, "static", {"text": "hi"})
|
||||
assert result == {"text": "hi"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_backups_adapter(client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
|
||||
adapter = BackupsWidgetSource()
|
||||
result = await adapter.fetch(None, "backups", {})
|
||||
assert "total_jobs" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_task_adapter_missing_service():
|
||||
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
|
||||
|
||||
adapter = SshTaskWidgetSource()
|
||||
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
|
||||
assert "error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ssh_task_adapter_records_history_on_run(client):
|
||||
store = app.dependency_overrides[get_settings_store]()
|
||||
# Save a task and an ssh_tasks service instance.
|
||||
task = store.upsert_task(
|
||||
{
|
||||
"name": "echo",
|
||||
"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"
|
||||
@@ -16,7 +16,8 @@ services:
|
||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
|
||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
|
||||
ports:
|
||||
- "8000:8000"
|
||||
volumes:
|
||||
@@ -37,6 +38,8 @@ services:
|
||||
VITE_API_URL: "/api"
|
||||
VITE_OIDC_ENABLED: "false"
|
||||
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
|
||||
VITE_GRAFANA_URL: "http://localhost:3000"
|
||||
VITE_PROMETHEUS_URL: "http://localhost:9090"
|
||||
ports:
|
||||
- "5173:5173"
|
||||
volumes:
|
||||
|
||||
+4
-1
@@ -27,7 +27,8 @@ services:
|
||||
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
|
||||
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
|
||||
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
|
||||
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
|
||||
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
|
||||
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
|
||||
volumes:
|
||||
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
|
||||
restart: unless-stopped
|
||||
@@ -69,6 +70,8 @@ services:
|
||||
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI:?set VITE_OIDC_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_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_BUILD_INFO: ${APP_BUILD_INFO:-dev}
|
||||
depends_on:
|
||||
|
||||
@@ -105,9 +105,9 @@ repo/
|
||||
| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals |
|
||||
| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown |
|
||||
| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info |
|
||||
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? |
|
||||
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples |
|
||||
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root |
|
||||
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? *(legacy/removed)* |
|
||||
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples *(legacy/removed)* |
|
||||
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root *(removed 2026-06-17; metrics now in Prometheus/Grafana)* |
|
||||
| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector |
|
||||
| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector |
|
||||
| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector |
|
||||
|
||||
+143
-2
@@ -10,6 +10,67 @@ Build Manage, a compact web application for browsing a remote Jellyfin media lib
|
||||
|
||||
Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates.
|
||||
|
||||
## Frontend Design System & Architecture
|
||||
|
||||
The Manage frontend is a React + TypeScript SPA built on a **single design system**.
|
||||
The legacy Material UI (MUI v9) / Emotion / recharts / D3 / `theme.ts` stack has been
|
||||
fully removed (web-ui-rework; see decision log 2026-06-17).
|
||||
|
||||
### Design system
|
||||
|
||||
- **shadcn/ui** components + **Tailwind CSS v4** + **lucide-react** icons are the only UI layer.
|
||||
- Design tokens live as CSS `@theme` tokens in `frontend/src/index.css` (light + `.dark`),
|
||||
with the primary brand color `#4f8cff`.
|
||||
- The `chart-1`..`chart-5` color tokens are **repurposed as status / Grafana-link color
|
||||
cues** (not charts): `chart-1`=info/brand, `chart-2`=success/healthy, `chart-3`=warning,
|
||||
`chart-4`=destructive, `chart-5`=neutral accent. No token value changed.
|
||||
- Removed from the frontend dependency tree: `@mui/material`, `@mui/icons-material`,
|
||||
`@mui/x-data-grid`, `@emotion/react`, `@emotion/styled`, `recharts`, `d3`, and the
|
||||
no-op `src/theme.ts` shim.
|
||||
|
||||
### Thin-dashboard observability model
|
||||
|
||||
- The app does **no in-app charting**. Metrics, charts, and logs live in the external,
|
||||
decoupled observability stack (Prometheus / Loki / Grafana / Alertmanager).
|
||||
- In-app observability surfaces (`/observability`) show **Alertmanager alerts, Prometheus
|
||||
target health, machine health, and Grafana deep-links** (per-machine metric/log panels),
|
||||
not rendered graphs.
|
||||
- The legacy in-app D3 monitoring charts and the POSIX remote resource collector are
|
||||
superseded by this Grafana-based model (see decision log 2026-06-13 and 2026-06-17).
|
||||
- **Manage no longer scrapes its own system metrics** (decision 2026-06-17). The backend
|
||||
`MonitoringPoller` (which SSH-ran `df` on every machine every 5 minutes into a local
|
||||
SQLite `monitoring_machine_actions` table), the `/api/monitoring/disk`, `/poller`, and
|
||||
`/machines/{id}/actions` endpoints, and the frontend `DiskSpaceCard` have been removed.
|
||||
Disk/CPU/memory visibility is owned by Prometheus + node_exporter + Grafana. The
|
||||
`disk_usage` **job template** in Actions remains as a manual on-demand SSH check.
|
||||
|
||||
### Tables
|
||||
|
||||
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
|
||||
wrapper (`components/ui/data-table.tsx`).
|
||||
- Parity is **visibility-only**: pagination, row selection, row click, and column
|
||||
visibility are supported. There is **no client sorting and no column resizing**.
|
||||
- Media uses **server-driven pagination** (`manualPagination` + `rowCount`); the File
|
||||
Browser renders the full listing without pagination.
|
||||
- The Media and File Browser tables previously used `@mui/x-data-grid`; both now use the
|
||||
TanStack `DataTable` (earlier "AG Grid" / `@mui/x-data-grid` references are superseded).
|
||||
|
||||
### Reconciled information architecture
|
||||
|
||||
- **Backups** is a top-level navigation item at `/backups`.
|
||||
- The media/applications surface is named **Media** and lives at `/media`; `/applications`
|
||||
redirects to `/media`, mirroring the existing `/monitoring` → `/observability` redirect.
|
||||
- User deep-links (`/users?user=<id>`), dashboard shortcut deep-links, and the Media →
|
||||
File Browser row-click navigation are preserved under the reconciled routes.
|
||||
|
||||
### Frontend testing
|
||||
|
||||
- Component tests run on **Vitest + @testing-library/react** (`npm test`), with the
|
||||
`@testing-library/jest-dom` matchers.
|
||||
- Legacy plain-Node suites (`frontend/tests/*.test.mjs`) run via
|
||||
`node --test tests/*.test.mjs` (npm script `test:node`).
|
||||
- The build/lint gate is `npm run build` (`tsc -b` + `vite build`) + `npm run lint` (ESLint).
|
||||
|
||||
## Core Requirements
|
||||
|
||||
### Jellyfin Library
|
||||
@@ -82,7 +143,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
||||
- Support manual path entry and refresh.
|
||||
- Remote file listing must be compact, structured, and navigable.
|
||||
- The file table should be read-only.
|
||||
- The file table should use row selection (single-select) in an AG Grid format consistent with the Media tab.
|
||||
- The file table should use row selection (single-select) in a TanStack `DataTable` format consistent with the Media tab (both migrated off the legacy `@mui/x-data-grid`/AG Grid).
|
||||
- The file table should not expose a visible checkbox selection column.
|
||||
- The file table should not show a visible `selected` column.
|
||||
- Include a top `[UP] ..` row, when not at `/`, to navigate to the parent directory.
|
||||
@@ -166,7 +227,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
||||
- The dashboard should present disk space as a single combined card with the progress/fill bar embedded inside the card and the size breakdown laid out clearly, with centered sub-card text for the Used/Free/Total breakdown and consistent vertical spacing across the dashboard cards.
|
||||
- The disk usage bar should change color as usage increases so high utilization is easy to notice at a glance.
|
||||
- The disk usage card should avoid redundant percentage labels next to the bar if the bar itself already communicates the value.
|
||||
- Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library.
|
||||
- (Superseded by the thin-dashboard observability model — 2026-06-17.) The app no longer renders in-app monitoring charts with D3; metrics/charts/logs live in the external Grafana stack, and the in-app Observability page surfaces Alertmanager alerts, Prometheus target health, and Grafana deep-links.
|
||||
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
|
||||
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
|
||||
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
|
||||
@@ -195,8 +256,83 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
||||
- Job templates should remain centralized in `jobs.py` for future extension.
|
||||
- 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
|
||||
|
||||
- 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: Completed the web UI rework to a single design system. The frontend now uses **shadcn/ui + Tailwind CSS v4 + lucide-react** exclusively, with CSS `@theme` tokens in `src/index.css` (primary `#4f8cff`; `chart-1..5` repurposed as status/Grafana-link cues). Removed `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, `@emotion/styled`, `recharts`, `d3`, and the `src/theme.ts` shim. Tables moved from `@mui/x-data-grid`/AG Grid to a visibility-only TanStack `DataTable` wrapper (pagination, row selection, row click, column visibility — no sorting/resizing). Adopted the thin-dashboard observability model (no in-app charts; Alertmanager alerts + Prometheus target health + Grafana deep-links). Reconciled the information architecture: Backups is a top-level nav item at `/backups`, and the media surface is named Media at `/media` with `/applications` redirecting to `/media` (mirroring `/monitoring` → `/observability`). Frontend tests moved to Vitest + @testing-library/react (`npm test`), with legacy node suites in `frontend/tests`.
|
||||
- 2026-06-13: Adopted a dedicated, self-hosted observability subsystem based on Prometheus, Grafana Loki, Grafana, and Alertmanager. Metrics will be pulled from Node Exporter on machines and from application exporters in containers; logs will be structured JSON shipped by Promtail/Grafana Alloy. The existing POSIX remote collector will be removed and backup alerts migrated to Alertmanager rules. See `docs/monitoring-logging-design.md`.
|
||||
- 2026-06-13 (Phase 1): Added Prometheus, Loki, Grafana Alloy, Grafana, Alertmanager, and Node Exporter services to `docker-compose.yml` and `docker-compose.dev.yml`. Provisioned Grafana datasources and an initial `Manage Overview` dashboard as code. Configured Alloy to tail Docker logs and ship to Loki. Added Grafana generic OAuth configuration via `monitoring/grafana/grafana.ini` and a dedicated Traefik host rule. Added Alertmanager email routing with env-var interpolation. Added `/grafana` proxy to the Vite dev server for iframe embedding.
|
||||
- 2026-06-13 (Phase 2): Extended machine settings with `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host`. Added Node Exporter install/restart/status job templates to `jobs.py`. Implemented `media_library_viewer_api.services.targets` to generate Prometheus file-SD target files and wired target regeneration into machine create/update/delete. Added `/api/monitoring/prometheus-targets` for live target previews. Configured Prometheus with a `node-exporter-remote` job reading file SD from the backend cache volume. Added a minimal `Node Exporter Overview` Grafana dashboard. Added unit and integration tests for target generation and the new endpoint.
|
||||
@@ -289,9 +425,11 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
|
||||
## Backup Monitoring
|
||||
|
||||
### Overview
|
||||
|
||||
The system receives backup execution reports from an external backup tool via HTTP API, stores job and run history, and provides alerting on failures, missed schedules, and anomalies.
|
||||
|
||||
### API
|
||||
|
||||
- `POST /api/backups/report` — Submit backup run (Bearer token auth)
|
||||
- `POST /api/backups/report/start` — Mark backup as in_progress
|
||||
- `GET /api/backups/jobs` — List jobs
|
||||
@@ -301,16 +439,19 @@ The system receives backup execution reports from an external backup tool via HT
|
||||
- `GET /api/dashboard/backups` — Dashboard summary
|
||||
|
||||
### Data Model
|
||||
|
||||
- **BackupJob**: id, name, source, target, schedule_interval_seconds, created_at
|
||||
- **BackupRun**: id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json
|
||||
- **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at
|
||||
|
||||
### Alert Types
|
||||
|
||||
- `failed_status` — Backup reported failure (critical)
|
||||
- `missed_schedule` — No run within 1.5x expected interval (warning)
|
||||
- `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning)
|
||||
- `anomaly_duration` — Duration >300% of 7-day median (warning)
|
||||
|
||||
### Authentication
|
||||
|
||||
- Backup tool uses auto-generated Bearer API key
|
||||
- Frontend uses existing OIDC/JWT auth
|
||||
|
||||
@@ -60,10 +60,16 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
|
||||
### Metrics
|
||||
|
||||
- `backend/src/media_library_viewer_api/clients/resources.py` deploys a POSIX shell collector to `/tmp` on each remote machine.
|
||||
- The collector samples `/proc/stat`, `/proc/meminfo`, `/proc/net/dev`, and `/sys/block/*/stat` every 10s and writes JSONL to `/tmp/media_library_viewer_metrics.jsonl`.
|
||||
- `MonitoringPoller` (`monitoring_poller.py`) runs every 5 minutes, reads the remote JSONL, and stores snapshots in SQLite (`monitoring_machine_actions`).
|
||||
- Retention defaults to 30 days with periodic pruning.
|
||||
> **Historical note (2026-06-17):** The legacy Manage-side `MonitoringPoller` that
|
||||
> SSH-scraped `/proc` + `df` into a local SQLite table (`monitoring_machine_actions`)
|
||||
> has been **decommissioned**. System metrics now live entirely in the external
|
||||
> observability stack: `node_exporter` on each machine is scraped by **Prometheus**
|
||||
> and visualised in **Grafana** (see the standalone `docker-compose.observability.yml`
|
||||
> stack). Manage is a thin dashboard: it surfaces Alertmanager alerts + Prometheus
|
||||
> target health + Grafana deep-links, and does not collect or store its own metrics.
|
||||
|
||||
- `main.py` has a `log_requests` middleware that emits method, path, client IP, status code, and elapsed time.
|
||||
- Frontend uses standard `console.log` / browser dev tools; no server-side log aggregation.
|
||||
|
||||
### Alerting
|
||||
|
||||
@@ -210,7 +216,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
- Manage API overview (request rate, latency, errors).
|
||||
- Manage operations (SSH commands, media index builds, mail queue).
|
||||
- Backup runs and alert history.
|
||||
- Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and ` kiosk` mode.
|
||||
- Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and `kiosk` mode.
|
||||
|
||||
### Manage React UI
|
||||
|
||||
@@ -309,6 +315,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
- [x] Wire Grafana OAuth to Authentik.
|
||||
|
||||
**Phase 1 files**:
|
||||
|
||||
- `monitoring/prometheus/prometheus.yml`
|
||||
- `monitoring/prometheus/rules/backup_alerts.yml`
|
||||
- `monitoring/loki/loki.yml`
|
||||
@@ -336,6 +343,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
- [x] Remove POSIX collector fallback. The legacy collector code in `backend/src/media_library_viewer_api/clients/resources.py` has been deleted, the collector control endpoints were removed from `routers/monitoring.py`, and `disk_space` was relocated to `services/monitoring_actions.py` as a lightweight SSH/local helper. Metrics are now sourced exclusively from Prometheus/Node Exporter.
|
||||
|
||||
**Phase 2 files**:
|
||||
|
||||
- `backend/src/media_library_viewer_api/jobs.py` (Node Exporter job templates).
|
||||
- `backend/src/media_library_viewer_api/routers/settings.py` (machine input fields + target regeneration).
|
||||
- `backend/src/media_library_viewer_api/services/settings_store.py` (machine persistence fields).
|
||||
@@ -364,6 +372,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
- [x] Added tests for the Alertmanager endpoints and the backup success gauge.
|
||||
|
||||
**Phase 3 files**:
|
||||
|
||||
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`/alerts` and `/alertmanager-status` endpoints).
|
||||
- `backend/src/media_library_viewer_api/observability.py` (`BACKUP_RUNS_LAST_SUCCESS` gauge + updated `record_backup_run`).
|
||||
- `backend/src/media_library_viewer_api/routers/backups.py` (pass `success=True` to `record_backup_run` on successful reports).
|
||||
@@ -386,6 +395,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
- [x] Wire the new `/observability` route into `App.tsx` and the sidebar navigation.
|
||||
|
||||
**Phase 4 files**:
|
||||
|
||||
- `frontend/src/components/ObservabilityPage.tsx` (page component).
|
||||
- `frontend/src/hooks/useObservability.ts` (React Query hooks).
|
||||
- `frontend/src/api/client.ts` (API client functions).
|
||||
@@ -406,6 +416,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
|
||||
- [ ] Optional: add OpenTelemetry Collector as a translation layer for traces later.
|
||||
|
||||
**Phase 5 files**:
|
||||
|
||||
- `docker-compose.yml` and `docker-compose.dev.yml` (health checks, resource limits, `depends_on` conditions).
|
||||
- `monitoring/prometheus/prometheus.yml` (additional scrape jobs for observability services).
|
||||
- `monitoring/prometheus/rules/backup_alerts.yml` (renamed scope to include observability health alerts).
|
||||
|
||||
@@ -15,6 +15,8 @@ ARG VITE_OIDC_SCOPE=openid profile email
|
||||
ARG VITE_OIDC_REDIRECT_URI=
|
||||
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
|
||||
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
|
||||
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_BUILD_INFO=dev
|
||||
|
||||
@@ -26,6 +28,8 @@ ENV VITE_API_URL=${VITE_API_URL} \
|
||||
VITE_OIDC_REDIRECT_URI=${VITE_OIDC_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_GRAFANA_URL=${VITE_GRAFANA_URL} \
|
||||
VITE_PROMETHEUS_URL=${VITE_PROMETHEUS_URL} \
|
||||
VITE_APP_VERSION=${VITE_APP_VERSION} \
|
||||
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
|
||||
|
||||
@@ -50,6 +54,8 @@ COPY frontend/ ./
|
||||
ENV VITE_API_URL=/api \
|
||||
VITE_OIDC_ENABLED=false \
|
||||
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
|
||||
VITE_GRAFANA_URL=http://localhost:3000 \
|
||||
VITE_PROMETHEUS_URL=http://localhost:9090 \
|
||||
VITE_APP_VERSION=0.1.0 \
|
||||
VITE_APP_BUILD_INFO=dev
|
||||
|
||||
|
||||
Generated
+1126
-1491
File diff suppressed because it is too large
Load Diff
+11
-10
@@ -7,19 +7,17 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"test:node": "node --test tests/*.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource-variable/geist": "^5.2.8",
|
||||
"@mui/icons-material": "^9.0.0",
|
||||
"@mui/material": "^9.0.0",
|
||||
"@mui/x-data-grid": "^9.0.4",
|
||||
"@tanstack/react-query": "^5.100.6",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"d3": "^7.9.0",
|
||||
"lucide-react": "^1.14.0",
|
||||
"oidc-client-ts": "^3.5.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -27,7 +25,6 @@
|
||||
"react-dom": "^19.2.5",
|
||||
"react-oidc-context": "^3.3.1",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"recharts": "^3.8.1",
|
||||
"shadcn": "^4.7.0",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
@@ -36,7 +33,9 @@
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@tailwindcss/vite": "^4.3.0",
|
||||
"@types/d3": "^7.4.3",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^24.13.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
@@ -46,10 +45,12 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.5.14",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"vite": "^8.0.10"
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.9"
|
||||
}
|
||||
}
|
||||
|
||||
+33
-5
@@ -22,6 +22,8 @@ import { FileBrowser } from "./pages/FileBrowser";
|
||||
import { Actions } from "./pages/Actions";
|
||||
import BackupsPage from "./components/BackupsPage";
|
||||
import { ObservabilityPage } from "./components/ObservabilityPage";
|
||||
import { ServicePage } from "./pages/ServicePage";
|
||||
import { ServicesPage } from "./pages/ServicesPage";
|
||||
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
|
||||
import { fetchAppVersion } from "./api/client";
|
||||
import { FRONTEND_VERSION_LABEL } from "./version";
|
||||
@@ -43,6 +45,7 @@ import {
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Activity,
|
||||
DatabaseBackup,
|
||||
Monitor,
|
||||
Users,
|
||||
Zap,
|
||||
@@ -54,6 +57,7 @@ import {
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Boxes,
|
||||
} from "lucide-react";
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
@@ -82,10 +86,12 @@ function useDarkMode() {
|
||||
const navItems = [
|
||||
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
|
||||
{ path: "/observability", label: "Observability", icon: Activity },
|
||||
{ path: "/applications", label: "Media", icon: Monitor },
|
||||
{ path: "/media", label: "Media", icon: Monitor },
|
||||
{ path: "/files", label: "Files", icon: FolderOpen },
|
||||
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
|
||||
{ path: "/users", label: "Users", icon: Users },
|
||||
{ path: "/actions", label: "Actions", icon: Zap },
|
||||
{ path: "/services", label: "Services", icon: Boxes },
|
||||
{ path: "/settings", label: "Settings", icon: SettingsIcon },
|
||||
];
|
||||
|
||||
@@ -432,15 +438,26 @@ function AppInner() {
|
||||
<Routes>
|
||||
<Route element={<AuthenticatedApp />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
|
||||
<Route path="/applications" element={<Applications />} />
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={<Navigate to="/observability" replace />}
|
||||
/>
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route
|
||||
path="/applications"
|
||||
element={<Navigate to="/media" replace />}
|
||||
/>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
@@ -457,15 +474,26 @@ function AppInner() {
|
||||
}
|
||||
>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
|
||||
<Route path="/applications" element={<Applications />} />
|
||||
<Route
|
||||
path="/monitoring"
|
||||
element={<Navigate to="/observability" replace />}
|
||||
/>
|
||||
<Route path="/media" element={<Applications />} />
|
||||
<Route
|
||||
path="/applications"
|
||||
element={<Navigate to="/media" replace />}
|
||||
/>
|
||||
<Route path="/users" element={<UsersPage />} />
|
||||
<Route path="/actions" element={<Actions />} />
|
||||
<Route path="/files" element={<FileBrowser />} />
|
||||
<Route path="/backups" element={<BackupsPage />} />
|
||||
<Route path="/observability" element={<ObservabilityPage />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/services" element={<ServicesPage />} />
|
||||
<Route
|
||||
path="/services/:serviceType/:serviceId"
|
||||
element={<ServicePage />}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
|
||||
+24
-22
@@ -134,26 +134,26 @@ async function del<T>(path: string): Promise<T> {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Dashboard
|
||||
export const fetchCounts = (machineId?: string) =>
|
||||
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
|
||||
export const fetchCounts = (jellyfinServiceId?: string) =>
|
||||
get<MediaCounts>(
|
||||
"/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[]>(
|
||||
"/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[]>(
|
||||
"/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>(
|
||||
"/api/users",
|
||||
machineId ? { machine_id: machineId } : undefined,
|
||||
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
|
||||
);
|
||||
|
||||
// Backward-compatible alias used by older hooks/components.
|
||||
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
|
||||
);
|
||||
|
||||
// Media
|
||||
export const fetchMediaStatus = (machineId?: string) =>
|
||||
export const fetchMediaStatus = (jellyfinServiceId?: string) =>
|
||||
get<MediaIndexStatus>(
|
||||
"/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>(
|
||||
machineId
|
||||
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}`
|
||||
jellyfinServiceId
|
||||
? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/build",
|
||||
);
|
||||
export const stopMediaIndexBuild = (machineId?: string) =>
|
||||
export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||
post<MediaIndexActionResponse>(
|
||||
machineId
|
||||
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}`
|
||||
jellyfinServiceId
|
||||
? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/stop",
|
||||
);
|
||||
export const forceStopMediaIndexBuild = (machineId?: string) =>
|
||||
export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
|
||||
post<MediaIndexActionResponse>(
|
||||
machineId
|
||||
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}`
|
||||
jellyfinServiceId
|
||||
? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
|
||||
: "/api/media/force-stop",
|
||||
);
|
||||
export const queryMedia = (params: {
|
||||
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
|
||||
sort_order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
machineId?: string;
|
||||
jellyfinServiceId?: string;
|
||||
}) =>
|
||||
get<MediaQueryResponse>("/api/media/query", {
|
||||
libraries: params.libraries || "",
|
||||
@@ -336,7 +336,9 @@ export const queryMedia = (params: {
|
||||
sort_order: params.sort_order || "Ascending",
|
||||
limit: String(params.limit || 100),
|
||||
offset: String(params.offset || 0),
|
||||
...(params.machineId ? { machine_id: params.machineId } : {}),
|
||||
...(params.jellyfinServiceId
|
||||
? { jellyfin_service_id: params.jellyfinServiceId }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type {
|
||||
BuiltinWidgetKindInfo,
|
||||
WidgetDataResponse,
|
||||
WidgetInstance,
|
||||
WidgetInstanceInput,
|
||||
} from "../types";
|
||||
|
||||
const API_BASE = "/api";
|
||||
|
||||
export async function fetchBuiltinWidgetKinds(): Promise<
|
||||
BuiltinWidgetKindInfo[]
|
||||
> {
|
||||
const res = await fetch(`${API_BASE}/widgets/builtin`);
|
||||
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances`);
|
||||
if (!res.ok) throw new Error("Failed to fetch widget instances");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function createWidgetInstance(
|
||||
input: WidgetInstanceInput,
|
||||
): Promise<WidgetInstance> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to create widget instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function updateWidgetInstance(
|
||||
input: WidgetInstanceInput,
|
||||
): Promise<WidgetInstance> {
|
||||
if (!input.id) throw new Error("Widget ID is required for update");
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to update widget instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function deleteWidgetInstance(
|
||||
widgetId: string,
|
||||
): Promise<{ status: string }> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to delete widget instance");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchWidgetData(
|
||||
widgetId: string,
|
||||
): Promise<WidgetDataResponse> {
|
||||
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
|
||||
if (!res.ok) throw new Error("Failed to fetch widget data");
|
||||
return res.json();
|
||||
}
|
||||
@@ -1,56 +1,73 @@
|
||||
import { Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { BackupAlert } from "../types/backups";
|
||||
|
||||
interface Props {
|
||||
alerts: BackupAlert[];
|
||||
onAcknowledge: (alertId: string) => void;
|
||||
alerts: BackupAlert[];
|
||||
onAcknowledge: (alertId: string) => void;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
type SeverityVariant = "destructive" | "warning";
|
||||
|
||||
/**
|
||||
* Map an alert severity onto a Badge variant per design §2.3.
|
||||
* `critical` → destructive (chart-4); `warning` → warning (chart-3).
|
||||
*/
|
||||
function severityVariant(severity: string): SeverityVariant {
|
||||
return severity === "critical" ? "destructive" : "warning";
|
||||
}
|
||||
|
||||
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Severity</TableCell>
|
||||
<TableCell>Type</TableCell>
|
||||
<TableCell>Message</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell>Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{alerts.map((alert) => (
|
||||
<TableRow key={alert.id} hover>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={alert.severity}
|
||||
color={alert.severity === "critical" ? "error" : "warning"}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{alert.alert_type}</TableCell>
|
||||
<TableCell>{alert.message}</TableCell>
|
||||
<TableCell>{formatTimestamp(alert.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
{!alert.acknowledged && (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
onClick={() => onAcknowledge(alert.id)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup alerts">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Message</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{alerts.map((alert) => (
|
||||
<TableRow key={alert.id}>
|
||||
<TableCell>
|
||||
<Badge variant={severityVariant(alert.severity)}>
|
||||
{alert.severity}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{alert.alert_type}</TableCell>
|
||||
<TableCell>{alert.message}</TableCell>
|
||||
<TableCell>{formatTimestamp(alert.created_at)}</TableCell>
|
||||
<TableCell>
|
||||
{!alert.acknowledged && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onAcknowledge(alert.id)}
|
||||
>
|
||||
Acknowledge
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,52 +1,49 @@
|
||||
import { Card, CardContent, Typography, Box, Chip } from "@mui/material";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { useBackupDashboard } from "../hooks/useBackups";
|
||||
|
||||
export default function BackupDashboardWidget() {
|
||||
const { data, isLoading } = useBackupDashboard();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6">Backups</Typography>
|
||||
<Typography color="text.secondary">Loading...</Typography>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>Backups</Typography>
|
||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
|
||||
<Box>
|
||||
<Typography variant="h4">{data.total_jobs}</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Jobs</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h4">{data.success_rate_24h}%</Typography>
|
||||
<Typography variant="body2" color="text.secondary">24h Success</Typography>
|
||||
</Box>
|
||||
<Box>
|
||||
<Typography variant="h4">
|
||||
{data.active_alerts > 0 ? (
|
||||
<Chip label={data.active_alerts} color="error" size="small" />
|
||||
) : (
|
||||
0
|
||||
)}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">Alerts</Typography>
|
||||
</Box>
|
||||
{data.last_failed_at && (
|
||||
<Box>
|
||||
<Typography variant="body2" color="error">
|
||||
Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
const { data, isLoading } = useBackupDashboard();
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Backups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading || !data ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : (
|
||||
<div className="flex flex-row flex-wrap gap-6">
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">{data.total_jobs}</div>
|
||||
<div className="text-xs text-muted-foreground">Jobs</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">
|
||||
{data.success_rate_24h}%
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">24h Success</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-semibold">
|
||||
{data.active_alerts > 0 ? (
|
||||
<Badge variant="destructive">{data.active_alerts}</Badge>
|
||||
) : (
|
||||
0
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Alerts</div>
|
||||
</div>
|
||||
{data.last_failed_at && (
|
||||
<div className="self-center text-xs text-destructive">
|
||||
Last failed:{" "}
|
||||
{new Date(data.last_failed_at * 1000).toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,84 +1,90 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Chip,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { BackupJob, BackupRun } from "../types/backups";
|
||||
|
||||
interface Props {
|
||||
jobs: BackupJob[];
|
||||
latestRuns: Map<string, BackupRun>;
|
||||
jobs: BackupJob[];
|
||||
latestRuns: Map<string, BackupRun>;
|
||||
}
|
||||
|
||||
function formatInterval(seconds: number | null): string {
|
||||
if (!seconds) return "N/A";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
if (!seconds) return "N/A";
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
|
||||
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
|
||||
return `${Math.floor(seconds / 86400)}d`;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number | null): string {
|
||||
if (!ts) return "Never";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
if (!ts) return "Never";
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
type StatusVariant = "success" | "destructive" | "warning" | "secondary";
|
||||
|
||||
/**
|
||||
* Map a job/run status onto a Badge variant per design §2.3:
|
||||
* `success` → success (chart-2); `failure` → destructive (chart-4);
|
||||
* `in_progress` → warning (chart-3); unknown → secondary (neutral accent).
|
||||
*/
|
||||
function statusVariant(status: string): StatusVariant {
|
||||
if (status === "success") return "success";
|
||||
if (status === "failure") return "destructive";
|
||||
if (status === "in_progress") return "warning";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
|
||||
return (
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Name</TableCell>
|
||||
<TableCell>Source</TableCell>
|
||||
<TableCell>Target</TableCell>
|
||||
<TableCell>Schedule</TableCell>
|
||||
<TableCell>Last Status</TableCell>
|
||||
<TableCell>Last Run</TableCell>
|
||||
<TableCell>Next Expected</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{jobs.map((job) => {
|
||||
const run = latestRuns.get(job.id);
|
||||
const status = run?.status ?? "unknown";
|
||||
const nextExpected = run && job.schedule_interval_seconds
|
||||
? run.started_at + job.schedule_interval_seconds
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TableRow key={job.id} hover>
|
||||
<TableCell>{job.name}</TableCell>
|
||||
<TableCell>{job.source ?? "—"}</TableCell>
|
||||
<TableCell>{job.target ?? "—"}</TableCell>
|
||||
<TableCell>{formatInterval(job.schedule_interval_seconds)}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={status}
|
||||
color={
|
||||
status === "success"
|
||||
? "success"
|
||||
: status === "failure"
|
||||
? "error"
|
||||
: status === "in_progress"
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatTimestamp(run?.started_at ?? null)}</TableCell>
|
||||
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
return (
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup jobs">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Source</TableHead>
|
||||
<TableHead>Target</TableHead>
|
||||
<TableHead>Schedule</TableHead>
|
||||
<TableHead>Last Status</TableHead>
|
||||
<TableHead>Last Run</TableHead>
|
||||
<TableHead>Next Expected</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{jobs.map((job) => {
|
||||
const run = latestRuns.get(job.id);
|
||||
const status = run?.status ?? "unknown";
|
||||
const nextExpected =
|
||||
run && job.schedule_interval_seconds
|
||||
? run.started_at + job.schedule_interval_seconds
|
||||
: null;
|
||||
|
||||
return (
|
||||
<TableRow key={job.id}>
|
||||
<TableCell>{job.name}</TableCell>
|
||||
<TableCell>{job.source ?? "—"}</TableCell>
|
||||
<TableCell>{job.target ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
{formatInterval(job.schedule_interval_seconds)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(status)}>{status}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{formatTimestamp(run?.started_at ?? null)}
|
||||
</TableCell>
|
||||
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,103 +1,110 @@
|
||||
import {
|
||||
Chip,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableRow,
|
||||
} from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { BackupRun } from "../types/backups";
|
||||
|
||||
interface Props {
|
||||
runs: BackupRun[];
|
||||
runs: BackupRun[];
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024)
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatDuration(ms: number | null): string {
|
||||
if (ms === null || ms === undefined) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
|
||||
return `${(ms / 3600_000).toFixed(1)}h`;
|
||||
if (ms === null || ms === undefined) return "—";
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
|
||||
return `${(ms / 3600_000).toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatTimestamp(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
type StatusVariant = "success" | "destructive" | "warning";
|
||||
|
||||
/**
|
||||
* Map a run status onto a Badge variant per design §2.3:
|
||||
* `success` → success (chart-2); `failure` → destructive (chart-4);
|
||||
* `in_progress` → warning (chart-3).
|
||||
*/
|
||||
function statusVariant(status: string): StatusVariant {
|
||||
if (status === "success") return "success";
|
||||
if (status === "failure") return "destructive";
|
||||
return "warning";
|
||||
}
|
||||
|
||||
export default function BackupRunsTable({ runs }: Props) {
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
|
||||
const filteredRuns = statusFilter === "all"
|
||||
? runs
|
||||
: runs.filter((r) => r.status === statusFilter);
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControl sx={{ minWidth: 120, mb: 2 }}>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={statusFilter}
|
||||
label="Status"
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="success">Success</MenuItem>
|
||||
<MenuItem value="failure">Failure</MenuItem>
|
||||
<MenuItem value="in_progress">In Progress</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TableContainer component={Paper}>
|
||||
<Table>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Job</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Duration</TableCell>
|
||||
<TableCell>Size</TableCell>
|
||||
<TableCell>Started</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredRuns.map((run) => (
|
||||
<TableRow key={run.id} hover>
|
||||
<TableCell>{run.job_id}</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={run.status}
|
||||
color={
|
||||
run.status === "success"
|
||||
? "success"
|
||||
: run.status === "failure"
|
||||
? "error"
|
||||
: "warning"
|
||||
}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</>
|
||||
);
|
||||
const [statusFilter, setStatusFilter] = useState<string>("all");
|
||||
|
||||
const filteredRuns =
|
||||
statusFilter === "all"
|
||||
? runs
|
||||
: runs.filter((r) => r.status === statusFilter);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[160px]" aria-label="Status filter">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All</SelectItem>
|
||||
<SelectItem value="success">Success</SelectItem>
|
||||
<SelectItem value="failure">Failure</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-border">
|
||||
<Table aria-label="Backup runs">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableHead>Job</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Duration</TableHead>
|
||||
<TableHead>Size</TableHead>
|
||||
<TableHead>Started</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredRuns.map((run) => (
|
||||
<TableRow key={run.id}>
|
||||
<TableCell>{run.job_id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariant(run.status)}>
|
||||
{run.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
|
||||
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
|
||||
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,72 @@
|
||||
import { Box, Tab, Tabs, Typography } from "@mui/material";
|
||||
import { useState } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
useAcknowledgeAlert,
|
||||
useBackupAlerts,
|
||||
useBackupJobs,
|
||||
useBackupRuns,
|
||||
useAcknowledgeAlert,
|
||||
useBackupAlerts,
|
||||
useBackupJobs,
|
||||
useBackupRuns,
|
||||
} from "../hooks/useBackups";
|
||||
import BackupAlertsTable from "./BackupAlertsTable";
|
||||
import BackupJobsTable from "./BackupJobsTable";
|
||||
import BackupRunsTable from "./BackupRunsTable";
|
||||
|
||||
export default function BackupsPage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false);
|
||||
const acknowledgeMutation = useAcknowledgeAlert();
|
||||
|
||||
// Build a map of latest runs per job
|
||||
const latestRuns = new Map();
|
||||
if (runsData) {
|
||||
for (const run of runsData) {
|
||||
const existing = latestRuns.get(run.job_id);
|
||||
if (!existing || run.started_at > existing.started_at) {
|
||||
latestRuns.set(run.job_id, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3 }}>
|
||||
<Typography variant="h4" gutterBottom>Backups</Typography>
|
||||
|
||||
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
|
||||
<Tab label="Jobs" />
|
||||
<Tab label="Runs" />
|
||||
<Tab label={`Alerts ${alertsData ? `(${alertsData.length})` : ""}`} />
|
||||
</Tabs>
|
||||
|
||||
{tab === 0 && (
|
||||
jobsLoading ? (
|
||||
<Typography>Loading jobs...</Typography>
|
||||
) : (
|
||||
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 1 && (
|
||||
runsLoading ? (
|
||||
<Typography>Loading runs...</Typography>
|
||||
) : (
|
||||
<BackupRunsTable runs={runsData ?? []} />
|
||||
)
|
||||
)}
|
||||
|
||||
{tab === 2 && (
|
||||
alertsLoading ? (
|
||||
<Typography>Loading alerts...</Typography>
|
||||
) : (
|
||||
<BackupAlertsTable
|
||||
alerts={alertsData ?? []}
|
||||
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
const [tab, setTab] = useState("jobs");
|
||||
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
|
||||
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
|
||||
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
|
||||
undefined,
|
||||
false,
|
||||
);
|
||||
const acknowledgeMutation = useAcknowledgeAlert();
|
||||
|
||||
// Build a map of latest runs per job
|
||||
const latestRuns = new Map();
|
||||
if (runsData) {
|
||||
for (const run of runsData) {
|
||||
const existing = latestRuns.get(run.job_id);
|
||||
if (!existing || run.started_at > existing.started_at) {
|
||||
latestRuns.set(run.job_id, run);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts";
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="jobs">Jobs</TabsTrigger>
|
||||
<TabsTrigger value="runs">Runs</TabsTrigger>
|
||||
<TabsTrigger value="alerts">{alertsLabel}</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="jobs">
|
||||
{jobsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading jobs…</p>
|
||||
) : (
|
||||
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="runs">
|
||||
{runsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading runs…</p>
|
||||
) : (
|
||||
<BackupRunsTable runs={runsData ?? []} />
|
||||
)}
|
||||
</TabsContent>
|
||||
<TabsContent value="alerts">
|
||||
{alertsLoading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading alerts…</p>
|
||||
) : (
|
||||
<BackupAlertsTable
|
||||
alerts={alertsData ?? []}
|
||||
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
} from "@/components/ui/dialog";
|
||||
import { DialogFooter } from "./DialogFooter";
|
||||
|
||||
/**
|
||||
* Reusable confirmation dialog built on the shadcn Dialog family and the
|
||||
* shared `DialogFooter`. Same exported props as the MUI version; Esc / overlay
|
||||
* click routes to `onCancel` via `onOpenChange`.
|
||||
*/
|
||||
export function ConfirmDialog({
|
||||
open,
|
||||
title,
|
||||
@@ -25,23 +30,26 @@ export function ConfirmDialog({
|
||||
busy?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={1}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{message}
|
||||
</Typography>
|
||||
</Stack>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) onCancel();
|
||||
}}
|
||||
>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{message}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel={confirmLabel}
|
||||
confirmColor="error"
|
||||
confirmBusyLabel={confirmLabel}
|
||||
confirmDisabled={busy}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogFooter
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel={confirmLabel}
|
||||
confirmColor="error"
|
||||
confirmBusyLabel={confirmLabel}
|
||||
confirmDisabled={busy}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Button, DialogActions } from "@mui/material";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface DialogFooterProps {
|
||||
onCancel: () => void;
|
||||
@@ -14,6 +14,28 @@ interface DialogFooterProps {
|
||||
secondaryAction?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the legacy MUI color/variant props onto a shadcn Button variant so
|
||||
* the exported API stays unchanged for consuming pages (ConfirmDialog here,
|
||||
* plus Dashboard/Settings/Actions in later slices).
|
||||
*/
|
||||
function resolveConfirmVariant(
|
||||
color: DialogFooterProps["confirmColor"],
|
||||
variant: DialogFooterProps["confirmVariant"],
|
||||
): "default" | "outline" | "ghost" | "destructive" {
|
||||
if (color === "error") return "destructive";
|
||||
if (variant === "outlined") return "outline";
|
||||
if (variant === "text") return "ghost";
|
||||
return "default";
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog action row: cancel + optional secondary action + confirm.
|
||||
*
|
||||
* Renders a horizontal Button row (`flex flex-row items-center gap-2`).
|
||||
* Preserves cancel/confirm/secondary-action props and the busy/disabled label
|
||||
* contract (renders `confirmBusyLabel` when provided, else `confirmLabel`).
|
||||
*/
|
||||
export function DialogFooter({
|
||||
onCancel,
|
||||
cancelLabel = "Cancel",
|
||||
@@ -27,20 +49,23 @@ export function DialogFooter({
|
||||
secondaryAction,
|
||||
}: DialogFooterProps) {
|
||||
return (
|
||||
<DialogActions sx={{ px: 3, py: 2 }}>
|
||||
<Button onClick={onCancel}>{cancelLabel}</Button>
|
||||
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
|
||||
{secondaryAction}
|
||||
<Button
|
||||
variant={confirmVariant}
|
||||
color={confirmColor}
|
||||
disabled={confirmDisabled}
|
||||
startIcon={confirmStartIcon}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmBusyLabel ?? confirmLabel}
|
||||
</Button>
|
||||
</Box>
|
||||
</DialogActions>
|
||||
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
|
||||
<Button variant="ghost" onClick={onCancel}>
|
||||
{cancelLabel}
|
||||
</Button>
|
||||
{secondaryAction ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
{secondaryAction}
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
|
||||
disabled={confirmDisabled}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
{confirmStartIcon}
|
||||
{confirmBusyLabel ?? confirmLabel}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,154 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
|
||||
interface Props {
|
||||
used: number;
|
||||
available: number;
|
||||
size: number;
|
||||
usedPct: string;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (!bytes || bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let value = bytes;
|
||||
let unitIdx = 0;
|
||||
while (value >= 1000 && unitIdx < units.length - 1) {
|
||||
value /= 1000;
|
||||
unitIdx++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unitIdx]}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dashboard card that summarizes the configured media disk.
|
||||
*
|
||||
* It intentionally keeps the progress bar inside the card so the capacity
|
||||
* signal, raw byte values, and free-space breakdown stay visually grouped.
|
||||
*/
|
||||
export function DiskSpaceCard({ used, available, size, usedPct }: Props) {
|
||||
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
|
||||
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
|
||||
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
|
||||
<Stack spacing={1.5}>
|
||||
<Box>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textTransform: "uppercase" }}
|
||||
>
|
||||
Disk space
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
||||
}}
|
||||
>
|
||||
{usedPct} used
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ width: "100%" }}>
|
||||
<LinearProgress
|
||||
variant="determinate"
|
||||
value={pct}
|
||||
color={barColor}
|
||||
sx={{
|
||||
height: 12,
|
||||
borderRadius: 999,
|
||||
bgcolor: "action.hover",
|
||||
"& .MuiLinearProgress-bar": {
|
||||
borderRadius: 999,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: "action.hover",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Used
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatBytes(used)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: "action.hover",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Free
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatBytes(available)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, sm: 4 }}>
|
||||
<Box
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 2,
|
||||
bgcolor: "action.hover",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
textAlign: "center",
|
||||
gap: 0.25,
|
||||
}}
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
Total
|
||||
</Typography>
|
||||
<Typography variant="body2" sx={{ fontWeight: 600 }}>
|
||||
{formatBytes(size)}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
import EditOutlinedIcon from "@mui/icons-material/EditOutlined";
|
||||
import { IconButton } from "@mui/material";
|
||||
import { Pencil } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface HoverEditButtonProps {
|
||||
onClick: () => void;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hover-to-reveal edit affordance.
|
||||
*
|
||||
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
|
||||
* existing hover-reveal rules in consuming pages (Actions, Settings) still
|
||||
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
|
||||
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
|
||||
* + lucide `Pencil`. Same exported props/display name.
|
||||
*/
|
||||
export function HoverEditButton({
|
||||
onClick,
|
||||
label = "Edit",
|
||||
}: HoverEditButtonProps) {
|
||||
return (
|
||||
<IconButton
|
||||
className="rail-edit"
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
|
||||
aria-label={label}
|
||||
size="small"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick();
|
||||
}}
|
||||
sx={{
|
||||
opacity: 0,
|
||||
transition: "opacity 120ms ease",
|
||||
color: "text.secondary",
|
||||
}}
|
||||
>
|
||||
<EditOutlinedIcon fontSize="inherit" />
|
||||
</IconButton>
|
||||
<Pencil />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,56 +1,57 @@
|
||||
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import type { LibraryCount } from "../types";
|
||||
|
||||
interface Props {
|
||||
libraries: LibraryCount[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Two-column overview of movie and TV libraries on a responsive CSS grid
|
||||
* (`grid grid-cols-1 md:grid-cols-2 gap-4`). Same exported props as the MUI
|
||||
* version; the per-library counts render verbatim.
|
||||
*/
|
||||
export function LibraryOverview({ libraries }: Props) {
|
||||
const movieLibs = libraries.filter((l) => l.type === "movies");
|
||||
const tvLibs = libraries.filter((l) => l.type === "tvshows");
|
||||
|
||||
return (
|
||||
<Grid container spacing={2}>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||
Movie libraries
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-4">
|
||||
{movieLibs.map((lib) => (
|
||||
<Card key={lib.library} variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{lib.library}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Card key={lib.library}>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold">{lib.library}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total: {lib.total.toLocaleString()} | Movies:{" "}
|
||||
{lib.movies.toLocaleString()}
|
||||
</Typography>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid>
|
||||
<Grid size={{ xs: 12, md: 6 }}>
|
||||
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4">
|
||||
<h4 className="text-sm font-semibold text-muted-foreground">
|
||||
TV libraries
|
||||
</Typography>
|
||||
<Stack spacing={1.5}>
|
||||
</h4>
|
||||
<div className="flex flex-col gap-4">
|
||||
{tvLibs.map((lib) => (
|
||||
<Card key={lib.library} variant="outlined">
|
||||
<CardContent>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
|
||||
{lib.library}
|
||||
</Typography>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
<Card key={lib.library}>
|
||||
<CardContent className="flex flex-col gap-1">
|
||||
<span className="text-base font-semibold">{lib.library}</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Total: {lib.total.toLocaleString()} | Series:{" "}
|
||||
{lib.series.toLocaleString()}
|
||||
</Typography>
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Card, CardContent, Typography } from "@mui/material";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface Props {
|
||||
label: string;
|
||||
@@ -6,44 +6,24 @@ interface Props {
|
||||
subtext?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact metric tile: label / value / optional subtext on the comfortable
|
||||
* density ramp (label `text-sm`, value `text-lg font-semibold`, subtext
|
||||
* `text-xs text-muted-foreground`). Same exported props as the MUI version.
|
||||
*/
|
||||
export function MetricCard({ label, value, subtext }: Props) {
|
||||
return (
|
||||
<Card variant="outlined" sx={{ height: "100%" }}>
|
||||
<CardContent
|
||||
sx={{
|
||||
p: { xs: 1.5, sm: 2 },
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
gap: 0.5,
|
||||
height: "100%",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
|
||||
>
|
||||
<Card className="h-full">
|
||||
<CardContent className="flex h-full flex-col gap-1.5">
|
||||
<span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="h5"
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
fontSize: { xs: "1.05rem", sm: "1.5rem" },
|
||||
lineHeight: 1.15,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</Typography>
|
||||
{subtext && (
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
|
||||
>
|
||||
</span>
|
||||
<span className="text-lg font-semibold leading-tight">{value}</span>
|
||||
{subtext ? (
|
||||
<span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
|
||||
{subtext}
|
||||
</Typography>
|
||||
)}
|
||||
</span>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
|
||||
interface SectionCardProps {
|
||||
title: string;
|
||||
@@ -8,6 +8,13 @@ interface SectionCardProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Titled section surface built on the shadcn Card family.
|
||||
*
|
||||
* Comfortable density: `gap-4` between the header row and the body. Exports
|
||||
* the same props/display name as the prior MUI implementation so every
|
||||
* consuming page compiles unchanged.
|
||||
*/
|
||||
export function SectionCard({
|
||||
title,
|
||||
description,
|
||||
@@ -15,32 +22,18 @@ export function SectionCard({
|
||||
children,
|
||||
}: SectionCardProps) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 1.5 }}>
|
||||
<Stack spacing={1.25}>
|
||||
<Box
|
||||
sx={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
gap: 1,
|
||||
flexWrap: "wrap",
|
||||
}}
|
||||
>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
{description ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
{action}
|
||||
</Box>
|
||||
{children}
|
||||
</Stack>
|
||||
<Card className="gap-4">
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<h3 className="text-base font-semibold">{title}</h3>
|
||||
{description ? (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
{action}
|
||||
</div>
|
||||
{children}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { Box, Card, CardContent, Typography } from "@mui/material";
|
||||
import { Card } from "@/components/ui/card";
|
||||
|
||||
interface SelectionRailCardProps {
|
||||
title: string;
|
||||
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
minHeight?: number;
|
||||
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||
contentSx?: object;
|
||||
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||
bodySx?: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection-rail surface: titled header, scrollable body, optional footer.
|
||||
*
|
||||
* Preserves the exported props (`minHeight`, `footer`, and the legacy `*Sx`
|
||||
* no-op passthroughs) so consuming pages (Actions, Settings) compile
|
||||
* unchanged. The scrollable body and footer contract are preserved.
|
||||
*/
|
||||
export function SelectionRailCard({
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
minHeight = 420,
|
||||
contentSx,
|
||||
bodySx,
|
||||
}: SelectionRailCardProps) {
|
||||
return (
|
||||
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
|
||||
<CardContent
|
||||
sx={{
|
||||
p: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
minHeight,
|
||||
...contentSx,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
px: 1.5,
|
||||
py: 1.25,
|
||||
borderBottom: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "action.hover",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
|
||||
>
|
||||
{title}
|
||||
</Typography>
|
||||
<Card className="h-fit self-start py-0" style={{ minHeight }}>
|
||||
<div className="flex flex-col" style={{ minHeight }}>
|
||||
<div className="border-b bg-muted/50 px-4 py-3">
|
||||
<h4 className="text-sm font-semibold tracking-wide">{title}</h4>
|
||||
{description ? (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{description}
|
||||
</Typography>
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
|
||||
{footer ? (
|
||||
<Box
|
||||
sx={{
|
||||
p: 1,
|
||||
borderTop: 1,
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
}}
|
||||
>
|
||||
{footer}
|
||||
</Box>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto">{children}</div>
|
||||
{footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Button,
|
||||
Chip,
|
||||
Paper,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
} from "@/components/ui/table";
|
||||
import type { NowPlayingSession } from "../types";
|
||||
|
||||
interface Props {
|
||||
@@ -19,6 +17,23 @@ interface Props {
|
||||
onSelectSession?: (session: NowPlayingSession) => void;
|
||||
}
|
||||
|
||||
type SessionStateVariant = "success" | "warning" | "secondary";
|
||||
|
||||
/**
|
||||
* Map a session state onto a Badge variant per design §2.3.
|
||||
*
|
||||
* `playing` (active/healthy) → `success` (chart-2), `paused` → `warning`
|
||||
* (chart-3), anything else (idle/unknown) → `secondary` (neutral accent).
|
||||
*/
|
||||
function sessionStateVariant(state: string): SessionStateVariant {
|
||||
const normalized = String(state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (normalized === "playing") return "success";
|
||||
if (normalized === "paused") return "warning";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function formatStateLabel(state: string): string {
|
||||
const normalized = String(state || "")
|
||||
.trim()
|
||||
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
|
||||
const userFallback = selectedUserLabel || "Unknown user";
|
||||
|
||||
if (!sessions.length) {
|
||||
return (
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
{emptyMessage}
|
||||
</Typography>
|
||||
);
|
||||
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer
|
||||
component={Paper}
|
||||
variant="outlined"
|
||||
sx={{
|
||||
maxHeight: 280,
|
||||
borderColor: "divider",
|
||||
borderRadius: 1,
|
||||
overflowX: "auto",
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
stickyHeader
|
||||
aria-label="Session activity details"
|
||||
sx={{ minWidth: 880 }}
|
||||
>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
minWidth: 160,
|
||||
}}
|
||||
>
|
||||
User
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
|
||||
>
|
||||
State
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
Title / Type
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
minWidth: 140,
|
||||
}}
|
||||
>
|
||||
Device
|
||||
</TableCell>
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
width: 118,
|
||||
}}
|
||||
>
|
||||
Transcoding
|
||||
</TableCell>
|
||||
<div className="max-h-[280px] overflow-auto rounded-lg border border-border">
|
||||
<Table aria-label="Session activity details" className="min-w-[880px]">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableHead className="min-w-[160px]">User</TableHead>
|
||||
<TableHead className="w-[82px]">State</TableHead>
|
||||
<TableHead className="min-w-[140px]">Title / Type</TableHead>
|
||||
<TableHead className="min-w-[140px]">Device</TableHead>
|
||||
<TableHead className="w-[118px]">Transcoding</TableHead>
|
||||
{onSelectSession ? (
|
||||
<TableCell
|
||||
sx={{
|
||||
fontWeight: 700,
|
||||
bgcolor: "background.default",
|
||||
width: 150,
|
||||
}}
|
||||
>
|
||||
Action
|
||||
</TableCell>
|
||||
<TableHead className="w-[150px]">Action</TableHead>
|
||||
) : null}
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableRow>
|
||||
<TableRow className="bg-card hover:bg-card">
|
||||
<TableCell
|
||||
colSpan={onSelectSession ? 6 : 5}
|
||||
sx={{ py: 0.75, bgcolor: "background.paper" }}
|
||||
className="bg-card py-3"
|
||||
>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{buildStatusSummary(sessions)}
|
||||
</Typography>
|
||||
</span>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{sessions.map((session) => {
|
||||
const state = String(session.state || "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const sessionLabel = formatStateLabel(session.state);
|
||||
return (
|
||||
<TableRow
|
||||
key={session.session_id}
|
||||
hover
|
||||
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
|
||||
className={onSelectSession ? "cursor-pointer" : undefined}
|
||||
onClick={
|
||||
onSelectSession ? () => onSelectSession(session) : undefined
|
||||
}
|
||||
>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
<TableCell className="min-w-[160px]">
|
||||
<div
|
||||
className="truncate text-sm"
|
||||
title={session.user || userFallback}
|
||||
>
|
||||
{session.user || userFallback}
|
||||
</Typography>
|
||||
<Typography
|
||||
variant="caption"
|
||||
color="text.secondary"
|
||||
noWrap
|
||||
</div>
|
||||
<div
|
||||
className="truncate text-xs text-muted-foreground"
|
||||
title={session.session_id}
|
||||
>
|
||||
{session.session_id}
|
||||
</Typography>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<Chip
|
||||
size="small"
|
||||
label={sessionLabel}
|
||||
color={
|
||||
state === "playing"
|
||||
? "primary"
|
||||
: state === "paused"
|
||||
? "warning"
|
||||
: "default"
|
||||
}
|
||||
variant={
|
||||
state === "playing" || state === "paused"
|
||||
? "filled"
|
||||
: "outlined"
|
||||
}
|
||||
/>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge variant={sessionStateVariant(session.state)}>
|
||||
{sessionLabel}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||
<Typography
|
||||
variant="body2"
|
||||
noWrap
|
||||
title={session.title || ""}
|
||||
>
|
||||
<TableCell className="min-w-[140px]">
|
||||
<div className="truncate text-sm" title={session.title || ""}>
|
||||
{session.title || "(idle)"}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary" noWrap>
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{session.type || "—"}
|
||||
</Typography>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
<TableCell className="min-w-[140px]">
|
||||
<div className="truncate text-sm">
|
||||
{session.device || "Unknown device"}
|
||||
</Typography>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<Typography variant="body2" noWrap>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<span className="text-sm">
|
||||
{session.transcoding === "yes"
|
||||
? session.transcoding_type
|
||||
? `yes (${session.transcoding_type})`
|
||||
: "yes"
|
||||
: "no"}
|
||||
</Typography>
|
||||
</span>
|
||||
</TableCell>
|
||||
{onSelectSession ? (
|
||||
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
onSelectSession(session);
|
||||
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { Box, Card, CardContent, Tabs } from "@mui/material";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Tabs, TabsList } from "@/components/ui/tabs";
|
||||
|
||||
interface TabbedCardProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
tabs: ReactElement[];
|
||||
children: ReactNode;
|
||||
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||
contentSx?: object;
|
||||
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
|
||||
tabsSx?: object;
|
||||
}
|
||||
|
||||
/**
|
||||
* Card surface with a line-style tab bar on top and a content area below.
|
||||
*
|
||||
* `value`/`onChange` stay string-typed (controlled) and the `tabs` prop stays
|
||||
* `ReactElement[]`, so consuming pages compile unchanged. The page owns the
|
||||
* rendered content from `children` keyed off `value`, exactly as before.
|
||||
*/
|
||||
export function TabbedCard({
|
||||
value,
|
||||
onChange,
|
||||
tabs,
|
||||
children,
|
||||
contentSx,
|
||||
tabsSx,
|
||||
}: TabbedCardProps) {
|
||||
return (
|
||||
<Card variant="outlined">
|
||||
<CardContent sx={{ p: 0 }}>
|
||||
<Tabs
|
||||
value={value}
|
||||
onChange={(_, next) => onChange(String(next))}
|
||||
variant="scrollable"
|
||||
scrollButtons="auto"
|
||||
allowScrollButtonsMobile
|
||||
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
|
||||
>
|
||||
{tabs}
|
||||
</Tabs>
|
||||
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
|
||||
</CardContent>
|
||||
<Card className="gap-0 py-0">
|
||||
<Tabs value={value} onValueChange={(next) => onChange(String(next))}>
|
||||
<div className="border-b px-2">
|
||||
<TabsList variant="line">{tabs}</TabsList>
|
||||
</div>
|
||||
<div className="p-4">{children}</div>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import BackupAlertsTable from "../BackupAlertsTable";
|
||||
import type { BackupAlert } from "../../types/backups";
|
||||
|
||||
function alert(overrides: Partial<BackupAlert> = {}): BackupAlert {
|
||||
return {
|
||||
id: "a1",
|
||||
job_id: "job-1",
|
||||
run_id: null,
|
||||
alert_type: "failed_status",
|
||||
severity: "warning",
|
||||
message: "Run failed",
|
||||
acknowledged: false,
|
||||
resolved_at: null,
|
||||
created_at: 1_700_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BackupAlertsTable", () => {
|
||||
it("maps alert severity onto Badge variants per design §2.3", () => {
|
||||
render(
|
||||
<BackupAlertsTable
|
||||
alerts={[
|
||||
alert({ id: "c", severity: "critical" }),
|
||||
alert({ id: "w", severity: "warning" }),
|
||||
]}
|
||||
onAcknowledge={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("critical").getAttribute("data-variant")).toBe(
|
||||
"destructive",
|
||||
);
|
||||
expect(screen.getByText("warning").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("calls onAcknowledge with the alert id when the button is clicked", async () => {
|
||||
const onAcknowledge = vi.fn();
|
||||
render(
|
||||
<BackupAlertsTable
|
||||
alerts={[alert({ id: "ack-me" })]}
|
||||
onAcknowledge={onAcknowledge}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Acknowledge" }));
|
||||
expect(onAcknowledge).toHaveBeenCalledTimes(1);
|
||||
expect(onAcknowledge).toHaveBeenCalledWith("ack-me");
|
||||
});
|
||||
|
||||
it("hides the acknowledge button for already-acknowledged alerts", () => {
|
||||
render(
|
||||
<BackupAlertsTable
|
||||
alerts={[alert({ id: "done", acknowledged: true })]}
|
||||
onAcknowledge={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import BackupDashboardWidget from "../BackupDashboardWidget";
|
||||
import { useBackupDashboard } from "../../hooks/useBackups";
|
||||
|
||||
// The widget reads from the react-query hook; mocking `useBackupDashboard` lets
|
||||
// us exercise the render paths without a QueryClientProvider or network.
|
||||
vi.mock("../../hooks/useBackups", () => ({
|
||||
useBackupDashboard: vi.fn(),
|
||||
}));
|
||||
|
||||
const mockUseBackupDashboard = vi.mocked(useBackupDashboard);
|
||||
type DashboardResult = ReturnType<typeof useBackupDashboard>;
|
||||
|
||||
function mockResult(
|
||||
data: DashboardResult["data"],
|
||||
isLoading = false,
|
||||
): DashboardResult {
|
||||
return { data, isLoading } as DashboardResult;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseBackupDashboard.mockReset();
|
||||
});
|
||||
|
||||
describe("BackupDashboardWidget", () => {
|
||||
it("renders the loading state while data is pending", () => {
|
||||
mockUseBackupDashboard.mockReturnValue(mockResult(undefined, true));
|
||||
render(<BackupDashboardWidget />);
|
||||
expect(screen.getByText("Loading…")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders the backup dashboard stats (jobs / 24h success)", () => {
|
||||
mockUseBackupDashboard.mockReturnValue(
|
||||
mockResult({
|
||||
total_jobs: 4,
|
||||
success_rate_24h: 96,
|
||||
active_alerts: 0,
|
||||
last_failed_at: null,
|
||||
}),
|
||||
);
|
||||
render(<BackupDashboardWidget />);
|
||||
expect(screen.getByText("4")).toBeInTheDocument();
|
||||
expect(screen.getByText("96%")).toBeInTheDocument();
|
||||
expect(screen.getByText("Jobs")).toBeInTheDocument();
|
||||
expect(screen.getByText("24h Success")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders a destructive Badge for active alerts and shows last-failed time", () => {
|
||||
mockUseBackupDashboard.mockReturnValue(
|
||||
mockResult({
|
||||
total_jobs: 2,
|
||||
success_rate_24h: 50,
|
||||
active_alerts: 3,
|
||||
last_failed_at: 1_700_000_000,
|
||||
}),
|
||||
);
|
||||
render(<BackupDashboardWidget />);
|
||||
const badge = screen.getByText("3");
|
||||
expect(badge.getAttribute("data-variant")).toBe("destructive");
|
||||
expect(screen.getByText(/Last failed:/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import BackupRunsTable from "../BackupRunsTable";
|
||||
import type { BackupRun } from "../../types/backups";
|
||||
|
||||
function run(overrides: Partial<BackupRun> = {}): BackupRun {
|
||||
return {
|
||||
id: "r1",
|
||||
job_id: "job-1",
|
||||
started_at: 1_700_000_000,
|
||||
ended_at: null,
|
||||
status: "success",
|
||||
bytes_transferred: 2048,
|
||||
duration_ms: 1500,
|
||||
error_message: null,
|
||||
details_json: null,
|
||||
created_at: 1_700_000_000,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("BackupRunsTable", () => {
|
||||
it("maps run status onto Badge variants per design §2.3", () => {
|
||||
render(
|
||||
<BackupRunsTable
|
||||
runs={[
|
||||
run({ id: "a", status: "success" }),
|
||||
run({ id: "b", status: "failure" }),
|
||||
run({ id: "c", status: "in_progress" }),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("success").getAttribute("data-variant")).toBe(
|
||||
"success",
|
||||
);
|
||||
expect(screen.getByText("failure").getAttribute("data-variant")).toBe(
|
||||
"destructive",
|
||||
);
|
||||
expect(screen.getByText("in_progress").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the formatted duration and transferred size", () => {
|
||||
render(
|
||||
<BackupRunsTable
|
||||
runs={[
|
||||
run({
|
||||
id: "fmt",
|
||||
duration_ms: 1500,
|
||||
bytes_transferred: 2048,
|
||||
}),
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("1.5s")).toBeInTheDocument();
|
||||
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { ConfirmDialog } from "../ConfirmDialog";
|
||||
|
||||
describe("ConfirmDialog", () => {
|
||||
it("renders the title and message and wires confirm/cancel", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete machine?"
|
||||
message="This cannot be undone."
|
||||
confirmLabel="Delete"
|
||||
onCancel={onCancel}
|
||||
onConfirm={onConfirm}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
|
||||
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("renders nothing when closed", () => {
|
||||
render(
|
||||
<ConfirmDialog
|
||||
open={false}
|
||||
title="Hidden"
|
||||
message="nope"
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { DialogFooter } from "../DialogFooter";
|
||||
|
||||
describe("DialogFooter", () => {
|
||||
it("renders cancel/confirm labels and wires both callbacks", async () => {
|
||||
const onCancel = vi.fn();
|
||||
const onConfirm = vi.fn();
|
||||
render(
|
||||
<DialogFooter
|
||||
onCancel={onCancel}
|
||||
cancelLabel="Cancel"
|
||||
onConfirm={onConfirm}
|
||||
confirmLabel="Save"
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: "Save" }));
|
||||
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("prefers the busy label and maps confirmColor=error to destructive", () => {
|
||||
render(
|
||||
<DialogFooter
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
confirmLabel="Delete"
|
||||
confirmBusyLabel="Deleting…"
|
||||
confirmColor="error"
|
||||
/>,
|
||||
);
|
||||
const confirm = screen.getByRole("button", { name: "Deleting…" });
|
||||
expect(confirm).toBeInTheDocument();
|
||||
expect(confirm.getAttribute("data-variant")).toBe("destructive");
|
||||
});
|
||||
|
||||
it("renders the secondary action when provided", () => {
|
||||
render(
|
||||
<DialogFooter
|
||||
onCancel={() => {}}
|
||||
onConfirm={() => {}}
|
||||
confirmLabel="OK"
|
||||
secondaryAction={<button type="button">Test SSH</button>}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Test SSH" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { HoverEditButton } from "../HoverEditButton";
|
||||
|
||||
describe("HoverEditButton", () => {
|
||||
it("fires onClick and exposes the default aria-label", async () => {
|
||||
const onClick = vi.fn();
|
||||
render(<HoverEditButton onClick={onClick} />);
|
||||
const button = screen.getByRole("button", { name: "Edit" });
|
||||
await userEvent.click(button);
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("honors a custom label", () => {
|
||||
render(<HoverEditButton onClick={() => {}} label="Rename machine" />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Rename machine" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { LibraryOverview } from "../LibraryOverview";
|
||||
import type { LibraryCount } from "../../types";
|
||||
|
||||
const libraries: LibraryCount[] = [
|
||||
{
|
||||
library: "Films",
|
||||
type: "movies",
|
||||
movies: 100,
|
||||
series: 0,
|
||||
episodes: 0,
|
||||
total: 100,
|
||||
},
|
||||
{
|
||||
library: "Shows",
|
||||
type: "tvshows",
|
||||
movies: 0,
|
||||
series: 12,
|
||||
episodes: 240,
|
||||
total: 252,
|
||||
},
|
||||
];
|
||||
|
||||
describe("LibraryOverview", () => {
|
||||
it("renders movie and TV library cards with their counts", () => {
|
||||
render(<LibraryOverview libraries={libraries} />);
|
||||
expect(screen.getByText("Movie libraries")).toBeInTheDocument();
|
||||
expect(screen.getByText("TV libraries")).toBeInTheDocument();
|
||||
expect(screen.getByText("Films")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Total: 100 \| Movies: 100/)).toBeInTheDocument();
|
||||
expect(screen.getByText("Shows")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Total: 252 \| Series: 12/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { MetricCard } from "../MetricCard";
|
||||
|
||||
describe("MetricCard", () => {
|
||||
it("renders the label, value, and subtext on the comfortable ramp", () => {
|
||||
render(
|
||||
<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />,
|
||||
);
|
||||
expect(screen.getByText("Movies")).toBeInTheDocument();
|
||||
expect(screen.getByText("1,234")).toBeInTheDocument();
|
||||
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("omits subtext when not provided", () => {
|
||||
render(<MetricCard label="Series" value="42" />);
|
||||
expect(screen.getByText("Series")).toBeInTheDocument();
|
||||
expect(screen.getByText("42")).toBeInTheDocument();
|
||||
expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { NowPlaying } from "../NowPlaying";
|
||||
|
||||
describe("NowPlaying", () => {
|
||||
it("renders the dashboard empty-state message contract when there are no sessions", () => {
|
||||
render(<NowPlaying sessions={[]} />);
|
||||
expect(
|
||||
screen.getByText("No recent user activity sessions right now."),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SectionCard } from "../SectionCard";
|
||||
|
||||
describe("SectionCard", () => {
|
||||
it("renders title, description, action, and children", () => {
|
||||
render(
|
||||
<SectionCard
|
||||
title="Shortcuts"
|
||||
description="Quick links"
|
||||
action={<button type="button">Add</button>}
|
||||
>
|
||||
<p>Body content</p>
|
||||
</SectionCard>,
|
||||
);
|
||||
expect(screen.getByText("Shortcuts")).toBeInTheDocument();
|
||||
expect(screen.getByText("Quick links")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
|
||||
expect(screen.getByText("Body content")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders without a description or action", () => {
|
||||
render(<SectionCard title="Only title">children</SectionCard>);
|
||||
expect(screen.getByText("Only title")).toBeInTheDocument();
|
||||
expect(screen.getByText("children")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { SelectionRailCard } from "../SelectionRailCard";
|
||||
|
||||
describe("SelectionRailCard", () => {
|
||||
it("renders the title, body, and footer and honors minHeight", () => {
|
||||
render(
|
||||
<SelectionRailCard
|
||||
title="Saved tasks"
|
||||
description="Pick one"
|
||||
minHeight={200}
|
||||
footer={<button type="button">New task</button>}
|
||||
>
|
||||
<div>Task A</div>
|
||||
</SelectionRailCard>,
|
||||
);
|
||||
expect(screen.getByText("Saved tasks")).toBeInTheDocument();
|
||||
expect(screen.getByText("Task A")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "New task" }),
|
||||
).toBeInTheDocument();
|
||||
// minHeight is applied to the Card via inline style.
|
||||
const card = screen
|
||||
.getByText("Saved tasks")
|
||||
.closest("[data-slot='card']") as HTMLElement | null;
|
||||
expect(card?.style.minHeight).toBe("200px");
|
||||
});
|
||||
|
||||
it("renders without a footer", () => {
|
||||
render(<SelectionRailCard title="No footer">body</SelectionRailCard>);
|
||||
expect(screen.getByText("No footer")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { SessionActivityPanel } from "../SessionActivityPanel";
|
||||
import type { NowPlayingSession } from "../../types";
|
||||
|
||||
function session(
|
||||
overrides: Partial<NowPlayingSession> = {},
|
||||
): NowPlayingSession {
|
||||
return {
|
||||
user: "alice",
|
||||
title: "Movie",
|
||||
type: "Movie",
|
||||
state: "playing",
|
||||
transcoding: "no",
|
||||
transcoding_type: "",
|
||||
device: "Web",
|
||||
session_id: "s1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("SessionActivityPanel", () => {
|
||||
it("maps a playing (healthy) session to the success Badge variant", () => {
|
||||
render(<SessionActivityPanel sessions={[session({ state: "playing" })]} />);
|
||||
const badge = screen.getByText("Playing");
|
||||
expect(badge.getAttribute("data-variant")).toBe("success");
|
||||
});
|
||||
|
||||
it("maps paused → warning and idle → secondary", () => {
|
||||
const { rerender } = render(
|
||||
<SessionActivityPanel sessions={[session({ state: "paused" })]} />,
|
||||
);
|
||||
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
|
||||
"warning",
|
||||
);
|
||||
rerender(<SessionActivityPanel sessions={[session({ state: "idle" })]} />);
|
||||
expect(screen.getByText("Idle").getAttribute("data-variant")).toBe(
|
||||
"secondary",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the empty-state message when there are no sessions", () => {
|
||||
render(
|
||||
<SessionActivityPanel sessions={[]} emptyMessage="Nothing playing." />,
|
||||
);
|
||||
expect(screen.getByText("Nothing playing.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls onSelectSession on row click and on the action button", async () => {
|
||||
const onSelectSession = vi.fn();
|
||||
render(
|
||||
<SessionActivityPanel
|
||||
sessions={[session({ state: "playing" })]}
|
||||
onSelectSession={onSelectSession}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText("alice"));
|
||||
expect(onSelectSession).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Open in Users" }),
|
||||
);
|
||||
expect(onSelectSession).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { TabbedCard } from "../TabbedCard";
|
||||
import { TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
describe("TabbedCard", () => {
|
||||
it("renders the provided tab triggers and reports selection changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<TabbedCard
|
||||
value="jellyfin"
|
||||
onChange={onChange}
|
||||
tabs={[
|
||||
<TabsTrigger key="jellyfin" value="jellyfin">
|
||||
Jellyfin
|
||||
</TabsTrigger>,
|
||||
<TabsTrigger key="nextcloud" value="nextcloud">
|
||||
Nextcloud
|
||||
</TabsTrigger>,
|
||||
]}
|
||||
>
|
||||
<p>Body</p>
|
||||
</TabbedCard>,
|
||||
);
|
||||
expect(screen.getByText("Jellyfin")).toBeInTheDocument();
|
||||
expect(screen.getByText("Body")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText("Nextcloud"));
|
||||
expect(onChange).toHaveBeenCalledWith("nextcloud");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { Badge } from "../badge";
|
||||
|
||||
// Slice 1 harness smoke test: proves the Vitest + jsdom + Testing Library
|
||||
// harness runs and the new `success` Badge variant renders with the chart-2 cue.
|
||||
describe("Badge", () => {
|
||||
it("renders a success variant tagged with the chart-2 cue", () => {
|
||||
render(<Badge variant="success">Healthy</Badge>);
|
||||
const badge = screen.getByText("Healthy");
|
||||
expect(badge).toBeInTheDocument();
|
||||
expect(badge.getAttribute("data-variant")).toBe("success");
|
||||
expect(badge.className).toContain("bg-chart-2/10");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,185 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { useState } from "react";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type { ColumnDef } from "@tanstack/react-table";
|
||||
import { DataTable } from "../data-table";
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
name: string;
|
||||
role: string;
|
||||
}
|
||||
|
||||
const rows: Row[] = [
|
||||
{ id: "1", name: "Alice", role: "Admin" },
|
||||
{ id: "2", name: "Bob", role: "Editor" },
|
||||
{ id: "3", name: "Carol", role: "Viewer" },
|
||||
];
|
||||
|
||||
const columns: ColumnDef<Row>[] = [
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: () => "Name",
|
||||
cell: ({ row }) => row.original.name,
|
||||
},
|
||||
{
|
||||
accessorKey: "role",
|
||||
header: () => "Role",
|
||||
cell: ({ row }) => row.original.role,
|
||||
},
|
||||
];
|
||||
|
||||
/** Wrapper so the DataTable's controlled state can update during interaction. */
|
||||
function Harness({
|
||||
onRowClick,
|
||||
initialSelection = {},
|
||||
}: {
|
||||
onRowClick?: (row: Row) => void;
|
||||
initialSelection?: Record<string, boolean>;
|
||||
}) {
|
||||
const [selection, setSelection] =
|
||||
useState<Record<string, boolean>>(initialSelection);
|
||||
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
|
||||
return (
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
getRowId={(row) => row.id}
|
||||
enableRowSelection
|
||||
rowSelection={selection}
|
||||
onRowSelectionChange={setSelection}
|
||||
onRowClick={onRowClick}
|
||||
enableColumnVisibilityToggle
|
||||
columnVisibility={visibility}
|
||||
onColumnVisibilityChange={setVisibility}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe("DataTable (slice 7a — TanStack wrapper)", () => {
|
||||
it("renders the column headers and rows", () => {
|
||||
render(<Harness />);
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
expect(screen.getByText("Carol")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("toggles row selection via the per-row checkbox and reflects state", async () => {
|
||||
render(<Harness />);
|
||||
// Header select-all checkbox + one per-row checkbox exist before rows.
|
||||
expect(screen.getAllByRole("checkbox", { name: "Select row" }).length).toBe(
|
||||
rows.length,
|
||||
);
|
||||
|
||||
const aliceCheckbox = screen.getAllByRole("checkbox", {
|
||||
name: "Select row",
|
||||
})[0];
|
||||
await userEvent.click(aliceCheckbox);
|
||||
expect(aliceCheckbox).toBeChecked();
|
||||
|
||||
// Toggling again un-selects (controlled membership flips).
|
||||
await userEvent.click(aliceCheckbox);
|
||||
expect(aliceCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it("selects all page rows via the header select-all checkbox", async () => {
|
||||
render(<Harness />);
|
||||
const selectAll = screen.getByRole("checkbox", {
|
||||
name: "Select all rows on this page",
|
||||
});
|
||||
await userEvent.click(selectAll);
|
||||
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
|
||||
expect(cb).toBeChecked();
|
||||
}
|
||||
await userEvent.click(selectAll);
|
||||
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
|
||||
expect(cb).not.toBeChecked();
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles column visibility via the Columns dropdown (column disappears)", async () => {
|
||||
render(<Harness />);
|
||||
|
||||
// Role column header present initially.
|
||||
expect(screen.getByText("Role")).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
|
||||
await userEvent.click(
|
||||
screen.getByRole("menuitemcheckbox", { name: "role" }),
|
||||
);
|
||||
|
||||
// Role header + all role cells vanish from the table.
|
||||
expect(screen.queryByText("Role")).toBeNull();
|
||||
expect(screen.queryByText("Admin")).toBeNull();
|
||||
expect(screen.queryByText("Viewer")).toBeNull();
|
||||
// Name column is unaffected.
|
||||
expect(screen.getByText("Name")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires onRowClick with row.original when a row body is clicked", async () => {
|
||||
const onRowClick = vi.fn();
|
||||
render(<Harness onRowClick={onRowClick} />);
|
||||
|
||||
await userEvent.click(screen.getByText("Bob"));
|
||||
expect(onRowClick).toHaveBeenCalledTimes(1);
|
||||
expect(onRowClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: "2", name: "Bob", role: "Editor" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does NOT fire onRowClick when the selection checkbox is toggled", async () => {
|
||||
const onRowClick = vi.fn();
|
||||
render(<Harness onRowClick={onRowClick} />);
|
||||
|
||||
const firstCheckbox = screen.getAllByRole("checkbox", {
|
||||
name: "Select row",
|
||||
})[0];
|
||||
await userEvent.click(firstCheckbox);
|
||||
expect(onRowClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders the empty message when data is empty", () => {
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={[]}
|
||||
emptyMessage="No files in this directory."
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("No files in this directory.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders client pagination controls when enabled", () => {
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
enablePagination
|
||||
pageSizeOptions={[2, 10]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText(/Page 1 of/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Previous page" }),
|
||||
).toBeDisabled();
|
||||
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("renders the manual pagination total when rowCount is supplied", () => {
|
||||
render(
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows.slice(0, 2)}
|
||||
enablePagination
|
||||
manualPagination
|
||||
rowCount={42}
|
||||
pagination={{ pageIndex: 0, pageSize: 2 }}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("42 rows")).toBeInTheDocument();
|
||||
expect(screen.getByText(/Page 1 of 21/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Avatar as AvatarPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
|
||||
size?: "default" | "sm" | "lg"
|
||||
}) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn(
|
||||
"aspect-square size-full rounded-full object-cover",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="avatar-badge"
|
||||
className={cn(
|
||||
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
|
||||
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
|
||||
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
|
||||
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group"
|
||||
className={cn(
|
||||
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AvatarGroupCount({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="avatar-group-count"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Avatar,
|
||||
AvatarImage,
|
||||
AvatarFallback,
|
||||
AvatarGroup,
|
||||
AvatarGroupCount,
|
||||
AvatarBadge,
|
||||
}
|
||||
@@ -14,6 +14,10 @@ const badgeVariants = cva(
|
||||
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
|
||||
destructive:
|
||||
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
|
||||
success:
|
||||
"bg-chart-2/10 text-chart-2 focus-visible:ring-chart-2/20 dark:bg-chart-2/20 dark:focus-visible:ring-chart-2/40 [a]:hover:bg-chart-2/20",
|
||||
warning:
|
||||
"bg-chart-3/10 text-chart-3 focus-visible:ring-chart-3/20 dark:bg-chart-3/20 dark:focus-visible:ring-chart-3/40 [a]:hover:bg-chart-3/20",
|
||||
outline:
|
||||
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
|
||||
ghost:
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Checkbox as CheckboxPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
|
||||
>
|
||||
<CheckIcon
|
||||
/>
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,342 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type OnChangeFn,
|
||||
type PaginationState,
|
||||
type RowSelectionState,
|
||||
type Table as TableInstance,
|
||||
type VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { Columns3 } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
export interface DataTableProps<TData, TValue = unknown> {
|
||||
columns: ColumnDef<TData, TValue>[];
|
||||
data: TData[];
|
||||
/** Stable row identity; Media derives it from `path` so selection survives paging. */
|
||||
getRowId?: (row: TData, index: number) => string;
|
||||
/** Visibility-only feature set (no sorting, no resizing — locked, design §3.3). */
|
||||
enableRowSelection?: boolean;
|
||||
rowSelection?: RowSelectionState;
|
||||
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
|
||||
onRowClick?: (row: TData) => void;
|
||||
columnVisibility?: VisibilityState;
|
||||
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
|
||||
enableColumnVisibilityToggle?: boolean;
|
||||
/** Pagination (Media only; FileBrowser does not paginate). */
|
||||
enablePagination?: boolean;
|
||||
manualPagination?: boolean;
|
||||
pagination?: PaginationState;
|
||||
onPaginationChange?: OnChangeFn<PaginationState>;
|
||||
pageSizeOptions?: number[];
|
||||
/** Server total for Media (manual pagination). */
|
||||
rowCount?: number;
|
||||
emptyMessage?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reusable TanStack Table wrapper built on the shadcn `Table` primitive.
|
||||
*
|
||||
* Visibility-only feature scope (locked, design §3): pagination, row selection,
|
||||
* row click, column visibility. A sorting row model is deliberately never
|
||||
* wired and column resizing/sizing is never enabled — both are explicit
|
||||
* non-goals.
|
||||
*/
|
||||
export function DataTable<TData, TValue = unknown>({
|
||||
columns,
|
||||
data,
|
||||
getRowId,
|
||||
enableRowSelection = false,
|
||||
rowSelection,
|
||||
onRowSelectionChange,
|
||||
onRowClick,
|
||||
columnVisibility,
|
||||
onColumnVisibilityChange,
|
||||
enableColumnVisibilityToggle = false,
|
||||
enablePagination = false,
|
||||
manualPagination = false,
|
||||
pagination,
|
||||
onPaginationChange,
|
||||
pageSizeOptions = [10, 20, 30, 50],
|
||||
rowCount,
|
||||
emptyMessage = "No results.",
|
||||
}: DataTableProps<TData, TValue>) {
|
||||
const pageSize = pagination?.pageSize ?? pageSizeOptions[0] ?? 10;
|
||||
|
||||
// Selection column is a *display* column (no accessor); only rendered when
|
||||
// the consumer opts in. Its checkbox handlers stopPropagation so toggling a
|
||||
// row never also fires onRowClick navigation.
|
||||
const tableColumns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {
|
||||
if (!enableRowSelection) return columns;
|
||||
const selectColumn: ColumnDef<TData, TValue> = {
|
||||
id: "__select__",
|
||||
enableSorting: false,
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
aria-label="Select all rows on this page"
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected()
|
||||
? true
|
||||
: table.getIsSomePageRowsSelected()
|
||||
? "indeterminate"
|
||||
: false
|
||||
}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
aria-label="Select row"
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
),
|
||||
enableHiding: false,
|
||||
};
|
||||
return [selectColumn as ColumnDef<TData, TValue>, ...columns];
|
||||
}, [columns, enableRowSelection]);
|
||||
|
||||
/* eslint-disable react-hooks/incompatible-library -- TanStack's
|
||||
useReactTable intentionally returns non-memoizable updater fns (controlled state). */
|
||||
const table = useReactTable<TData>({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
getRowId,
|
||||
enableRowSelection,
|
||||
onRowSelectionChange,
|
||||
onColumnVisibilityChange,
|
||||
manualPagination: enablePagination ? manualPagination : false,
|
||||
rowCount: enablePagination && manualPagination ? rowCount : undefined,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
// Client pagination model ONLY when paginating locally (FileBrowser does
|
||||
// not paginate; Media drives the page from the server via limit/offset).
|
||||
getPaginationRowModel:
|
||||
enablePagination && !manualPagination
|
||||
? getPaginationRowModel()
|
||||
: undefined,
|
||||
state: {
|
||||
...(rowSelection !== undefined ? { rowSelection } : {}),
|
||||
...(columnVisibility !== undefined ? { columnVisibility } : {}),
|
||||
...(enablePagination
|
||||
? { pagination: pagination ?? { pageIndex: 0, pageSize } }
|
||||
: {}),
|
||||
},
|
||||
onPaginationChange,
|
||||
// Visibility-only: deliberately NO sorting model / sorting state.
|
||||
});
|
||||
|
||||
const pageCount =
|
||||
enablePagination && rowCount !== undefined && pageSize > 0
|
||||
? Math.max(1, Math.ceil(rowCount / pageSize))
|
||||
: table.getPageCount();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{enableColumnVisibilityToggle && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm">
|
||||
<Columns3 className="size-4" />
|
||||
Columns
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
{column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="hover:bg-transparent">
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead key={header.id}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() ? "selected" : undefined}
|
||||
className={cn(onRowClick && "cursor-pointer")}
|
||||
onClick={() => onRowClick?.(row.original)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
className="h-24 text-center text-muted-foreground"
|
||||
>
|
||||
{emptyMessage}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{enablePagination && (
|
||||
<DataTablePagination
|
||||
table={table}
|
||||
pageSizeOptions={pageSizeOptions}
|
||||
pageCount={pageCount}
|
||||
manual={manualPagination}
|
||||
rowCount={rowCount}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface PaginationProps<TData> {
|
||||
table: TableInstance<TData>;
|
||||
pageSizeOptions: number[];
|
||||
pageCount: number;
|
||||
manual: boolean;
|
||||
rowCount?: number;
|
||||
}
|
||||
|
||||
function DataTablePagination<TData>({
|
||||
table,
|
||||
pageSizeOptions,
|
||||
pageCount,
|
||||
manual,
|
||||
rowCount,
|
||||
}: PaginationProps<TData>) {
|
||||
const pageIndex = table.getState().pagination.pageIndex;
|
||||
const pageSize = table.getState().pagination.pageSize;
|
||||
const visibleRows = table.getRowModel().rows.length;
|
||||
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
||||
<div className="text-muted-foreground">
|
||||
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-muted-foreground">Rows per page</span>
|
||||
<Select
|
||||
value={String(pageSize)}
|
||||
onValueChange={(value) => table.setPageSize(Number(value))}
|
||||
>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
className="w-[70px]"
|
||||
aria-label="Rows per page"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{pageSizeOptions.map((option) => (
|
||||
<SelectItem key={option} value={String(option)}>
|
||||
{option}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<span className="text-muted-foreground">
|
||||
Page {pageIndex + 1} of {pageCount}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
aria-label="Previous page"
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
aria-label="Next page"
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import * as React from "react"
|
||||
import { Dialog as DialogPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { XIcon } from "lucide-react"
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close data-slot="dialog-close" asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="absolute top-2 right-2"
|
||||
size="icon-sm"
|
||||
>
|
||||
<XIcon
|
||||
/>
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogFooter({
|
||||
className,
|
||||
showCloseButton = false,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
showCloseButton?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close asChild>
|
||||
<Button variant="outline">Close</Button>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn(
|
||||
"text-base leading-none font-medium",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
import * as React from "react"
|
||||
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { CheckIcon, ChevronRightIcon } from "lucide-react"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
align = "start",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
align={align}
|
||||
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-checkbox-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
className="pointer-events-none absolute right-2 flex items-center justify-center"
|
||||
data-slot="dropdown-menu-radio-item-indicator"
|
||||
>
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon
|
||||
/>
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("-mx-1 my-1 h-px bg-border", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className )}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
data-slot="input"
|
||||
className={cn(
|
||||
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Input }
|
||||
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
import { Label as LabelPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Label({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
|
||||
return (
|
||||
<LabelPrimitive.Root
|
||||
data-slot="label"
|
||||
className={cn(
|
||||
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Label }
|
||||
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Progress as ProgressPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="size-full flex-1 bg-primary transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
data-orientation={orientation}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="relative flex-1 rounded-full bg-border"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react"
|
||||
import { Separator as SeparatorPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Separator({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
|
||||
return (
|
||||
<SeparatorPrimitive.Root
|
||||
data-slot="separator"
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Separator }
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as React from "react"
|
||||
import { Switch as SwitchPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Switch({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
|
||||
size?: "sm" | "default"
|
||||
}) {
|
||||
return (
|
||||
<SwitchPrimitive.Root
|
||||
data-slot="switch"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
data-slot="switch-thumb"
|
||||
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Switch }
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
|
||||
return (
|
||||
<thead
|
||||
data-slot="table-header"
|
||||
className={cn("[&_tr]:border-b", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
|
||||
return (
|
||||
<tbody
|
||||
data-slot="table-body"
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
|
||||
return (
|
||||
<tfoot
|
||||
data-slot="table-footer"
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
|
||||
return (
|
||||
<tr
|
||||
data-slot="table-row"
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
return (
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TableCaption({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"caption">) {
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { Tabs as TabsPrimitive } from "radix-ui"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Tabs({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
|
||||
return (
|
||||
<TabsPrimitive.Root
|
||||
data-slot="tabs"
|
||||
data-orientation={orientation}
|
||||
className={cn(
|
||||
"group/tabs flex gap-2 data-horizontal:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const tabsListVariants = cva(
|
||||
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-muted",
|
||||
line: "gap-1 bg-transparent",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
|
||||
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
|
||||
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 text-sm outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -9,26 +9,26 @@ import {
|
||||
} from "../api/client";
|
||||
import type { DashboardShortcutInput } from "../types";
|
||||
|
||||
export function useCounts(machineId?: string) {
|
||||
export function useCounts(jellyfinServiceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "counts", machineId ?? "default"],
|
||||
queryFn: () => fetchCounts(machineId),
|
||||
queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchCounts(jellyfinServiceId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useLibraries(machineId?: string) {
|
||||
export function useLibraries(jellyfinServiceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "libraries", machineId ?? "default"],
|
||||
queryFn: () => fetchLibraries(machineId),
|
||||
queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchLibraries(jellyfinServiceId),
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useActivity(machineId?: string) {
|
||||
export function useActivity(jellyfinServiceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["dashboard", "activity", machineId ?? "default"],
|
||||
queryFn: () => fetchActivity(machineId),
|
||||
queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchActivity(jellyfinServiceId),
|
||||
refetchInterval: 15_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -7,10 +7,10 @@ import {
|
||||
forceStopMediaIndexBuild,
|
||||
} from "../api/client";
|
||||
|
||||
export function useMediaStatus(machineId?: string) {
|
||||
export function useMediaStatus(jellyfinServiceId?: string) {
|
||||
return useQuery({
|
||||
queryKey: ["media", "status", machineId ?? "default"],
|
||||
queryFn: () => fetchMediaStatus(machineId),
|
||||
queryKey: ["media", "status", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchMediaStatus(jellyfinServiceId),
|
||||
staleTime: 5_000,
|
||||
refetchInterval: (query) =>
|
||||
query.state.data?.build_running ? 1000 : false,
|
||||
@@ -27,7 +27,7 @@ export function useMediaQuery(params: {
|
||||
sort_order?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
machineId?: string;
|
||||
jellyfinServiceId?: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const { enabled = true, ...queryParams } = params;
|
||||
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
|
||||
queryClient.invalidateQueries({ queryKey: ["media"] });
|
||||
}
|
||||
|
||||
export function useBuildIndex(machineId?: string) {
|
||||
export function useBuildIndex(jellyfinServiceId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => buildMediaIndex(machineId),
|
||||
mutationFn: () => buildMediaIndex(jellyfinServiceId),
|
||||
onSuccess: () => {
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useStopBuildIndex(machineId?: string) {
|
||||
export function useStopBuildIndex(jellyfinServiceId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => stopMediaIndexBuild(machineId),
|
||||
mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
|
||||
onSuccess: () => {
|
||||
invalidateMedia(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useForceStopBuildIndex(machineId?: string) {
|
||||
export function useForceStopBuildIndex(jellyfinServiceId?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: () => forceStopMediaIndexBuild(machineId),
|
||||
mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
|
||||
onSuccess: () => {
|
||||
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 type { UserDirectoryResponse } from "../types";
|
||||
|
||||
export function useUsers(machineId?: string) {
|
||||
export function useUsers(jellyfinServiceId?: string) {
|
||||
return useQuery<UserDirectoryResponse>({
|
||||
queryKey: ["users", machineId ?? "default"],
|
||||
queryFn: () => fetchUsers(machineId),
|
||||
queryKey: ["users", jellyfinServiceId ?? "default"],
|
||||
queryFn: () => fetchUsers(jellyfinServiceId),
|
||||
staleTime: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
createWidgetInstance,
|
||||
deleteWidgetInstance,
|
||||
fetchBuiltinWidgetKinds,
|
||||
fetchWidgetData,
|
||||
fetchWidgetInstances,
|
||||
updateWidgetInstance,
|
||||
} from "../api/widgets";
|
||||
import type { WidgetInstanceInput } from "../types";
|
||||
|
||||
export function useWidgetInstances() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "instances"],
|
||||
queryFn: fetchWidgetInstances,
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useWidgetData(widgetId: string, refreshInterval: number) {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "data", widgetId],
|
||||
queryFn: () => fetchWidgetData(widgetId),
|
||||
refetchInterval: refreshInterval || false,
|
||||
enabled: !!widgetId,
|
||||
retry: 1,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSaveWidgetInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (input: WidgetInstanceInput) =>
|
||||
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteWidgetInstance() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useBuiltinWidgetKinds() {
|
||||
return useQuery({
|
||||
queryKey: ["widgets", "builtin"],
|
||||
queryFn: fetchBuiltinWidgetKinds,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user