diff --git a/backend/src/media_library_viewer_api/clients/authentik.py b/backend/src/media_library_viewer_api/clients/authentik.py new file mode 100644 index 0000000..8c94a10 --- /dev/null +++ b/backend/src/media_library_viewer_api/clients/authentik.py @@ -0,0 +1,115 @@ +"""Authentik directory API client. + +Authentik is the user-directory source (replacing the Jellyfin-backed Users +page). This client wraps the Authentik REST API for browsing the user directory +with pagination and search. OIDC authentication is unchanged — this client is +for the directory, not SSO. +""" + +from __future__ import annotations + +import logging +from typing import Any + +import requests + +logger = logging.getLogger(__name__) + + +class AuthentikClient: + """Small wrapper around the Authentik core directory API.""" + + def __init__(self, base_url: str, api_token: str, timeout: float = 10.0): + if not base_url: + raise ValueError("Authentik base_url is required") + if not api_token: + raise ValueError("Authentik API token is required") + + self.base_url = base_url.rstrip("/") + if self.base_url.endswith("/api/v3"): + self.base_url = self.base_url[:-7] + self.api_token = api_token + self.timeout = timeout + self.session = requests.Session() + self.session.headers.update( + { + "Authorization": f"Bearer {api_token}", + "Accept": "application/json", + } + ) + + def get(self, path: str, **params: Any) -> Any: + """GET an Authentik endpoint and include useful response text on errors.""" + clean_params = {k: v for k, v in params.items() if v is not None and v != ""} + logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys())) + response = self.session.get( + f"{self.base_url}/api/v3{path}", + params=clean_params, + timeout=self.timeout, + ) + try: + response.raise_for_status() + except requests.HTTPError as exc: + detail = response.text[:500] + logger.warning( + "Authentik GET %s failed status=%s url=%s", + path, + response.status_code, + response.url, + ) + raise requests.HTTPError( + f"{response.status_code} for {response.url}: {detail}", + response=response, + ) from exc + logger.debug("Authentik GET %s ok status=%s", path, response.status_code) + return response.json() + + def users( + self, + search: str | None = None, + page: int = 1, + page_size: int = 50, + ) -> dict[str, Any]: + """Return a normalized page of Authentik users. + + Calls ``GET /api/v3/core/users/`` and normalizes the paginated + Authentik response into ``{items, total, page, page_size}``. Each item + is the raw Authentik user dict (pk, username, name, email, avatar, …) + so the frontend can pick the fields it needs. + """ + payload = self.get( + "/core/users/", + search=search, + page=page, + page_size=page_size, + ) + if not isinstance(payload, dict): + logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__) + return {"items": [], "total": 0, "page": page, "page_size": page_size} + + results = payload.get("results") + items: list[dict[str, Any]] = ( + [item for item in results if isinstance(item, dict)] if isinstance(results, list) else [] + ) + + pagination = payload.get("pagination") or {} + total = 0 + if isinstance(pagination, dict): + try: + total = int(pagination.get("count") or 0) + except (TypeError, ValueError): + total = 0 + + logger.info( + "Authentik users page=%s page_size=%s -> %s items (total=%s)", + page, + page_size, + len(items), + total, + ) + return { + "items": items, + "total": total, + "page": page, + "page_size": page_size, + } diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index ef00d8b..8eb4f52 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -18,7 +18,6 @@ from typing import Any from fastapi import HTTPException, Request from media_library_viewer_api.clients.jellyfin import JellyfinClient -from media_library_viewer_api.clients.jellyseerr import JellyseerrClient 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 @@ -178,22 +177,6 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient: 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() - 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: """Build a RemoteSSHClient from a machine config dict.""" store = store or get_settings_store() diff --git a/backend/src/media_library_viewer_api/integrations/authentik.py b/backend/src/media_library_viewer_api/integrations/authentik.py new file mode 100644 index 0000000..bfb53a6 --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/authentik.py @@ -0,0 +1,35 @@ +"""Authentik service definition. + +Authentik is the user-directory source (replacing the Jellyfin-backed Users +page). Its directory API is queried via :class:`AuthentikClient` and surfaced +on the Authentik service page (Users + Messaging tabs). OIDC authentication +is unchanged -- this service type is for the directory, not SSO. +""" + +from __future__ import annotations + +from media_library_viewer_api.integrations.base import ( + SecretField, + ServiceBaseUrl, + ServiceConfigBase, + ServiceDefinition, +) + + +class AuthentikConfig(ServiceConfigBase): + """Non-secret Authentik connection config.""" + + base_url: ServiceBaseUrl + timeout_seconds: int = 10 + + +DEFINITION = ServiceDefinition( + service_type="authentik", + name="Authentik", + description="User directory and identity provider integration.", + config_model=AuthentikConfig, + secret_fields=[ + SecretField(key="api_token", label="API token", required=True), + ], + widget_kinds=[], +) diff --git a/backend/src/media_library_viewer_api/integrations/backups.py b/backend/src/media_library_viewer_api/integrations/backups.py new file mode 100644 index 0000000..08010a0 --- /dev/null +++ b/backend/src/media_library_viewer_api/integrations/backups.py @@ -0,0 +1,48 @@ +"""Backups service definition. + +Backups is modeled as a service type so it can be configured, named, and +multi-instanced like other services. Reports arrive via the existing REST +report endpoint; the ``ingestion_label`` disambiguates multi-instance +ingestion. +""" + +from __future__ import annotations + +from media_library_viewer_api.integrations.base import ( + ServiceConfigBase, + ServiceDefinition, + WidgetConfigBase, + widget_kind, +) + + +class BackupsConfig(ServiceConfigBase): + """Non-secret Backups connection config.""" + + ingestion_label: str = "default" + + +class BackupsSummaryWidgetConfig(WidgetConfigBase): + """Backup dashboard summary (jobs, runs, alerts).""" + + # No user-overridable fields; the widget reads the internal backup tables. + pass + + +DEFINITION = ServiceDefinition( + service_type="backups", + name="Backups", + description="Backup job monitoring, run history, and alerting.", + config_model=BackupsConfig, + secret_fields=[], + widget_kinds=[ + widget_kind( + kind="summary", + name="Summary", + description="Backup job summary and active alerts.", + model_cls=BackupsSummaryWidgetConfig, + default_config={}, + refresh_interval_ms=60_000, + ), + ], +) diff --git a/backend/src/media_library_viewer_api/integrations/jellyfin.py b/backend/src/media_library_viewer_api/integrations/jellyfin.py index efbab6b..4feba14 100644 --- a/backend/src/media_library_viewer_api/integrations/jellyfin.py +++ b/backend/src/media_library_viewer_api/integrations/jellyfin.py @@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import ( class JellyfinConfig(ServiceConfigBase): - """Non-secret Jellyfin connection config.""" + """Non-secret Jellyfin connection config. + + The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the + paired Jellyseerr companion config, absorbed from the former standalone + ``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``). + When both are set, the Jellyfin service page renders a Requests tab backed + by Jellyseerr. + """ base_url: ServiceBaseUrl user_id: str = "" timeout_seconds: int = 10 + jellyseerr_url: str = "" + jellyseerr_api_key: str = "" class JellyfinActivityWidgetConfig(WidgetConfigBase): diff --git a/backend/src/media_library_viewer_api/integrations/jellyseerr.py b/backend/src/media_library_viewer_api/integrations/jellyseerr.py deleted file mode 100644 index 1cf5d76..0000000 --- a/backend/src/media_library_viewer_api/integrations/jellyseerr.py +++ /dev/null @@ -1,33 +0,0 @@ -"""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, - ServiceBaseUrl, - ServiceConfigBase, - ServiceDefinition, -) - - -class JellyseerrConfig(ServiceConfigBase): - """Non-secret Jellyseerr connection config.""" - - base_url: ServiceBaseUrl - - -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=[], -) diff --git a/backend/src/media_library_viewer_api/integrations/registry.py b/backend/src/media_library_viewer_api/integrations/registry.py index a018c51..5d2c87f 100644 --- a/backend/src/media_library_viewer_api/integrations/registry.py +++ b/backend/src/media_library_viewer_api/integrations/registry.py @@ -7,10 +7,11 @@ There is no runtime plugin loading. from __future__ import annotations from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER +from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK +from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS 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 @@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { PROMETHEUS.service_type: PROMETHEUS, ALERTMANAGER.service_type: ALERTMANAGER, JELLYFIN.service_type: JELLYFIN, - JELLYSEERR.service_type: JELLYSEERR, NEXTCLOUD.service_type: NEXTCLOUD, SSH_TASKS.service_type: SSH_TASKS, + BACKUPS.service_type: BACKUPS, + AUTHENTIK.service_type: AUTHENTIK, } diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 021df8c..6ec2373 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -21,8 +21,12 @@ from media_library_viewer_api.observability import ( record_request, set_current_request_id, ) +from media_library_viewer_api.routers import ( + authentik_users as authentik_users_router, +) from media_library_viewer_api.routers import backups as backups_router -from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users +from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks +from media_library_viewer_api.routers import dashboards as dashboards_router 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 @@ -136,12 +140,13 @@ app.include_router(monitoring.router) app.include_router(media.router) app.include_router(files.router) app.include_router(jobs.router) -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(dashboards_router.router) app.include_router(services_router.router) +app.include_router(authentik_users_router.router) @app.get("/api/health") diff --git a/backend/src/media_library_viewer_api/models/dashboards.py b/backend/src/media_library_viewer_api/models/dashboards.py new file mode 100644 index 0000000..cb72a3b --- /dev/null +++ b/backend/src/media_library_viewer_api/models/dashboards.py @@ -0,0 +1,29 @@ +"""Pydantic models for the named-dashboards API.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class NamedDashboardInput(BaseModel): + """Input for create/update of a named dashboard.""" + + id: str | None = None + label: str = Field(default="Dashboard") + slug: str | None = None + sort_order: int = 0 + payload: dict[str, Any] = Field(default_factory=dict) + + +class NamedDashboard(BaseModel): + """A named dashboard record.""" + + id: str + label: str + slug: str + sort_order: int + payload: dict[str, Any] + created_at: int + updated_at: int diff --git a/backend/src/media_library_viewer_api/routers/authentik_users.py b/backend/src/media_library_viewer_api/routers/authentik_users.py new file mode 100644 index 0000000..dff359a --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/authentik_users.py @@ -0,0 +1,144 @@ +"""Authentik directory + messaging router. + +Resolves an ``authentik`` service instance from the registry, builds an +:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and +proxies paginated directory queries plus message-compose (email enqueue). +Graceful "not configured" / "unreachable" payloads (matching the monitoring +router's pattern) so the UI always renders. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from media_library_viewer_api.clients.authentik import AuthentikClient +from media_library_viewer_api.config import get_settings +from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store +from media_library_viewer_api.services.mail_queue import MailQueue +from media_library_viewer_api.services.mailer import validate_smtp_settings +from media_library_viewer_api.services.settings_store import SettingsStore +from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/services/authentik", tags=["authentik"]) + + +class MessageRequest(BaseModel): + """Compose-request body for the Authentik messaging endpoint.""" + + recipient_emails: list[str] + subject: str + html_body: str + + +def _resolve_service_record( + store: SettingsStore, + service_id: str | None = None, +) -> ServiceRecord | None: + """Return the requested authentik instance, else the first enabled one. + + Returns ``None`` when the instance does not exist / is the wrong type, or + when no enabled ``authentik`` instance is configured. + """ + service_type = "authentik" + if service_id: + row = store.get_service(service_id) + if not row or row.get("service_type") != service_type: + return None + if not row.get("enabled", True): + return None + return build_service_record(store, row) + for row in store.list_services(service_type): + if row.get("enabled", True): + return build_service_record(store, row) + return None + + +def _build_client(service: ServiceRecord) -> AuthentikClient: + base_url = str(service.config.get("base_url") or "").rstrip("/") + api_token = str(service.secrets.get("api_token") or "") + try: + timeout = float(service.config.get("timeout_seconds") or 10) + except (TypeError, ValueError): + timeout = 10.0 + return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout) + + +def _empty(error: str) -> dict[str, Any]: + return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error} + + +@router.get("/{service_id}/users") +def get_authentik_users( + service_id: str, + search: str | None = None, + page: int = 1, + page_size: int = 50, + store: SettingsStore = Depends(get_settings_store), +) -> dict[str, Any]: + """Paginated Authentik user directory for a specific service instance.""" + service = _resolve_service_record(store, service_id) + if service is None: + logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id) + return _empty("Authentik service not configured") + + try: + client = _build_client(service) + return client.users(search=search, page=page, page_size=page_size) + except Exception: + logger.exception("Authentik users query failed for service %s", service_id) + return _empty("Authentik is unreachable") + + +@router.get("/{service_id}/message/status") +def get_authentik_message_status( + service_id: str, + store: SettingsStore = Depends(get_settings_store), + mail_queue: MailQueue = Depends(get_mail_queue), +) -> dict[str, Any]: + """Mail-queue status snapshot for the Authentik messaging tab.""" + service = _resolve_service_record(store, service_id) + if service is None: + return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"} + return mail_queue.status() + + +@router.post("/{service_id}/message") +def post_authentik_message( + service_id: str, + body: MessageRequest, + store: SettingsStore = Depends(get_settings_store), + mail_queue: MailQueue = Depends(get_mail_queue), +) -> dict[str, Any]: + """Enqueue an email to Authentik-sourced recipients via the mail queue.""" + service = _resolve_service_record(store, service_id) + if service is None: + return {"status": "error", "error": "Authentik service not configured"} + + recipients = [r.strip() for r in body.recipient_emails if r.strip()] + if not recipients: + return {"status": "error", "error": "No recipients with valid email addresses."} + + settings = get_settings() + try: + validate_smtp_settings(settings) + except ValueError as exc: + return {"status": "error", "error": f"SMTP settings invalid: {exc}"} + + request_id = mail_queue.enqueue( + settings=settings, + recipients=recipients, + subject=body.subject, + html_body=body.html_body, + ) + logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients)) + return { + "status": "queued", + "request_id": request_id, + "recipient_count": len(recipients), + } diff --git a/backend/src/media_library_viewer_api/routers/backups.py b/backend/src/media_library_viewer_api/routers/backups.py index 085fb6a..eec4dac 100644 --- a/backend/src/media_library_viewer_api/routers/backups.py +++ b/backend/src/media_library_viewer_api/routers/backups.py @@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store router = APIRouter(prefix="/api/backups", tags=["backups"]) -def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]: +def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str: + """Return the service_id for backup attribution. + + First-wins: if no explicit service_id is given, pick the first enabled + ``backups`` service instance (spec R6.1). Returns an empty string when + none is configured (backward-compatible with pre-service reports). + """ + if explicit: + return explicit + candidates = store.list_services("backups") + for svc in candidates: + if svc.get("enabled"): + return svc["id"] + return "" + + +def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]: job = store.get_backup_job_by_name(report.name) if not job: job = store.upsert_backup_job( @@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, + "service_id": service_id, } ) elif report.schedule_interval_seconds: @@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, + "service_id": service_id, } ) job = store.get_backup_job(job["id"]) @@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic @router.post("/report") def post_backup_report( report: BackupReportRequest, + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: - job = _get_or_create_job(store, report) + resolved_service_id = _resolve_backup_service_id(store, service_id) + job = _get_or_create_job(store, report, resolved_service_id) # Check for duplicate (same job + started_at within 1s) existing_runs = store.list_backup_runs(job_id=job["id"], limit=5) @@ -88,10 +108,12 @@ def post_backup_report( @router.post("/report/start") def post_backup_start( report: BackupReportRequest, + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: - job = _get_or_create_job(store, report) + resolved_service_id = _resolve_backup_service_id(store, service_id) + job = _get_or_create_job(store, report, resolved_service_id) run_data = { "job_id": job["id"], diff --git a/backend/src/media_library_viewer_api/routers/dashboards.py b/backend/src/media_library_viewer_api/routers/dashboards.py new file mode 100644 index 0000000..8f28f48 --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/dashboards.py @@ -0,0 +1,53 @@ +"""Named dashboards CRUD router.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput +from media_library_viewer_api.services.settings_store import SettingsStore + +router = APIRouter(prefix="/api/dashboards", tags=["dashboards"]) + + +@router.get("") +def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]: + rows = store.list_dashboards() + return [NamedDashboard(**row) for row in rows] + + +@router.get("/slug/{slug}") +def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard: + row = store.get_dashboard_by_slug(slug) + if not row: + raise HTTPException(status_code=404, detail="Dashboard not found") + return NamedDashboard(**row) + + +@router.post("") +def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard: + row = store.upsert_dashboard(body.model_dump()) + return NamedDashboard(**row) + + +@router.put("/{dashboard_id}") +def update_dashboard( + dashboard_id: str, + body: NamedDashboardInput, + store: SettingsStore = Depends(get_settings_store), +) -> NamedDashboard: + if not store.get_dashboard(dashboard_id): + raise HTTPException(status_code=404, detail="Dashboard not found") + if body.id and body.id != dashboard_id: + raise HTTPException(status_code=400, detail="ID mismatch") + row = store.upsert_dashboard(body.model_dump(), dashboard_id) + return NamedDashboard(**row) + + +@router.delete("/{dashboard_id}") +def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]: + if not store.get_dashboard(dashboard_id): + raise HTTPException(status_code=404, detail="Dashboard not found") + store.delete_dashboard(dashboard_id) + return {"status": "deleted"} diff --git a/backend/src/media_library_viewer_api/routers/users.py b/backend/src/media_library_viewer_api/routers/users.py deleted file mode 100644 index 3e6d7bf..0000000 --- a/backend/src/media_library_viewer_api/routers/users.py +++ /dev/null @@ -1 +0,0 @@ -from .users_impl import * # noqa: F401,F403 diff --git a/backend/src/media_library_viewer_api/routers/users_impl.py b/backend/src/media_library_viewer_api/routers/users_impl.py deleted file mode 100644 index 275e130..0000000 --- a/backend/src/media_library_viewer_api/routers/users_impl.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Users router — Jellyfin list plus optional Jellyseerr enrichment.""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status - -from media_library_viewer_api.clients.jellyfin import JellyfinClient -from media_library_viewer_api.clients.jellyseerr import JellyseerrClient -from media_library_viewer_api.config import get_settings -from media_library_viewer_api.dependencies import ( - get_jellyfin_client, - get_jellyseerr_client, - get_mail_queue, -) -from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/users", tags=["users"]) - - -_PERMISSION_FLAGS = [ - (2, "admin"), - (4, "manage_settings"), - (8, "manage_users"), - (16, "manage_requests"), - (32, "request"), - (64, "vote"), - (128, "auto_approve"), - (256, "auto_approve_movie"), - (512, "auto_approve_tv"), - (1024, "request_4k"), - (2048, "request_4k_movie"), - (4096, "request_4k_tv"), - (8192, "request_advanced"), - (16384, "request_view"), - (32768, "auto_approve_4k"), - (65536, "auto_approve_4k_movie"), - (131072, "auto_approve_4k_tv"), - (262144, "request_movie"), - (524288, "request_tv"), - (1048576, "manage_issues"), - (2097152, "view_issues"), -] - -_USER_TYPES = { - 1: "plex", - 2: "local", - 3: "jellyfin", - 4: "emby", -} - - -def _safe_int(value: Any) -> int: - try: - return int(value) - except (TypeError, ValueError): - return 0 - - -def _permission_labels(permissions: int) -> list[str]: - labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit] - return labels or ["none"] - - -def _role_label(permissions: int) -> str: - if permissions & 2: - return "admin" - if permissions & (4 | 8 | 16): - return "manager" - if permissions & (32 | 64 | 128): - return "requester" - return "user" - - -def _account_type(user_type: Any) -> str: - return _USER_TYPES.get(_safe_int(user_type), "unknown") - - -def _merge_users( - jellyfin_users: list[dict[str, Any]], - jellyseerr_jellyfin_users: list[dict[str, Any]] | None, - jellyseerr_users: list[dict[str, Any]] | None, - jellyseerr_client: JellyseerrClient | None, -) -> dict[str, Any]: - def _normalize(value: Any) -> str: - return str(value or "").strip().lower() - - def _looks_like_email(value: Any) -> bool: - text = str(value or "").strip() - return bool(text and "@" in text and " " not in text) - - def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]: - for source, value in candidates: - if _looks_like_email(value): - return source, str(value).strip() - return "", "" - - def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]: - for source, value in candidates: - text = str(value or "").strip() - if text: - return source, text - return "", "" - - def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str: - return ", ".join( - [ - f"name={name_source or 'none'}", - f"email={email_source or 'none'}", - f"avatar={avatar_source or 'none'}", - f"access={access_source or 'none'}", - ] - ) - - def _lookup_keys(item: dict[str, Any]) -> list[str]: - return [ - _normalize(item.get("id")), - _normalize(item.get("Id")), - _normalize(item.get("userId")), - _normalize(item.get("user_id")), - _normalize(item.get("jellyfinUserId")), - _normalize(item.get("jellyfin_user_id")), - _normalize(item.get("jellyfinUsername")), - _normalize(item.get("jellyfin_username")), - _normalize(item.get("username")), - _normalize(item.get("displayName")), - _normalize(item.get("display_name")), - ] - - linked_by_jellyfin_id: dict[str, dict[str, Any]] = {} - for item in jellyseerr_jellyfin_users or []: - for key in ( - item.get("id"), - item.get("Id"), - item.get("userId"), - item.get("user_id"), - item.get("jellyfinUserId"), - item.get("jellyfin_user_id"), - ): - normalized = _normalize(key) - if normalized: - linked_by_jellyfin_id[normalized] = item - - seerr_by_key: dict[str, dict[str, Any]] = {} - for item in jellyseerr_users or []: - for key in _lookup_keys(item): - if key: - seerr_by_key[key] = item - - items: list[dict[str, Any]] = [] - enriched_count = 0 - for user in jellyfin_users: - jellyfin_id = str(user.get("Id") or user.get("id") or "") - jellyfin_name = str(user.get("Name") or user.get("name") or "") - jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id)) - - seerr_user = None - for candidate in [ - jellyfin_name, - (jf_link or {}).get("jellyfinUsername"), - (jf_link or {}).get("jellyfin_username"), - (jf_link or {}).get("username"), - (jf_link or {}).get("displayName"), - (jf_link or {}).get("display_name"), - ]: - seerr_user = seerr_by_key.get(_normalize(candidate)) - if seerr_user: - break - - email_source, email = _pick_source_and_value( - [ - ("jellyseerr:user", (seerr_user or {}).get("email")), - ("jellyseerr:jellyfin", (jf_link or {}).get("email")), - ] - ) - avatar_source, avatar = _first_value( - [ - ("jellyseerr:user", (seerr_user or {}).get("avatar")), - ("jellyseerr:jellyfin", (jf_link or {}).get("thumb")), - ("jellyseerr:jellyfin", (jf_link or {}).get("avatar")), - ] - ) - if avatar and jellyseerr_client: - avatar = jellyseerr_client.absolute_url(avatar) - - permissions = _safe_int((seerr_user or {}).get("permissions")) - user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type")) - role = _role_label(permissions) - access_source = "jellyseerr:user" if seerr_user else "" - name_source = "jellyfin" - summary = _source_summary(name_source, email_source, avatar_source, access_source) - - if seerr_user or jf_link: - enriched_count += 1 - - items.append( - { - "jellyfin_id": jellyfin_id, - "username": jellyfin_name, - "display_name": jellyfin_name, - "email": email, - "email_source": email_source, - "avatar": avatar, - "avatar_source": avatar_source, - "contactable": bool(email), - "source": summary, - "source_summary": summary, - "name_source": name_source, - "access_source": access_source, - "jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId")) - or None, - "jellyseerr_username": str( - (seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or "" - ), - "user_type": user_type or None, - "user_type_label": _account_type(user_type), - "role": role, - "permissions": permissions, - "permissions_label": ", ".join(_permission_labels(permissions)), - "request_count": _safe_int((seerr_user or {}).get("requestCount")) or None, - } - ) - - logger.info( - "Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s", - len(jellyfin_users), - len(jellyseerr_jellyfin_users or []), - len(jellyseerr_users or []), - enriched_count, - ) - return { - "items": items, - "total": len(items), - "jellyseerr_configured": jellyseerr_client is not None, - "jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users), - "jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []), - "jellyseerr_user_count": len(jellyseerr_users or []), - "enriched_count": enriched_count, - } - - -@router.get("") -def get_users( - jellyfin: JellyfinClient = Depends(get_jellyfin_client), - jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client), -) -> dict[str, Any]: - """Return the known users, enriched with Jellyseerr data when available.""" - jellyfin_users = jellyfin.users() - logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users)) - jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None - jellyseerr_users: list[dict[str, Any]] | None = None - jellyseerr_error = "" - if jellyseerr: - try: - jellyseerr_jellyfin_users = jellyseerr.jellyfin_users() - except Exception as exc: # pragma: no cover - network fallback - logger.exception("Jellyseerr Jellyfin-linked user fetch failed") - jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}" - try: - jellyseerr_users = jellyseerr.users() - except Exception as exc: # pragma: no cover - network fallback - logger.exception("Jellyseerr user list fetch failed") - jellyseerr_error = ( - f"{jellyseerr_error}; " if jellyseerr_error else "" - ) + f"Jellyseerr user list fetch failed: {exc}" - - result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr) - result["jellyseerr_error"] = jellyseerr_error - logger.info( - "Users response total=%s configured=%s available=%s enriched=%s error=%s", - result["total"], - result["jellyseerr_configured"], - result["jellyseerr_available"], - result["enriched_count"], - bool(jellyseerr_error), - ) - return result - - -@router.get("/message/status") -def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]: - """Return the current background email queue status.""" - return mail_queue.status() - - -@router.post("/message", status_code=status.HTTP_202_ACCEPTED) -async def post_user_message( - recipient_ids: str = Form(...), - subject: str = Form(...), - html_body: str = Form(""), - text_body: str = Form(""), - attachments: list[UploadFile] | None = File(default=None), - jellyfin: JellyfinClient = Depends(get_jellyfin_client), - jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client), - mail_queue=Depends(get_mail_queue), -) -> dict[str, Any]: - """Queue a single email to the selected users without blocking the API.""" - try: - requested_ids = json.loads(recipient_ids) - except json.JSONDecodeError as exc: - raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc - - if not isinstance(requested_ids, list): - raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list") - - cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()] - if not cleaned_ids: - raise HTTPException(status_code=400, detail="At least one recipient is required") - - subject = subject.strip() - if not subject: - raise HTTPException(status_code=400, detail="Subject is required") - - directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr) - users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])} - - recipients: list[str] = [] - recipient_labels: list[str] = [] - skipped: list[dict[str, str]] = [] - for user_id in cleaned_ids: - item = users_by_id.get(user_id) - if not item: - skipped.append({"jellyfin_id": user_id, "reason": "not found"}) - continue - email = str(item.get("email") or "").strip() - if not email: - skipped.append({"jellyfin_id": user_id, "reason": "missing email"}) - continue - recipients.append(email) - recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>") - - if not recipients: - raise HTTPException(status_code=400, detail="No selected users have a deliverable email address") - - settings = get_settings() - validate_smtp_settings(settings) - - queue_status = mail_queue.status() - if not queue_status["worker_running"]: - raise HTTPException(status_code=503, detail="Email queue worker is not running") - - attachment_payloads: list[EmailAttachment] = [] - for upload in attachments or []: - data = await upload.read() - if not data: - continue - attachment_payloads.append( - EmailAttachment( - filename=upload.filename or "attachment", - content_type=upload.content_type or "application/octet-stream", - data=data, - ) - ) - - request_id = mail_queue.enqueue( - settings=settings, - recipients=recipients, - subject=subject, - html_body=html_body, - text_body=text_body, - attachments=attachment_payloads, - ) - from_address = ( - str(getattr(settings, "smtp_from_address", "") or "").strip() - or str(getattr(settings, "smtp_username", "") or "").strip() - ) - logger.info( - "Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s", - request_id, - subject, - len(recipients), - len(attachment_payloads), - len(skipped), - ) - return { - "status": "queued", - "request_id": request_id, - "from_address": from_address, - "recipient_count": len(recipients), - "attachment_count": len(attachment_payloads), - "subject": subject, - "recipient_labels": recipient_labels, - "skipped": skipped, - } diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index e2021b2..30e0ec0 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -8,6 +8,7 @@ in the same UI. from __future__ import annotations import json +import logging import sqlite3 import time import uuid @@ -19,6 +20,8 @@ import paramiko from media_library_viewer_api.models.widgets import _validate_config_keys +logger = logging.getLogger(__name__) + DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite") LOCAL_MACHINE_ID = "local" DEFAULT_SERVICES = ["monitoring", "files"] @@ -172,6 +175,9 @@ class SettingsStore: created_at INTEGER NOT NULL ) """) + backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()} + if "service_id" not in backup_job_cols: + conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT") conn.execute(""" CREATE TABLE IF NOT EXISTS backup_runs ( id TEXT PRIMARY KEY, @@ -244,6 +250,19 @@ class SettingsStore: conn.execute( "CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)" ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS named_dashboards ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) @staticmethod def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]: @@ -415,6 +434,75 @@ class SettingsStore: row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() if not row or int(row[0]) == 0: self._seed_local_machine() + self._migrate_jellyseerr_into_jellyfin() + + def _migrate_jellyseerr_into_jellyfin(self) -> None: + """Absorb standalone ``jellyseerr`` services into their paired Jellyfin. + + Idempotent: once no ``jellyseerr`` rows remain the method is a no-op. + Pairing policy: exactly-one Jellyfin merges; multiple picks the first + Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all + paired -> drop with a logged warning. + """ + from media_library_viewer_api.services.secrets import decrypt_value + + self.init_schema() + jellyseerr_rows: list[sqlite3.Row] = [] + with self.connect() as conn: + jellyseerr_rows = conn.execute( + "SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC" + ).fetchall() + if not jellyseerr_rows: + return + + jellyfin_rows = self.list_services("jellyfin") + for js_row in jellyseerr_rows: + js_config = json.loads(js_row["config_json"] or "{}") + js_secrets = json.loads(js_row["secrets_json"] or "{}") + js_url = str(js_config.get("base_url", "")).strip() + js_api_key = str(js_secrets.get("api_key", "")).strip() + # Decrypt the api_key (secrets are stored encrypted; config is plaintext). + if js_api_key: + try: + js_api_key = decrypt_value(js_api_key) + except Exception: + logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"]) + js_api_key = "" + js_name = js_row["name"] + + target = None + if len(jellyfin_rows) == 1: + target = jellyfin_rows[0] + elif len(jellyfin_rows) > 1: + for jf in jellyfin_rows: + if not str(jf["config"].get("jellyseerr_url", "")).strip(): + target = jf + break + + if target: + merged_config = dict(target["config"]) + merged_config["jellyseerr_url"] = js_url + merged_config["jellyseerr_api_key"] = js_api_key + self.upsert_service( + { + "id": target["id"], + "service_type": "jellyfin", + "name": target["name"], + "config": merged_config, + "enabled": target["enabled"], + }, + secret_values={"api_key": str(target["secrets"].get("api_key", ""))}, + ) + logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"]) + else: + logger.warning( + "dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance", + js_name, + ) + + with self.connect() as conn: + conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],)) + conn.commit() def list_machines(self) -> list[dict[str, Any]]: self.init_schema() @@ -892,6 +980,7 @@ class SettingsStore: "source": row["source"], "target": row["target"], "schedule_interval_seconds": row["schedule_interval_seconds"], + "service_id": row["service_id"], "created_at": row["created_at"], } @@ -910,12 +999,18 @@ class SettingsStore: schedule_interval_seconds = (current or {}).get("schedule_interval_seconds") if schedule_interval_seconds is not None: schedule_interval_seconds = int(schedule_interval_seconds) + service_id = str( + payload.get("service_id") + if payload.get("service_id") is not None + else (current or {}).get("service_id", "") or "" + ).strip() return { "id": job_id, "name": name, "source": source, "target": target, "schedule_interval_seconds": schedule_interval_seconds, + "service_id": service_id, } def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None: @@ -933,17 +1028,19 @@ class SettingsStore: created_at = int(existing[0]) if existing else now conn.execute( """ - INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, source = excluded.source, target = excluded.target, - schedule_interval_seconds = excluded.schedule_interval_seconds + schedule_interval_seconds = excluded.schedule_interval_seconds, + service_id = excluded.service_id ON CONFLICT(name) DO UPDATE SET source = excluded.source, target = excluded.target, - schedule_interval_seconds = excluded.schedule_interval_seconds + schedule_interval_seconds = excluded.schedule_interval_seconds, + service_id = excluded.service_id """, ( job["id"], @@ -951,6 +1048,7 @@ class SettingsStore: job["source"], job["target"], job["schedule_interval_seconds"], + job["service_id"], created_at, ), ) @@ -1566,6 +1664,113 @@ class SettingsStore: for row in rows ] + # ------------------------------------------------------------------ + # Named dashboards + # ------------------------------------------------------------------ + + @staticmethod + def _slugify(label: str) -> str: + import re + + slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") + return slug or "dashboard" + + def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str: + self.init_schema() + base = slug + suffix = 1 + with self.connect() as conn: + while True: + row = conn.execute( + "SELECT id FROM named_dashboards WHERE slug = ? AND id != ?", + (slug, exclude_id or ""), + ).fetchone() + if not row: + return slug + suffix += 1 + slug = f"{base}-{suffix}" + + def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "label": row["label"], + "slug": row["slug"], + "sort_order": row["sort_order"], + "payload": json.loads(row["payload_json"] or "{}"), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def list_dashboards(self) -> list[dict[str, Any]]: + self.init_schema() + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE" + ).fetchall() + return [self._row_to_dashboard(row) for row in rows] + + def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None: + if not dashboard_id: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone() + return self._row_to_dashboard(row) if row else None + + def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None: + if not slug: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone() + return self._row_to_dashboard(row) if row else None + + def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]: + self.init_schema() + current = self.get_dashboard(dashboard_id) if dashboard_id else None + dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip() + label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip() + slug = str(payload.get("slug") or "").strip() or self._slugify(label) + slug = self._unique_slug(slug, exclude_id=dash_id) + sort_order = payload.get("sort_order") + if sort_order is None: + sort_order = (current or {}).get("sort_order", 0) + sort_order = int(sort_order) + payload_data = payload.get("payload") + if payload_data is None: + payload_data = (current or {}).get("payload", {}) + now = int(time.time()) + with self.connect() as conn: + existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone() + created_at = int(existing[0]) if existing else now + conn.execute( + """ + INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + label = excluded.label, + slug = excluded.slug, + sort_order = excluded.sort_order, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at + """, + ( + dash_id, + label, + slug, + sort_order, + json.dumps(payload_data), + created_at, + now, + ), + ) + return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug} + + def delete_dashboard(self, dashboard_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,)) + _store: SettingsStore | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c986952..6bedb04 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -14,8 +14,6 @@ from fastapi.testclient import TestClient from media_library_viewer_api.clients.ssh import CommandResult from media_library_viewer_api.dependencies import ( get_jellyfin_client, - get_jellyseerr_client, - get_mail_queue, get_settings_store, get_ssh_client, get_user_id, @@ -70,38 +68,6 @@ def mock_jellyfin(): return client -@pytest.fixture -def mock_jellyseerr(): - """Mock Jellyseerr client.""" - client = MagicMock() - client.jellyfin_users.return_value = [ - {"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"}, - {"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"}, - ] - client.users.return_value = [ - { - "id": 7, - "username": "alex", - "email": "alex@example.com", - "avatar": "/avatarproxy/alex", - "userType": 3, - "permissions": 10, - "requestCount": 3, - }, - { - "id": 8, - "username": "sam", - "email": "sam@example.com", - "avatar": "/avatarproxy/sam", - "userType": 2, - "permissions": 32, - "requestCount": 1, - }, - ] - client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}" - return client - - @pytest.fixture def mock_ssh(): """Mock SSH client.""" @@ -132,10 +98,9 @@ def mock_ssh(): @pytest.fixture -def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path): +def test_client(mock_jellyfin, mock_ssh, tmp_path): """FastAPI test client with mocked dependencies.""" app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin - app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr app.dependency_overrides[get_ssh_client] = lambda: mock_ssh app.dependency_overrides[get_user_id] = lambda: "user123" store = SettingsStore(tmp_path / "settings.sqlite") @@ -293,122 +258,6 @@ class TestSettingsReset: assert len(store.list_machines()) == 0 -# --- Users --- - - -class TestUsers: - def test_users_list_enriched(self, test_client): - response = test_client.get("/api/users") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 2 - assert data["jellyseerr_configured"] is True - assert data["jellyseerr_available"] is True - assert data["jellyseerr_error"] == "" - - alex = next(item for item in data["items"] if item["username"] == "alex") - assert alex["email"] == "alex@example.com" - assert alex["email_source"] == "jellyseerr:user" - assert alex["contactable"] is True - assert alex["avatar"].startswith("https://requests.example.com/") - assert alex["avatar_source"] == "jellyseerr:user" - assert alex["permissions"] == 10 - assert alex["permissions_label"] == "admin, manage_users" - assert alex["role"] == "admin" - assert alex["user_type_label"] == "jellyfin" - assert alex["request_count"] == 3 - assert "name=jellyfin" in alex["source_summary"] - assert "email=jellyseerr:user" in alex["source_summary"] - - sam = next(item for item in data["items"] if item["username"] == "sam") - assert sam["role"] == "requester" - assert sam["user_type_label"] == "local" - assert sam["email"] == "sam@example.com" - - def test_users_message_status(self, test_client): - mail_queue = MagicMock() - mail_queue.status.return_value = { - "state": "idle", - "worker_running": True, - "stop_requested": False, - "pending_count": 0, - "active_request_id": None, - "last_request_id": None, - "last_result": None, - "last_error": "", - "last_error_at": None, - "last_success_at": None, - "last_activity_at": None, - "sent_count": 0, - "failed_count": 0, - } - app.dependency_overrides[get_mail_queue] = lambda: mail_queue - try: - response = test_client.get("/api/users/message/status") - finally: - app.dependency_overrides.pop(get_mail_queue, None) - assert response.status_code == 200 - assert response.json()["state"] == "idle" - assert response.json()["pending_count"] == 0 - - def test_users_message_is_queued(self, test_client): - mail_queue = MagicMock() - mail_queue.status.return_value = { - "state": "idle", - "worker_running": True, - "stop_requested": False, - "pending_count": 0, - "active_request_id": None, - "last_request_id": None, - "last_result": None, - "last_error": "", - "last_error_at": None, - "last_success_at": None, - "last_activity_at": None, - "sent_count": 0, - "failed_count": 0, - } - mail_queue.enqueue.return_value = "mail-123456" - app.dependency_overrides[get_mail_queue] = lambda: mail_queue - settings = SimpleNamespace( - smtp_host="smtp.example.com", - smtp_port=587, - smtp_username="mailer@example.com", - smtp_password="secret", - smtp_from_address="mailer@example.com", - smtp_from_name="Manage", - smtp_use_tls=True, - smtp_use_ssl=False, - smtp_timeout=15, - ) - - try: - with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings): - response = test_client.post( - "/api/users/message", - data={ - "recipient_ids": json.dumps(["jf1", "jf2"]), - "subject": "Hello team", - "html_body": "

Hi there

", - "text_body": "Hi there", - }, - ) - finally: - app.dependency_overrides.pop(get_mail_queue, None) - - assert response.status_code == 202 - data = response.json() - assert data["status"] == "queued" - assert data["request_id"] == "mail-123456" - assert data["recipient_count"] == 2 - assert data["attachment_count"] == 0 - mail_queue.enqueue.assert_called_once() - kwargs = mail_queue.enqueue.call_args.kwargs - assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"] - assert kwargs["subject"] == "Hello team" - assert kwargs["settings"] is settings - - # --- Files --- diff --git a/backend/tests/test_authentik_client.py b/backend/tests/test_authentik_client.py new file mode 100644 index 0000000..b318de0 --- /dev/null +++ b/backend/tests/test_authentik_client.py @@ -0,0 +1,183 @@ +"""Tests for AuthentikClient and the directory endpoint.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest +from cryptography.fernet import Fernet +from fastapi.testclient import TestClient + +from media_library_viewer_api.clients.authentik import AuthentikClient +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.main import app +from media_library_viewer_api.services.secrets import 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: pytest.MonkeyPatch) -> None: + """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 store(tmp_path: Path) -> SettingsStore: + s = SettingsStore(tmp_path / "settings.sqlite") + s.ensure_defaults() + app.dependency_overrides[get_settings_store] = lambda: s + yield s + app.dependency_overrides.clear() + + +# --------------------------------------------------------------------------- +# Client unit tests +# --------------------------------------------------------------------------- + + +class TestAuthentikClient: + def test_base_url_normalizes_trailing_slash(self) -> None: + c = AuthentikClient(base_url="https://auth.example.com/", api_token="t") + assert c.base_url == "https://auth.example.com" + + def test_base_url_strips_api_v3_suffix(self) -> None: + c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t") + assert c.base_url == "https://auth.example.com" + + def test_bearer_header_is_set(self) -> None: + c = AuthentikClient(base_url="https://auth.example.com", api_token="tok") + assert c.session.headers["Authorization"] == "Bearer tok" + + def test_empty_base_url_raises(self) -> None: + with pytest.raises(ValueError): + AuthentikClient(base_url="", api_token="t") + + def test_empty_api_token_raises(self) -> None: + with pytest.raises(ValueError): + AuthentikClient(base_url="https://auth.example.com", api_token="") + + @patch.object(AuthentikClient, "get") + def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None: + mock_get.return_value = { + "pagination": {"count": 42, "next": 2, "previous": 0, "current": 1}, + "results": [ + {"pk": 1, "username": "alice", "email": "alice@example.com"}, + {"pk": 2, "username": "bob", "email": "bob@example.com"}, + ], + } + client = AuthentikClient(base_url="https://auth.example.com", api_token="t") + result = client.users(search="ali", page=1, page_size=2) + assert result["total"] == 42 + assert result["page"] == 1 + assert result["page_size"] == 2 + assert len(result["items"]) == 2 + assert result["items"][0]["username"] == "alice" + + @patch.object(AuthentikClient, "get") + def test_users_handles_empty_results(self, mock_get: MagicMock) -> None: + mock_get.return_value = {"pagination": {"count": 0}, "results": []} + client = AuthentikClient(base_url="https://auth.example.com", api_token="t") + result = client.users() + assert result["items"] == [] + assert result["total"] == 0 + + @patch.object(AuthentikClient, "get") + def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None: + mock_get.return_value = [] + client = AuthentikClient(base_url="https://auth.example.com", api_token="t") + result = client.users() + assert result["items"] == [] + assert result["total"] == 0 + + @patch("media_library_viewer_api.clients.authentik.requests.Session") + def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + mock_response = MagicMock() + mock_response.json.return_value = {"results": []} + mock_response.raise_for_status.return_value = None + mock_session.get.return_value = mock_response + + c = AuthentikClient(base_url="https://auth.example.com", api_token="t") + c.get("/core/users/", search="x", page=2) + + call_args = mock_session.get.call_args + assert call_args.kwargs["params"] == {"search": "x", "page": 2} + assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/" + + +# --------------------------------------------------------------------------- +# Endpoint integration tests +# --------------------------------------------------------------------------- + + +class TestAuthentikUsersEndpoint: + def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None: + client = TestClient(app) + response = client.get("/api/services/authentik/nonexistent/users") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert data["total"] == 0 + assert "error" in data + + @patch("media_library_viewer_api.routers.authentik_users.AuthentikClient") + def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None: + mock_client = MagicMock() + mock_client.users.return_value = { + "items": [{"pk": 1, "username": "alice"}], + "total": 1, + "page": 1, + "page_size": 50, + } + mock_client_cls.return_value = mock_client + + created = store.upsert_service( + { + "service_type": "authentik", + "name": "Main", + "config": {"base_url": "https://auth.example.com"}, + "enabled": True, + }, + secret_values={"api_token": "secret-token"}, + ) + service_id = created["id"] + + client = TestClient(app) + response = client.get(f"/api/services/authentik/{service_id}/users?search=ali") + assert response.status_code == 200 + data = response.json() + assert len(data["items"]) == 1 + assert data["items"][0]["username"] == "alice" + assert data["total"] == 1 + assert "error" not in data + + @patch("media_library_viewer_api.routers.authentik_users.AuthentikClient") + def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None: + mock_client = MagicMock() + mock_client.users.side_effect = ConnectionError("refused") + mock_client_cls.return_value = mock_client + + created = store.upsert_service( + { + "service_type": "authentik", + "name": "Main", + "config": {"base_url": "https://auth.example.com"}, + "enabled": True, + }, + secret_values={"api_token": "secret-token"}, + ) + service_id = created["id"] + + client = TestClient(app) + response = client.get(f"/api/services/authentik/{service_id}/users") + assert response.status_code == 200 + data = response.json() + assert data["items"] == [] + assert "error" in data diff --git a/backend/tests/test_dashboards.py b/backend/tests/test_dashboards.py new file mode 100644 index 0000000..8d30e7e --- /dev/null +++ b/backend/tests/test_dashboards.py @@ -0,0 +1,97 @@ +"""Tests for named-dashboards CRUD + slug uniqueness.""" + +from __future__ import annotations + +from pathlib import Path + +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 + + +def _client(tmp_path: Path) -> TestClient: + store = SettingsStore(tmp_path / "settings.sqlite") + app.dependency_overrides[get_settings_store] = lambda: store + client = TestClient(app) + client.store = store # type: ignore[attr-defined] + return client + + +def test_create_and_list_dashboards(tmp_path: Path): + client = _client(tmp_path) + try: + resp = client.post( + "/api/dashboards", + json={"label": "Storage Overview", "payload": {"widgets": []}}, + ) + assert resp.status_code == 200 + created = resp.json() + assert created["label"] == "Storage Overview" + assert created["slug"] == "storage-overview" + assert created["payload"] == {"widgets": []} + + listed = client.get("/api/dashboards").json() + assert len(listed) == 1 + assert listed[0]["id"] == created["id"] + finally: + app.dependency_overrides.clear() + + +def test_update_dashboard(tmp_path: Path): + client = _client(tmp_path) + try: + created = client.post("/api/dashboards", json={"label": "First"}).json() + updated = client.put( + f"/api/dashboards/{created['id']}", + json={"label": "Renamed", "payload": {"widgets": ["w1"]}}, + ).json() + assert updated["label"] == "Renamed" + assert updated["payload"] == {"widgets": ["w1"]} + assert updated["slug"] == "renamed" + finally: + app.dependency_overrides.clear() + + +def test_delete_dashboard(tmp_path: Path): + client = _client(tmp_path) + try: + created = client.post("/api/dashboards", json={"label": "Temp"}).json() + resp = client.delete(f"/api/dashboards/{created['id']}") + assert resp.status_code == 200 + assert client.get("/api/dashboards").json() == [] + finally: + app.dependency_overrides.clear() + + +def test_slug_collision_appends_suffix(tmp_path: Path): + client = _client(tmp_path) + try: + first = client.post("/api/dashboards", json={"label": "Overview"}).json() + second = client.post("/api/dashboards", json={"label": "Overview"}).json() + assert first["slug"] == "overview" + assert second["slug"] == "overview-2" + finally: + app.dependency_overrides.clear() + + +def test_explicit_slug_respected(tmp_path: Path): + client = _client(tmp_path) + try: + created = client.post( + "/api/dashboards", + json={"label": "My Dashboard", "slug": "custom-slug"}, + ).json() + assert created["slug"] == "custom-slug" + finally: + app.dependency_overrides.clear() + + +def test_update_nonexistent_returns_404(tmp_path: Path): + client = _client(tmp_path) + try: + resp = client.put("/api/dashboards/nope", json={"label": "X"}) + assert resp.status_code == 404 + finally: + app.dependency_overrides.clear() diff --git a/backend/tests/test_services.py b/backend/tests/test_services.py index b041762..cfbe6ce 100644 --- a/backend/tests/test_services.py +++ b/backend/tests/test_services.py @@ -57,24 +57,55 @@ def client(tmp_path): # --------------------------------------------------------------------------- -def test_registry_contains_seven_service_types(): +def test_registry_contains_eight_service_types(): assert set(SERVICE_DEFINITIONS) == { "grafana", "prometheus", "alertmanager", "jellyfin", - "jellyseerr", "nextcloud", "ssh_tasks", + "backups", + "authentik", } +def test_jellyseerr_absorbed_into_jellyfin(): + """Jellyseerr is no longer its own service type (absorbed into Jellyfin).""" + assert "jellyseerr" not in SERVICE_DEFINITIONS + jellyfin_config = get_service_definition("jellyfin").config_schema["properties"] + assert "jellyseerr_url" in jellyfin_config + assert "jellyseerr_api_key" in jellyfin_config + + +def test_backups_service_definition(): + definition = get_service_definition("backups") + assert definition is not None + assert definition.secret_fields == [] + assert {wk.kind for wk in definition.widget_kinds} == {"summary"} + schema = definition.config_schema + assert "ingestion_label" in schema["properties"] + + +def test_authentik_service_definition(): + definition = get_service_definition("authentik") + assert definition is not None + assert {sf.key for sf in definition.secret_fields} == {"api_token"} + assert definition.secret_fields[0].required is True + assert definition.widget_kinds == [] + schema = definition.config_schema + assert "base_url" in schema["properties"] + assert "timeout_seconds" in schema["properties"] + + 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("alertmanager").widget_kinds} == {"active_alerts"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"} assert get_service_definition("nextcloud").widget_kinds == [] + assert get_service_definition("authentik").widget_kinds == [] + assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"} assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"} @@ -139,9 +170,10 @@ def test_list_service_types(client): types = {item["service_type"] for item in response.json()} assert types == { "alertmanager", + "authentik", + "backups", "grafana", "jellyfin", - "jellyseerr", "nextcloud", "prometheus", "ssh_tasks", @@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url): @pytest.mark.parametrize( - "service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"] + "service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"] ) def test_service_base_url_accepts_absolute_urls(service_type): model = get_service_definition(service_type).config_model @@ -390,3 +422,99 @@ def test_record_and_list_service_task_runs(client): assert len(runs) == 1 assert runs[0]["status"] == "success" assert runs[0]["stdout_tail"] == "ok" + + +# --------------------------------------------------------------------------- +# Jellyseerr → Jellyfin migration (Slice 1.4 / 1.5) +# --------------------------------------------------------------------------- + + +def test_jellyseerr_migrates_into_single_jellyfin(tmp_path): + """A standalone jellyseerr service merges into the only jellyfin instance.""" + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + jellyfin = store.upsert_service( + { + "service_type": "jellyfin", + "name": "Main Jellyfin", + "config": {"base_url": "https://jellyfin.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "jf-key"}, + ) + store.upsert_service( + { + "service_type": "jellyseerr", + "name": "Main Jellyseerr", + "config": {"base_url": "https://jellyseerr.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "js-key"}, + ) + + # Run migration via ensure_defaults (idempotent entry point). + store.ensure_defaults() + + # Jellyseerr row is gone. + assert store.list_services("jellyseerr") == [] + + # Jellyfin config gained the absorbed fields. + migrated = store.get_service(jellyfin["id"]) + assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com" + assert migrated["config"]["jellyseerr_api_key"] == "js-key" + + +def test_jellyseerr_dropped_when_no_jellyfin(tmp_path): + """An unpaired jellyseerr (no jellyfin) is dropped with a warning, no crash.""" + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + store.upsert_service( + { + "service_type": "jellyseerr", + "name": "Orphan Jellyseerr", + "config": {"base_url": "https://jellyseerr.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "js-key"}, + ) + + store.ensure_defaults() + + assert store.list_services("jellyseerr") == [] + assert store.list_services("jellyfin") == [] + + +def test_jellyseerr_migration_is_idempotent(tmp_path): + """Running ensure_defaults twice does nothing the second time.""" + store = SettingsStore(tmp_path / "settings.sqlite") + store.ensure_defaults() + + store.upsert_service( + { + "service_type": "jellyfin", + "name": "JF", + "config": {"base_url": "https://jellyfin.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "k"}, + ) + store.upsert_service( + { + "service_type": "jellyseerr", + "name": "JS", + "config": {"base_url": "https://jellyseerr.example.com"}, + "enabled": True, + }, + secret_values={"api_key": "k"}, + ) + + store.ensure_defaults() + first_jellyfin = store.list_services("jellyfin")[0] + first_url = first_jellyfin["config"]["jellyseerr_url"] + + store.ensure_defaults() # second run + second_jellyfin = store.list_services("jellyfin")[0] + assert second_jellyfin["config"]["jellyseerr_url"] == first_url + assert store.list_services("jellyseerr") == [] diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b4ef87c..b030a99 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -5,7 +5,6 @@ import { NavLink, useLocation, Outlet, - Navigate, } from "react-router-dom"; import { QueryClient, @@ -13,22 +12,22 @@ import { useQuery, } from "@tanstack/react-query"; import { useEffect, useMemo, useState } from "react"; +import type { LucideIcon } from "lucide-react"; import { AuthProvider, useAuth } from "react-oidc-context"; import { Dashboard } from "./pages/Dashboard"; -import { Applications } from "./pages/Applications"; +import { NamedDashboardPage } from "./pages/NamedDashboardPage"; import { Settings } from "./pages/Settings"; -import { UsersPage } from "./pages/Users"; -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 { ServiceTypePage } from "./pages/ServiceTypePage"; import { ServicesPage } from "./pages/ServicesPage"; import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; import { fetchAppVersion } from "./api/client"; import { FRONTEND_VERSION_LABEL } from "./version"; import { usePersistentState } from "./hooks/usePersistentState"; import { useIsMobile } from "./hooks/useIsMobile"; +import { useServiceInstances } from "./hooks/useServices"; +import { useDashboards } from "./hooks/useDashboards"; +import { configuredNavEntries } from "./integrations/navEntries"; import { Button } from "@/components/ui/button"; import { Tooltip, @@ -45,12 +44,6 @@ import { } from "@/components/ui/sheet"; import { LayoutDashboard, - Activity, - DatabaseBackup, - Monitor, - Users, - Zap, - FolderOpen, Settings as SettingsIcon, Menu, Sun, @@ -59,6 +52,7 @@ import { ChevronLeft, ChevronRight, Boxes, + LayoutTemplate, } from "lucide-react"; const queryClient = new QueryClient({ @@ -66,9 +60,6 @@ const queryClient = new QueryClient({ queries: { retry: 1, refetchOnWindowFocus: false, - // Pause interval-based refetches (widgets ~30s, queue status 5s, - // media build progress 1s) when the tab is hidden. Saves battery on - // mobile (D8 follow-up). Build progress polls resume on return. refetchIntervalInBackground: false, }, }, @@ -92,18 +83,40 @@ function useDarkMode() { return [darkMode, () => setDarkMode((prev) => !prev)] as const; } -// Navigation items for sidebar -const navItems = [ - { path: "/", label: "Dashboard", icon: LayoutDashboard }, - { path: "/observability", label: "Observability", icon: Activity }, - { 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 }, -]; +// Navigation items are data-driven (spec R1). Built from configured services + dashboards. +interface NavItem { + path: string; + label: string; + icon: LucideIcon; +} + +function useNavItems() { + const { data: services = [] } = useServiceInstances(); + const { data: dashboards = [] } = useDashboards(); + + return useMemo(() => { + const configuredTypes = new Set( + services.filter((s) => s.enabled).map((s) => s.service_type), + ); + const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({ + path: e.path, + label: e.label, + icon: e.icon, + })); + const dashboardEntries = dashboards.map((d) => ({ + path: `/d/${d.slug}`, + label: d.label, + icon: LayoutTemplate, + })); + return [ + { path: "/", label: "Dashboard", icon: LayoutDashboard }, + ...dashboardEntries, + ...serviceEntries, + { path: "/services", label: "Services", icon: Boxes }, + { path: "/settings", label: "Settings", icon: SettingsIcon }, + ]; + }, [services, dashboards]); +} function Sidebar({ collapsed, @@ -115,6 +128,7 @@ function Sidebar({ isMobile: boolean; }) { const location = useLocation(); + const navItems = useNavItems(); if (isMobile) return null; @@ -199,11 +213,12 @@ function Sidebar({ function MobileDrawer() { const [open, setOpen] = useState(false); const location = useLocation(); + const navItems = useNavItems(); return ( - @@ -263,6 +278,7 @@ function TopBar({ }); const backendLabel = appVersion?.backend_label || "…"; + const navItems = useNavItems(); const pageTitle = navItems.find((item) => item.path === location.pathname)?.label || "Dashboard"; @@ -290,7 +306,7 @@ function TopBar({ variant="ghost" size="icon" onClick={onToggleDarkMode} - className="mobile-touch-target h-8 w-8" + className="h-8 w-8" > {darkMode ? ( @@ -303,7 +319,7 @@ function TopBar({ variant="ghost" size="sm" onClick={onSignOut} - className="mobile-touch-target gap-2" + className="gap-2" > Logout @@ -428,6 +444,18 @@ function AuthenticatedApp() { ); } +function NotFoundPage() { + return ( +
+

Not found

+

This page doesn't exist.

+ +
+ ); +} + function AppInner() { const [darkMode, toggleDarkMode] = useDarkMode(); @@ -439,26 +467,18 @@ function AppInner() { }> } /> - } - /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> + } /> } /> } /> + } + /> } /> + } /> @@ -475,26 +495,18 @@ function AppInner() { } > } /> - } - /> - } /> - } - /> - } /> - } /> - } /> - } /> - } /> + } /> } /> } /> + } + /> } /> + } /> diff --git a/frontend/src/api/authentik.ts b/frontend/src/api/authentik.ts new file mode 100644 index 0000000..14a874f --- /dev/null +++ b/frontend/src/api/authentik.ts @@ -0,0 +1,65 @@ +/** API client for the Authentik service (directory + messaging). */ +import { get, post } from "./shared"; + +export interface AuthentikUser { + pk: number; + username: string; + name: string; + email: string; + is_active: boolean; + avatar: string | null; + [key: string]: unknown; +} + +export interface AuthentikUsersResponse { + items: AuthentikUser[]; + total: number; + page: number; + page_size: number; + error?: string; +} + +export async function fetchAuthentikUsers( + serviceId: string, + params: { search?: string; page?: number; page_size?: number }, +): Promise { + return get( + `/api/services/authentik/${serviceId}/users`, + { + search: params.search ?? "", + page: String(params.page ?? 1), + page_size: String(params.page_size ?? 50), + }, + ); +} + +export interface AuthentikMessageInput { + recipient_emails: string[]; + subject: string; + html_body: string; +} + +export interface AuthentikMessageResponse { + status: string; + request_id?: string; + recipient_count?: number; + error?: string; +} + +export async function sendAuthentikMessage( + serviceId: string, + input: AuthentikMessageInput, +): Promise { + return post( + `/api/services/authentik/${serviceId}/message`, + input, + ); +} + +export async function fetchAuthentikMessageStatus( + serviceId: string, +): Promise> { + return get>( + `/api/services/authentik/${serviceId}/message/status`, + ); +} diff --git a/frontend/src/api/dashboards.ts b/frontend/src/api/dashboards.ts new file mode 100644 index 0000000..cf3325e --- /dev/null +++ b/frontend/src/api/dashboards.ts @@ -0,0 +1,50 @@ +/** + * API client for the named-dashboards backend (Slice 3). + */ +import { del, get, post, put } from "./shared"; + +export interface NamedDashboard { + id: string; + label: string; + slug: string; + sort_order: number; + payload: Record; + created_at: number; + updated_at: number; +} + +export interface NamedDashboardInput { + id?: string | null; + label: string; + slug?: string; + sort_order: number; + payload: Record; +} + +export async function fetchDashboards(): Promise { + return get("/api/dashboards"); +} + +export async function fetchDashboardBySlug( + slug: string, +): Promise { + return get( + `/api/dashboards/slug/${encodeURIComponent(slug)}`, + ); +} + +export async function createDashboard( + input: NamedDashboardInput, +): Promise { + return post("/api/dashboards", input); +} + +export async function updateDashboard( + input: NamedDashboardInput, +): Promise { + return put(`/api/dashboards`, input); +} + +export async function deleteDashboard(id: string): Promise<{ status: string }> { + return del<{ status: string }>(`/api/dashboards/${id}`); +} diff --git a/frontend/src/components/ObservabilityPage.tsx b/frontend/src/components/ObservabilityPage.tsx deleted file mode 100644 index 8ca7019..0000000 --- a/frontend/src/components/ObservabilityPage.tsx +++ /dev/null @@ -1,667 +0,0 @@ -import { useMemo, useState, type ElementType, type ReactNode } from "react"; -import { Link } from "react-router-dom"; -import { - Activity, - AlertTriangle, - Bell, - CheckCircle2, - ChevronDown, - ExternalLink, - Gauge, - Inbox, - Radio, - RefreshCw, - Server, - ServerOff, - XCircle, -} from "lucide-react"; -import { - useAlertmanagerAlerts, - useAlertmanagerStatus, - useGrafanaStatus, - usePrometheusStatus, - usePrometheusTargets, - useMonitoringMachines, -} from "../hooks/useObservability"; -import { useServiceInstances } from "../hooks/useServices"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Button } from "@/components/ui/button"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - Collapsible, - CollapsibleContent, - CollapsibleTrigger, -} from "@/components/ui/collapsible"; -import type { - AlertmanagerAlert, - MonitoringMachine, - PrometheusTarget, -} from "../types"; - -function severityVariant( - severity: string, -): "default" | "secondary" | "destructive" | "outline" { - switch (severity.toLowerCase()) { - case "critical": - return "destructive"; - case "warning": - return "default"; - case "info": - return "secondary"; - default: - return "outline"; - } -} - -function HealthCard({ - title, - status, - detail, - icon: Icon, - isLoading, -}: { - title: string; - status: "ok" | "warning" | "error" | "unknown"; - detail: string; - icon: ElementType; - isLoading?: boolean; -}) { - const statusIcon = - status === "ok" ? ( - - ) : status === "warning" ? ( - - ) : status === "error" ? ( - - ) : ( - - ); - - return ( - - - {title} - - - -
- {isLoading ? : statusIcon} - {status} -
-

{detail}

-
-
- ); -} - -function EmptyState({ - icon: Icon, - title, - description, - action, -}: { - icon: ElementType; - title: string; - description: string; - action?: ReactNode; -}) { - return ( -
- -
{title}
-
- {description} -
- {action ?
{action}
: null} -
- ); -} - -function QueryError({ - label, - error, - refetch, -}: { - label: string; - error: Error | null; - refetch: () => void; -}) { - if (!error) return null; - return ( - - {label} failed - - {error.message} - - - - ); -} - -function AlertItem({ alert }: { alert: AlertmanagerAlert }) { - return ( - - -
-
-
{alert.name}
-
- - {alert.severity} - - -
-
-
- {alert.summary || alert.description} -
- {alert.active_since && ( -
- Since {new Date(alert.active_since).toLocaleString()} -
- )} -
-
- -
- {alert.description && ( -
- Description:{" "} - {alert.description} -
- )} -
- {alert.job_name && ( -
- Job: {alert.job_name} -
- )} - {alert.category && ( -
- Category: {alert.category} -
- )} -
- State: {alert.state} -
-
- Since:{" "} - {alert.active_since - ? new Date(alert.active_since).toLocaleString() - : "unknown"} -
-
- {alert.labels && Object.keys(alert.labels).length > 0 && ( -
- {Object.entries(alert.labels).map(([key, value]) => ( - - {key}={value} - - ))} -
- )} -
-
-
- ); -} - -function TargetsTable({ targets }: { targets: PrometheusTarget[] }) { - return ( -
- {targets.map((target, idx) => ( -
-
{target.targets.join(", ")}
- {target.labels && Object.keys(target.labels).length > 0 && ( -
- {Object.entries(target.labels).map(([key, value]) => ( - - {key}: {value} - - ))} -
- )} -
- ))} -
- ); -} - -function GrafanaLinkCard({ - title, - description, - href, -}: { - title: string; - description: string; - href: string; -}) { - return ( -
-
-
-
{title}
-
{description}
-
- -
-
- ); -} - -export function ObservabilityPage() { - const { - data: alertsSummary, - isLoading: alertsLoading, - error: alertsError, - refetch: refetchAlerts, - } = useAlertmanagerAlerts(); - const { - data: alertmanagerStatus, - isLoading: statusLoading, - error: statusError, - refetch: refetchStatus, - } = useAlertmanagerStatus(); - const { - data: grafanaStatus, - isLoading: grafanaLoading, - error: grafanaError, - refetch: refetchGrafana, - } = useGrafanaStatus(); - const { - data: prometheusStatus, - isLoading: prometheusLoading, - error: prometheusError, - refetch: refetchPrometheus, - } = usePrometheusStatus(); - const { - data: prometheusTargets, - isLoading: targetsLoading, - error: targetsError, - refetch: refetchTargets, - } = usePrometheusTargets(); - const { - data: machines = [], - isLoading: machinesLoading, - error: machinesError, - refetch: refetchMachines, - } = useMonitoringMachines(); - const { data: grafanaServices = [] } = useServiceInstances("grafana"); - const [selectedMachineId, setSelectedMachineId] = useState(""); - - const grafanaService = - grafanaServices.find((s) => s.enabled) ?? grafanaServices[0]; - const GRAFANA_BASE_URL = - (grafanaService?.config?.base_url as string | undefined) ?? ""; - - const selectedMachine = useMemo( - () => - machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null, - [machines, selectedMachineId], - ); - - const nodeExporterDashboardUrl = useMemo(() => { - if (!selectedMachine || !GRAFANA_BASE_URL) return ""; - const instance = `${selectedMachine.host || "localhost"}:9100`; - return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`; - }, [selectedMachine, GRAFANA_BASE_URL]); - - const logsUrl = useMemo(() => { - if (!selectedMachine || !GRAFANA_BASE_URL) return ""; - const container = - selectedMachine.mode === "local" ? "backend" : selectedMachine.name; - return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent( - JSON.stringify({ - datasource: "Loki", - queries: [{ refId: "A", expr: `{container="${container}"}` }], - range: { from: "now-1h", to: "now" }, - }), - )}`; - }, [selectedMachine, GRAFANA_BASE_URL]); - - const alertmanagerStatusDetail = alertmanagerStatus?.up - ? alertmanagerStatus.version - ? `version ${alertmanagerStatus.version}` - : "reachable" - : "unreachable"; - - const targetsCount = prometheusTargets?.length ?? 0; - const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading - ? "unknown" - : targetsError - ? "error" - : targetsCount > 0 - ? "ok" - : "warning"; - - const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading - ? "unknown" - : alertsError - ? "error" - : (alertsSummary?.total ?? 0) > 0 - ? alertsSummary?.alerts.some((a) => a.severity === "critical") - ? "error" - : "warning" - : "ok"; - - const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading - ? "unknown" - : machinesError - ? "error" - : machines.length > 0 - ? "ok" - : "warning"; - - return ( -
-
-

Observability

-

- Unified view of metrics, logs, and alerts from Prometheus, Loki, and - Alertmanager. Deep dashboards live in Grafana. -

-
- -
- - - - - - -
- -
- {statusError && ( - - )} - {alertsError && ( - - )} - {targetsError && ( - - )} - {machinesError && ( - - )} - {grafanaError && ( - - )} - {prometheusError && ( - - )} -
- - {alertsSummary?.error && ( - - Alertmanager unreachable - - The UI cannot reach Alertmanager right now. Alerts shown here may be - stale. - - - )} - -
-
- - - - - Recent Alerts - - - - {alertsLoading ? ( -
- - - -
- ) : !alertsSummary || alertsSummary.total === 0 ? ( - - ) : ( - <> - {alertsSummary.alerts.map((alert, idx) => ( - - ))} - {alertsSummary.total > alertsSummary.alerts.length && ( -
- {alertsSummary.total - alertsSummary.alerts.length} more - alert - {alertsSummary.total - alertsSummary.alerts.length === 1 - ? "" - : "s"}{" "} - in Alertmanager -
- )} - - )} -
-
- - - - - - Prometheus Targets - - - - {targetsLoading ? ( -
- - -
- ) : !prometheusTargets || prometheusTargets.length === 0 ? ( - - Open Settings - - } - /> - ) : ( - - )} -
-
-
- - - - - - Machine Dashboard - - - - - {selectedMachine ? ( - GRAFANA_BASE_URL ? ( - <> - - - - ) : ( - - Open Services - - } - /> - ) - ) : ( - - Open Settings - - } - /> - )} - - -
-
- ); -} diff --git a/frontend/src/components/PinnedServiceLink.tsx b/frontend/src/components/PinnedServiceLink.tsx new file mode 100644 index 0000000..3543b5f --- /dev/null +++ b/frontend/src/components/PinnedServiceLink.tsx @@ -0,0 +1,57 @@ +import { useNavigate } from "react-router-dom"; +import { Boxes, ChevronRight, type LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +/** + * Pinned service link rendered on named dashboards. A card-shaped shortcut + * that navigates to a service page (or a specific tab via query param). + * + * The `target` is a route path like `/services/jellyfin/svc-1` or + * `/services/ssh_tasks/svc-2?tab=Files`. + */ +export interface PinnedServiceLinkProps { + label: string; + target: string; + icon?: LucideIcon; + className?: string; +} + +export function PinnedServiceLink({ + label, + target, + icon: Icon = Boxes, + className, +}: PinnedServiceLinkProps) { + const navigate = useNavigate(); + return ( + + ); +} + +/** + * Static helper: build a target path for a pinned service link. + * Returns `/services/:type/:id` or with a `?tab=` suffix when provided. + */ +// eslint-disable-next-line react-refresh/only-export-components +export function serviceLinkTarget( + serviceType: string, + serviceId: string, + tab?: string, +): string { + const base = `/services/${serviceType}/${serviceId}`; + return tab ? `${base}?tab=${tab}` : base; +} diff --git a/frontend/src/components/__tests__/PinnedServiceLink.test.tsx b/frontend/src/components/__tests__/PinnedServiceLink.test.tsx new file mode 100644 index 0000000..9df1f3b --- /dev/null +++ b/frontend/src/components/__tests__/PinnedServiceLink.test.tsx @@ -0,0 +1,41 @@ +import { describe, it, expect } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Routes, Route } from "react-router-dom"; +import userEvent from "@testing-library/user-event"; +import { PinnedServiceLink } from "../PinnedServiceLink"; + +function renderLink() { + return render( + + + + } + /> + target page} + /> + + , + ); +} + +describe("PinnedServiceLink", () => { + it("renders the label", () => { + renderLink(); + expect(screen.getByText("My Jellyfin")).toBeInTheDocument(); + }); + + it("navigates to the target on click", async () => { + const user = userEvent.setup(); + renderLink(); + await user.click(screen.getByText("My Jellyfin")); + expect(screen.getByText("target page")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/hooks/useAuthentik.ts b/frontend/src/hooks/useAuthentik.ts new file mode 100644 index 0000000..d783f7d --- /dev/null +++ b/frontend/src/hooks/useAuthentik.ts @@ -0,0 +1,43 @@ +/** Hooks for the Authentik directory + messaging tabs. */ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + fetchAuthentikMessageStatus, + fetchAuthentikUsers, + sendAuthentikMessage, +} from "../api/authentik"; + +export function useAuthentikUsers( + serviceId: string, + params: { search?: string; page?: number; page_size?: number }, +) { + return useQuery({ + queryKey: ["authentik", "users", serviceId, params], + queryFn: () => fetchAuthentikUsers(serviceId, params), + staleTime: 10_000, + }); +} + +export function useSendAuthentikMessage(serviceId: string) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: { + recipient_emails: string[]; + subject: string; + html_body: string; + }) => sendAuthentikMessage(serviceId, input), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: ["authentik", "message-status", serviceId], + }); + }, + }); +} + +export function useAuthentikMessageStatus(serviceId: string) { + return useQuery({ + queryKey: ["authentik", "message-status", serviceId], + queryFn: () => fetchAuthentikMessageStatus(serviceId), + refetchInterval: 5_000, + staleTime: 0, + }); +} diff --git a/frontend/src/hooks/useDashboards.ts b/frontend/src/hooks/useDashboards.ts new file mode 100644 index 0000000..b54ce12 --- /dev/null +++ b/frontend/src/hooks/useDashboards.ts @@ -0,0 +1,47 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { + createDashboard, + deleteDashboard, + fetchDashboardBySlug, + fetchDashboards, + updateDashboard, + type NamedDashboardInput, +} from "../api/dashboards"; + +export function useDashboards() { + return useQuery({ + queryKey: ["dashboards"], + queryFn: fetchDashboards, + staleTime: 30 * 1000, + }); +} + +export function useDashboardBySlug(slug: string | undefined) { + return useQuery({ + queryKey: ["dashboards", "slug", slug], + queryFn: () => fetchDashboardBySlug(slug!), + enabled: !!slug, + staleTime: 30 * 1000, + }); +} + +export function useSaveDashboard() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (input: NamedDashboardInput) => + input.id ? updateDashboard(input) : createDashboard(input), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboards"] }); + }, + }); +} + +export function useDeleteDashboard() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => deleteDashboard(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["dashboards"] }); + }, + }); +} diff --git a/frontend/src/hooks/useUsers.ts b/frontend/src/hooks/useUsers.ts deleted file mode 100644 index 4d31114..0000000 --- a/frontend/src/hooks/useUsers.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { fetchUsers } from "../api/client"; -import type { UserDirectoryResponse } from "../types"; - -export function useUsers(jellyfinServiceId?: string) { - return useQuery({ - queryKey: ["users", jellyfinServiceId ?? "default"], - queryFn: () => fetchUsers(jellyfinServiceId), - staleTime: 30_000, - }); -} diff --git a/frontend/src/integrations/__tests__/navEntries.test.ts b/frontend/src/integrations/__tests__/navEntries.test.ts new file mode 100644 index 0000000..1991958 --- /dev/null +++ b/frontend/src/integrations/__tests__/navEntries.test.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest"; +import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries"; + +describe("navEntries", () => { + it("returns no entries when no types are configured", () => { + expect(configuredNavEntries(new Set())).toEqual([]); + }); + + it("returns Media when jellyfin is configured", () => { + const entries = configuredNavEntries(new Set(["jellyfin"])); + expect(entries).toHaveLength(1); + expect(entries[0].label).toBe("Media"); + expect(entries[0].path).toBe("/services/jellyfin"); + }); + + it("returns Files + Actions when ssh_tasks is configured", () => { + const entries = configuredNavEntries(new Set(["ssh_tasks"])); + expect(entries).toHaveLength(2); + expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]); + }); + + it("returns all observability entries", () => { + const entries = configuredNavEntries( + new Set(["alertmanager", "grafana", "prometheus"]), + ); + expect(entries.map((e) => e.label)).toEqual([ + "Alerts", + "Grafana", + "Prometheus", + ]); + }); + + it("returns Backups + Users when configured", () => { + const entries = configuredNavEntries(new Set(["backups", "authentik"])); + expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]); + }); + + it("nextcloud has no nav entries in the static map", () => { + expect( + SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"), + ).toEqual([]); + }); + + it("preserves declaration order across mixed types", () => { + const entries = configuredNavEntries( + new Set(["authentik", "ssh_tasks", "jellyfin"]), + ); + expect(entries.map((e) => e.label)).toEqual([ + "Media", + "Files", + "Actions", + "Users", + ]); + }); +}); diff --git a/frontend/src/integrations/navEntries.ts b/frontend/src/integrations/navEntries.ts new file mode 100644 index 0000000..b914c3c --- /dev/null +++ b/frontend/src/integrations/navEntries.ts @@ -0,0 +1,91 @@ +/** + * Service-type → conditional nav-entry map. + * + * Each configured service type contributes one or more top-level nav entries + * that appear only when at least one enabled instance of that type exists. + * See OpenSpec change `services-as-hub-ia`, spec R1.2. + */ +import { + Activity, + DatabaseBackup, + FolderOpen, + GanttChartSquare, + Link2, + Monitor, + Users, + Zap, + type LucideIcon, +} from "lucide-react"; + +export interface NavEntry { + serviceType: string; + label: string; + icon: LucideIcon; + /** Route path for this entry. */ + path: string; +} + +/** + * Static mapping from service type to its conditional nav entries. + * `nextcloud` has no entries (no operational content). + */ +export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [ + { + serviceType: "jellyfin", + label: "Media", + icon: Monitor, + path: "/services/jellyfin", + }, + { + serviceType: "ssh_tasks", + label: "Files", + icon: FolderOpen, + path: "/services/ssh_tasks", + }, + { + serviceType: "ssh_tasks", + label: "Actions", + icon: Zap, + path: "/services/ssh_tasks", + }, + { + serviceType: "alertmanager", + label: "Alerts", + icon: Activity, + path: "/services/alertmanager", + }, + { + serviceType: "grafana", + label: "Grafana", + icon: Link2, + path: "/services/grafana", + }, + { + serviceType: "prometheus", + label: "Prometheus", + icon: GanttChartSquare, + path: "/services/prometheus", + }, + { + serviceType: "backups", + label: "Backups", + icon: DatabaseBackup, + path: "/services/backups", + }, + { + serviceType: "authentik", + label: "Users", + icon: Users, + path: "/services/authentik", + }, +]; + +/** + * Filter the static entries to those whose service type is configured (present + * in the `configuredTypes` set). Returns a flat list in declaration order. + */ +export function configuredNavEntries(configuredTypes: Set): NavEntry[] { + return SERVICE_TYPE_NAV_ENTRIES.filter((e) => + configuredTypes.has(e.serviceType), + ); +} diff --git a/frontend/src/pages/Applications.tsx b/frontend/src/pages/Applications.tsx deleted file mode 100644 index e002554..0000000 --- a/frontend/src/pages/Applications.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import { useState } from "react"; -import { useSearchParams } from "react-router-dom"; -import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; -import { TabsTrigger } from "@/components/ui/tabs"; -import { Media } from "./Media"; -import { useCounts, useLibraries } from "../hooks/useDashboard"; -import { useServiceInstances } from "../hooks/useServices"; -import { SectionCard } from "../components/SectionCard"; -import { TabbedCard } from "../components/TabbedCard"; - -function JellyfinLibraryStats() { - const [searchParams] = useSearchParams(); - const { data: jellyfinServices = [] } = useServiceInstances("jellyfin"); - const selectedServiceId = - searchParams.get("jellyfin_service_id") || - jellyfinServices.find((s) => s.enabled)?.id || - ""; - const { data: counts } = useCounts(selectedServiceId || undefined); - const { data: libraries } = useLibraries(selectedServiceId || undefined); - - return ( - - {selectedServiceId ? "Selected service" : "Default service"} - - } - > -
- {counts ? ( -
-
- Total -
- {( - counts.movies + - counts.series + - counts.episodes - ).toLocaleString()} -
-
-
- Movies -
- {counts.movies.toLocaleString()} -
-
-
- Series -
- {counts.series.toLocaleString()} -
-
-
- Episodes -
- {counts.episodes.toLocaleString()} -
-
-
- ) : null} - - {libraries?.length ? ( -
- {libraries.map((library) => ( -
-
- - {library.library} - - - Total {library.total.toLocaleString()} · Movies{" "} - {library.movies.toLocaleString()} · Series{" "} - {library.series.toLocaleString()} - -
-
- ))} -
- ) : null} -
-
- ); -} - -export function Applications() { - const [tab, setTab] = useState("jellyfin"); - - return ( -
-
-

Applications

-

- Browse application-specific tools from a compact tabbed workspace. -

-
- - - Jellyfin - , - - Nextcloud - , - ]} - > - {tab === "jellyfin" ? ( -
- - -
- ) : ( -
- - - Nextcloud support will be added in a future update. - - -
- )} -
-
- ); -} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index dc2065c..3e10243 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -46,7 +46,7 @@ import { DialogFooter } from "../components/DialogFooter"; import { WidgetInstanceCard } from "../components/WidgetInstance"; import { WidgetConfigDialog } from "../components/WidgetConfigDialog"; -// --- Mobile section grouping (spec R7.2) --- +// --- Mobile section grouping (mobile-parity) --- const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const; type SectionId = (typeof SECTION_ORDER)[number]; @@ -102,7 +102,6 @@ function MobileWidgetSections({ }) { return ( <> - {/* Anchor bar — horizontally scrollable pills (spec R7.2, md:hidden) */}
{sections.map((section) => { const meta = SECTION_META[section.id]; @@ -127,7 +126,6 @@ function MobileWidgetSections({ ); })}
- {/* Sectioned widgets — single column (spec R7.1) */}
{sections.map((section) => (
onChange({ ...draft, enabled: checked }) @@ -425,24 +423,13 @@ function ShortcutCard({ size="sm" disabled={!shortcut.enabled || !href} onClick={onOpen} - className="mobile-touch-target" > Open - -
@@ -508,23 +495,36 @@ export function Dashboard() { return (
+ {services.length === 0 ? ( + +
+

+ No services configured yet. Add a Jellyfin, SSH target, Authentik, + or observability service to populate the navigation and + dashboards. +

+ +
+
+ ) : null} - -
diff --git a/frontend/src/pages/FileBrowser.tsx b/frontend/src/pages/FileBrowser.tsx deleted file mode 100644 index 6842e29..0000000 --- a/frontend/src/pages/FileBrowser.tsx +++ /dev/null @@ -1 +0,0 @@ -export { FileBrowser } from "./FileBrowser.impl"; diff --git a/frontend/src/pages/NamedDashboardPage.tsx b/frontend/src/pages/NamedDashboardPage.tsx new file mode 100644 index 0000000..9c24ba9 --- /dev/null +++ b/frontend/src/pages/NamedDashboardPage.tsx @@ -0,0 +1,91 @@ +import { useMemo } from "react"; +import { useParams } from "react-router-dom"; +import { Boxes } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useDashboardBySlug } from "../hooks/useDashboards"; +import { PinnedServiceLink } from "../components/PinnedServiceLink"; + +/** + * Payload model for named dashboards (design choice: inline items, not widget + * instance ids). The payload stores an ordered list of items: + * + * ``` + * { items: DashboardItem[] } + * ``` + * + * Where `DashboardItem` is either a pinned service link (this slice) or a + * future widget reference (follow-up). Widget composition on named dashboards + * is deferred — the main Dashboard already has the rich widget config dialog. + */ +interface LinkItem { + type: "link"; + label: string; + target: string; +} + +type DashboardItem = LinkItem; + +function parseItems(payload: Record): DashboardItem[] { + const items = payload.items; + if (!Array.isArray(items)) return []; + return items.filter( + (item): item is LinkItem => + typeof item === "object" && + item !== null && + item.type === "link" && + typeof item.label === "string" && + typeof item.target === "string", + ); +} + +export function NamedDashboardPage() { + const { slug = "" } = useParams<{ slug: string }>(); + const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug); + + const items = useMemo( + () => parseItems(dashboard?.payload ?? {}), + [dashboard?.payload], + ); + + if (isLoading) { + return ; + } + + if (isError || !dashboard) { + return ( + + + Dashboard not found. It may have been deleted or the link is invalid. + + + ); + } + + return ( +
+
+

{dashboard.label}

+
+ {items.length === 0 ? ( + + + This dashboard has no shortcuts yet. Add pinned service links from + the dashboard management panel on the Services page. + + + ) : ( +
+ {items.map((item, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/ServicePage.tsx b/frontend/src/pages/ServicePage.tsx index b8261c7..038ad24 100644 --- a/frontend/src/pages/ServicePage.tsx +++ b/frontend/src/pages/ServicePage.tsx @@ -1,11 +1,19 @@ import { useMemo, useState } from "react"; -import { useNavigate, useParams } from "react-router-dom"; +import { useParams, useNavigate } from "react-router-dom"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useDeleteServiceInstance, useSaveServiceInstance, @@ -13,6 +21,7 @@ import { useServiceTypes, } from "../hooks/useServices"; import { useIsMobile } from "../hooks/useIsMobile"; +import { SheetForm } from "@/components/ui/sheet-form"; import type { ServiceInstance, ServiceInstanceInput, @@ -20,8 +29,12 @@ import type { } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; -import { SheetForm } from "@/components/ui/sheet-form"; import { getServiceBinding } from "../integrations/registry"; +import { + OVERVIEW_TAB, + serviceContentTabs, + type ContentTab, +} from "./service-tabs"; function Field({ label, @@ -52,6 +65,7 @@ export function ServicePage() { }>(); const { data: services = [] } = useServiceInstances(serviceType || undefined); const { data: types = [] } = useServiceTypes(); + const navigate = useNavigate(); const saveService = useSaveServiceInstance(); const deleteService = useDeleteServiceInstance(); @@ -64,24 +78,35 @@ export function ServicePage() { () => types.find((t) => t.service_type === serviceType), [types, serviceType], ); + const contentTabs = useMemo( + () => serviceContentTabs(serviceType), + [serviceType], + ); + const siblings = useMemo( + () => services.filter((s) => s.service_type === serviceType), + [services, serviceType], + ); + // R3.1: switcher trigger keys off ENABLED siblings (not total). + const enabledSiblings = useMemo( + () => siblings.filter((s) => s.enabled), + [siblings], + ); + const showSwitcher = enabledSiblings.length > 1; - const navigate = useNavigate(); const [name, setName] = useState(""); const [enabled, setEnabled] = useState(true); const [draftConfig, setDraftConfig] = useState>({}); + const [draftSecrets, setDraftSecrets] = useState>({}); const [deleteOpen, setDeleteOpen] = useState(false); const [hydrated, setHydrated] = useState(false); const isMobile = useIsMobile(); - // The mobile SheetForm opens by default when the page loads: this page is - // reached via /services/:serviceType/:serviceId, always editing an existing - // instance, so there is no separate "open edit" trigger on mobile. const [sheetOpen, setSheetOpen] = useState(true); - // Hydrate local form state once the instance loads. if (instance && !hydrated) { setName(instance.name); setEnabled(instance.enabled); setDraftConfig({ ...instance.config }); + setDraftSecrets({}); setHydrated(true); } @@ -102,84 +127,77 @@ export function ServicePage() { } function buildInput(): ServiceInstanceInput { + // R2.3/R10.1: collect typed secret drafts. Empty values mean "keep the + // existing value" so they are filtered out before sending. + const onlyChangedSecrets = Object.fromEntries( + Object.entries(draftSecrets).filter(([, v]) => v !== ""), + ); return { id: instance!.id, service_type: instance!.service_type, name, config: draftConfig, - secrets: {}, // secrets are managed via the dedicated inputs below + secrets: onlyChangedSecrets, enabled, }; } async function save() { await saveService.mutateAsync(buildInput()); - // R4.5: close the sheet on successful save and return to the services list - // (on mobile the sheet IS the page, so closing it would strand the user). - if (isMobile) { - setSheetOpen(false); - navigate("/services"); - } + // Clear secret drafts after a successful save so the inputs reset to + // "leave blank to keep" state. + setDraftSecrets({}); } - const configFields = ( - 0 ? ( +
+ {binding.widgets.map((w) => ( +
+
+
{w.name}
+
+ {w.description} +
+
+ {w.kind} +
+ ))} +

+ Add these to the dashboard from the dashboard's edit dialog. +

+
+ ) : ( +

+ No widget kinds for this service type. +

+ ); + + const configBody = ( + setDeleteOpen(true)} /> ); - const widgetsCard = - binding.widgets.length > 0 ? ( - -
- {binding.widgets.map((w) => ( -
-
-
{w.name}
-
- {w.description} -
-
- {w.kind} -
- ))} -

- Add these to the dashboard from the dashboard's edit dialog. -

-
-
- ) : null; - - const confirmDelete = ( - setDeleteOpen(false)} - onConfirm={() => { - deleteService.mutate(instance.id); - setDeleteOpen(false); - }} - /> - ); - - // Dirty when any editable field diverges from the persisted instance (mobile SheetForm R4.5 guard). - const isDirty = - name !== instance.name || - enabled !== instance.enabled || - JSON.stringify(draftConfig) !== JSON.stringify(instance.config); - + // Mobile: render inside a SheetForm (open on mount; cancel navigates back). if (isMobile) { return (
@@ -193,113 +211,153 @@ export function ServicePage() { navigate("/services"); }} isPending={saveService.isPending} - isDirty={isDirty} + isDirty={ + name !== instance.name || + enabled !== instance.enabled || + JSON.stringify(draftConfig) !== JSON.stringify(instance.config) + } >
- - setName(e.target.value)} - /> - -
- - -
- {configFields} - - {widgetsCard} + {allTabs.map((tab) => { + const TabComponent = tab.Component; + return ( +
+

+ {tab.label} +

+ +
+ ); + })} + {widgetsContent} + {configBody}
- {confirmDelete} + setDeleteOpen(false)} + onConfirm={() => { + deleteService.mutate(instance.id); + setDeleteOpen(false); + navigate("/services"); + }} + />
); } return (
-
-
+ {/* Header + instance switcher */} +
+

{instance.name}

{binding.description}

- {binding.name} +
+ {showSwitcher ? ( + + ) : null} + {binding.name} +
- -
- - setName(e.target.value)} - /> - -
- - -
-
- - -
-
-
+ {/* Tab skeleton */} + + + Overview + {contentTabs.map((tab) => ( + + {tab.label} + + ))} + Widgets + Config + - {configFields} + {allTabs.map((tab) => { + const TabComponent = tab.Component; + return ( + + + + ); + })} - {widgetsCard} + + + {widgetsContent} + + - {confirmDelete} + {configBody} + + + setDeleteOpen(false)} + onConfirm={() => { + deleteService.mutate(instance.id); + setDeleteOpen(false); + navigate("/services"); + }} + />
); } -function ServiceConnectionFields({ +function ConfigBody({ instance, typeInfo, draftConfig, onConfigChange, - isMobile, + draftSecrets, + onSecretsChange, + name, + enabled, + onNameChange, + onEnabledChange, + onSave, + savePending, + onDelete, }: { instance: ServiceInstance; typeInfo: ServiceTypeInfo | undefined; draftConfig: Record; onConfigChange: (config: Record) => void; - isMobile: boolean; + draftSecrets: Record; + onSecretsChange: (secrets: Record) => void; + name: string; + enabled: boolean; + onNameChange: (name: string) => void; + onEnabledChange: (enabled: boolean) => void; + onSave: () => void; + savePending: boolean; + onDelete: () => void; }) { - const saveService = useSaveServiceInstance(); - // Empty-on-edit: local state starts blank; a blank field means "keep existing". - const [draftSecrets, setDraftSecrets] = useState>({}); - const properties = ( (typeInfo?.config_schema ?? {}) as { @@ -322,107 +380,97 @@ function ServiceConnectionFields({ { type: typeof value === "number" ? "integer" : "string" }, ]); - function handleUpdateConnection() { - const onlyChanged = Object.fromEntries( - Object.entries(draftSecrets).filter(([, v]) => v !== ""), - ); - saveService.mutate({ - id: instance.id, - service_type: instance.service_type, - name: instance.name, - config: draftConfig, - secrets: onlyChanged, - enabled: instance.enabled, - }); - setDraftSecrets({}); - } - - const fields = ( -
- {configEntries.length === 0 ? ( -

No connection config.

- ) : ( -
- {configEntries.map(([key, schema]) => { - const isNumber = - schema.type === "integer" || schema.type === "number"; - return ( - - - onConfigChange({ - ...draftConfig, - [key]: isNumber - ? e.target.value === "" - ? undefined - : Number(e.target.value) - : e.target.value, - }) - } - /> - - ); - })} -
- )} - - {Object.keys(instance.secrets_set).length === 0 ? ( -

No secret fields.

- ) : ( -
- {Object.entries(instance.secrets_set).map(([key, isSet]) => ( -
- - - setDraftSecrets({ - ...draftSecrets, - [key]: e.target.value, - }) - } - /> - - {isSet ? set : null} -
- ))} -
- )} - - -
- ); - - // On mobile the fields render inside the SheetForm body without a card - // wrapper (the SheetForm already provides the container). On desktop they - // keep their original SectionCard framing. - if (isMobile) { - return
{fields}
; - } - return ( - - {fields} + +
+ + onNameChange(e.target.value)} + /> + +
+ + +
+ + {configEntries.length === 0 ? ( +

No connection config.

+ ) : ( +
+ {configEntries.map(([key, schema]) => { + const isNumber = + schema.type === "integer" || schema.type === "number"; + return ( + + + onConfigChange({ + ...draftConfig, + [key]: isNumber + ? e.target.value === "" + ? undefined + : Number(e.target.value) + : e.target.value, + }) + } + /> + + ); + })} +
+ )} + + {Object.keys(instance.secrets_set).length === 0 ? null : ( +
+ {Object.entries(instance.secrets_set).map(([key, isSet]) => ( +
+ + + onSecretsChange({ + ...draftSecrets, + [key]: e.target.value, + }) + } + /> + + {isSet ? set : null} +
+ ))} +
+ )} + +
+ + +
+
); } diff --git a/frontend/src/pages/ServiceTypePage.tsx b/frontend/src/pages/ServiceTypePage.tsx new file mode 100644 index 0000000..269f7f8 --- /dev/null +++ b/frontend/src/pages/ServiceTypePage.tsx @@ -0,0 +1,46 @@ +/** + * Handles `/services/:type` (no instance id). Resolves the first enabled + * instance and redirects. Shows an empty state if none are configured. + */ +import { useMemo } from "react"; +import { Link, useParams, Navigate } from "react-router-dom"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { useServiceInstances } from "../hooks/useServices"; + +export function ServiceTypePage() { + const { serviceType = "" } = useParams<{ serviceType: string }>(); + const { data: instances = [], isLoading } = useServiceInstances( + serviceType || undefined, + ); + + const firstEnabled = useMemo( + () => instances.find((s) => s.enabled) ?? instances[0], + [instances], + ); + + if (isLoading) { + return ( +
+
+
+ ); + } + + if (firstEnabled) { + return ( + + ); + } + + return ( + + + No {serviceType} service configured. + + + + ); +} diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx index 462333f..794622d 100644 --- a/frontend/src/pages/ServicesPage.tsx +++ b/frontend/src/pages/ServicesPage.tsx @@ -12,13 +12,31 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; -import { ExternalLink, Plus, Trash2 } from "lucide-react"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + ChevronDown, + ChevronUp, + ExternalLink, + Plus, + Trash2, +} from "lucide-react"; import { useDeleteServiceInstance, useSaveServiceInstance, useServiceInstances, } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices"; +import { + useDashboards, + useDeleteDashboard, + useSaveDashboard, +} from "../hooks/useDashboards"; import type { SecretFieldInfo, ServiceInstance, @@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import { getServiceBinding } from "../integrations/registry"; +import { serviceLinkTarget } from "../components/PinnedServiceLink"; +import type { NamedDashboardInput } from "../api/dashboards"; interface CreateDraft { serviceType: string; @@ -195,7 +215,7 @@ function CreateServiceDialog({ {!draft ? (
{types.map((t) => ( - + } + > + {dashboards.length === 0 ? ( +

+ No named dashboards yet. Create one to add pinned service links. +

+ ) : ( +
+ {[...dashboards] + .sort((a, b) => a.sort_order - b.sort_order) + .map((d, idx, arr) => ( +
+
+
+ {d.label} + /{d.slug} +
+
+ + + +
+
+
+ {Array.isArray(d.payload.items) && + (d.payload.items as unknown[]).length > 0 ? ( + + {(d.payload.items as unknown[]).length} pinned link(s) + + ) : ( + + No links yet + + )} +
+
+ + { + setLinkDashId(d.id); + setLinkLabel(e.target.value); + }} + /> + +
+ + +
+ +
+
+ ))} +
+ )} + + + + + New dashboard + + + setNewLabel(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") createDashboard(); + }} + /> + + setCreateOpen(false)} + onConfirm={createDashboard} + confirmLabel="Create" + confirmDisabled={!newLabel.trim() || saveDashboard.isPending} + /> + + + + setDeleteId(null)} + onConfirm={() => { + if (deleteId) deleteDashboard.mutate(deleteId); + setDeleteId(null); + }} + /> + + ); +} + export function ServicesPage() { const navigate = useNavigate(); const { data: services = [] } = useServiceInstances(); @@ -287,7 +539,7 @@ export function ServicesPage() { title="Services" description="External services the app talks to. Configure URLs and API keys here; they are encrypted at rest." action={ - @@ -329,7 +581,6 @@ export function ServicesPage() {