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.
+
+ Back to dashboard
+
+
+ );
+}
+
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}
- refetch()}>
-
- Retry
-
-
-
- );
-}
-
-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 (
-
- );
-}
-
-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
-
-
-
-
-
-
- {machines.map((machine) => (
-
- {machine.name}
-
- ))}
-
-
-
-
- {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 (
+ navigate(target)}
+ className={cn(
+ "mobile-touch-target group flex min-h-16 w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
+ className,
+ )}
+ >
+
+
+ {label}
+
+
+
+ );
+}
+
+/**
+ * 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
-
+
Edit
-
+
Delete
@@ -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.
+
+
navigate("/services")}
+ className="w-fit"
+ >
+ Add a service
+
+
+
+ ) : null}
- setWidgetDialogOpen(true)}
- >
+ setWidgetDialogOpen(true)}>
Edit dashboard
-
+
Add shortcut
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)}
- />
-
-
-
- Enabled
-
- {configFields}
-
setDeleteOpen(true)}
- >
- Delete service
-
- {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 ? (
+ navigate(`/services/${serviceType}/${id}`)}
+ >
+
+
+
+
+ {siblings.map((s) => (
+
+ {s.name}
+
+ ))}
+
+
+ ) : null}
+ {binding.name}
+
-
-
-
- setName(e.target.value)}
- />
-
-
-
- Enabled
-
-
-
- Save
-
- setDeleteOpen(true)}
- >
- Delete
-
-
-
-
+ {/* 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}
-
- ))}
-
- )}
-
-
- Update connection
-
-
- );
-
- // 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)}
+ />
+
+
+
+ Enabled
+
+
+ {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}
+
+ ))}
+
+ )}
+
+
+
+ Save
+
+
+ Delete
+
+
+
);
}
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.
+
+ Add a service
+
+
+
+ );
+}
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) => (
-
setDraft(emptyDraft(t.service_type))}
@@ -234,7 +254,6 @@ function CreateServiceDialog({
setDraft({ ...draft, enabled: checked })
@@ -258,6 +277,239 @@ function CreateServiceDialog({
);
}
+// --- Named dashboards management (Slice 10.3) ---
+
+function DashboardManagementCard() {
+ const { data: dashboards = [] } = useDashboards();
+ const saveDashboard = useSaveDashboard();
+ const deleteDashboard = useDeleteDashboard();
+ const { data: services = [] } = useServiceInstances();
+ const [createOpen, setCreateOpen] = useState(false);
+ const [newLabel, setNewLabel] = useState("");
+ const [deleteId, setDeleteId] = useState(null);
+ const [linkDashId, setLinkDashId] = useState(null);
+ const [linkLabel, setLinkLabel] = useState("");
+ const [linkTarget, setLinkTarget] = useState("");
+
+ const enabledServices = useMemo(
+ () => services.filter((s) => s.enabled),
+ [services],
+ );
+
+ function createDashboard() {
+ if (!newLabel.trim()) return;
+ const input: NamedDashboardInput = {
+ label: newLabel.trim(),
+ sort_order: dashboards.length,
+ payload: { items: [] },
+ };
+ saveDashboard.mutate(input);
+ setNewLabel("");
+ setCreateOpen(false);
+ }
+
+ function reorder(dashId: string, direction: -1 | 1) {
+ const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order);
+ const idx = sorted.findIndex((d) => d.id === dashId);
+ const swapIdx = idx + direction;
+ if (swapIdx < 0 || swapIdx >= sorted.length) return;
+ const a = sorted[idx];
+ const b = sorted[swapIdx];
+ saveDashboard.mutate({
+ ...a,
+ sort_order: b.sort_order,
+ payload: a.payload,
+ });
+ saveDashboard.mutate({
+ ...b,
+ sort_order: a.sort_order,
+ payload: b.payload,
+ });
+ }
+
+ function addPinnedLink() {
+ if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return;
+ const dash = dashboards.find((d) => d.id === linkDashId);
+ if (!dash) return;
+ const items = Array.isArray(dash.payload.items)
+ ? (dash.payload.items as unknown[])
+ : [];
+ items.push({ type: "link", label: linkLabel.trim(), target: linkTarget });
+ saveDashboard.mutate({
+ id: dash.id,
+ label: dash.label,
+ sort_order: dash.sort_order,
+ payload: { items },
+ });
+ setLinkLabel("");
+ setLinkTarget("");
+ }
+
+ return (
+ setCreateOpen(true)}>
+
+ New dashboard
+
+ }
+ >
+ {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}
+
+
+ reorder(d.id, -1)}
+ >
+
+
+ reorder(d.id, 1)}
+ >
+
+
+ setDeleteId(d.id)}
+ >
+
+
+
+
+
+ {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);
+ }}
+ />
+
+
+ Service
+ {
+ setLinkDashId(d.id);
+ setLinkTarget(v);
+ }}
+ >
+
+
+
+
+ {enabledServices.map((s) => (
+
+ {s.name} ({s.service_type})
+
+ ))}
+
+
+
+
+ Add link
+
+
+
+ ))}
+
+ )}
+
+
+
+
+ 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={
- setCreateOpen(true)} className="mobile-touch-target">
+ setCreateOpen(true)}>
Add service
@@ -329,7 +581,6 @@ export function ServicesPage() {
navigate(`/services/${s.service_type}/${s.id}`)
}
@@ -339,7 +590,7 @@ export function ServicesPage() {
setDeleteId(s.id)}
>
@@ -354,6 +605,8 @@ export function ServicesPage() {
)}
+
+
setCreateOpen(false)}
diff --git a/frontend/src/pages/Users.tsx b/frontend/src/pages/Users.tsx
deleted file mode 100644
index 1073303..0000000
--- a/frontend/src/pages/Users.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { UsersPage } from "./UsersPage.impl";
diff --git a/frontend/src/pages/UsersPage.impl.tsx b/frontend/src/pages/UsersPage.impl.tsx
deleted file mode 100644
index 81b9f26..0000000
--- a/frontend/src/pages/UsersPage.impl.tsx
+++ /dev/null
@@ -1,1084 +0,0 @@
-import { useEffect, useMemo, useRef, useState } from "react";
-import { useSearchParams } from "react-router-dom";
-import type { ChangeEvent } from "react";
-// Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is
-// now fully @mui-free (6a migrated the directory surface, drawer, and the
-// compose content's shared leaf components).
-import {
- X,
- Paperclip,
- Bold,
- Italic,
- Link,
- List,
- Mail,
- Send,
- Trash2,
-} from "lucide-react";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
-} from "@/components/ui/dialog";
-import { Input } from "@/components/ui/input";
-import { Textarea } from "@/components/ui/textarea";
-import { Separator } from "@/components/ui/separator";
-import { Label } from "@/components/ui/label";
-// Slice 6a directory surface + drawer primitives.
-import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
-import { Badge } from "@/components/ui/badge";
-import { Button as UiButton } from "@/components/ui/button";
-import { Checkbox } from "@/components/ui/checkbox";
-import { Alert as UIAlert, AlertDescription } from "@/components/ui/alert";
-import { Progress } from "@/components/ui/progress";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
-import { Sheet, SheetContent } from "@/components/ui/sheet";
-import { SheetForm } from "@/components/ui/sheet-form";
-import {
- Table,
- TableBody,
- TableCell,
- TableHead,
- TableHeader,
- TableRow,
-} from "@/components/ui/table";
-import { cn } from "@/lib/utils";
-import { MetricCard } from "../components/MetricCard";
-import { SessionActivityPanel } from "../components/SessionActivityPanel";
-import {
- MobileCardRow,
- type MobileCardField,
-} from "@/components/ui/mobile-card";
-import { useUsers } from "../hooks/useUsers";
-import { useActivity } from "../hooks/useDashboard";
-import { useIsMobile } from "../hooks/useIsMobile";
-import { useSendUserMessage } from "../hooks/useSendUserMessage";
-import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
-import type { UserDirectoryItem } from "../types";
-import { buildUserDrawerModel } from "../users";
-import {
- mergeUsersWithActivity,
- resolveUserSelection,
- type UserStateItem,
-} from "../userState";
-
-// Local breakpoint for the compose dialog (slice 6b uses 900px for fullScreen).
-// The shared `useIsMobile` from hooks/ (768px) drives the directory table branch.
-function useComposeViewport(query = "(max-width: 900px)") {
- const [mobile, setMobile] = useState(() =>
- typeof window !== "undefined" && typeof window.matchMedia === "function"
- ? window.matchMedia(query).matches
- : false,
- );
- useEffect(() => {
- if (
- typeof window === "undefined" ||
- typeof window.matchMedia !== "function"
- ) {
- return;
- }
- const mql = window.matchMedia(query);
- const onChange = (event: MediaQueryListEvent) => setMobile(event.matches);
- mql.addEventListener("change", onChange);
- return () => mql.removeEventListener("change", onChange);
- }, [query]);
- return mobile;
-}
-
-function userLabel(user: UserDirectoryItem) {
- return user.display_name || user.username || user.jellyfin_id;
-}
-
-// Activity → Badge status variant (design §2.3: healthy/active = success chart-2,
-// paused = warning chart-3, neutral = secondary).
-function activityBadgeVariant(
- label: string,
-): "success" | "warning" | "secondary" {
- if (label === "Playing") return "success";
- if (label === "Paused") return "warning";
- return "secondary";
-}
-
-const DEFAULT_HTML_BODY = "Hello,
Best, Manage
";
-
-// Mobile card fields (spec R3.2): display name is primary; username, activity
-// badge, and email give the at-a-glance info for scanning users on a phone.
-// See OpenSpec change `mobile-responsive-parity`, tasks slice 5.1.
-const userCardFields: MobileCardField[] = [
- { key: "name", label: "Name", render: (r) => userLabel(r), primary: true },
- {
- key: "username",
- label: "Username",
- render: (r) =>
- r.username && r.username !== r.display_name ? r.username : r.jellyfin_id,
- },
- {
- key: "activity",
- label: "Activity",
- render: (r) => (
-
- {r.activity_label}
-
- ),
- },
- { key: "email", label: "Email", render: (r) => r.email || "—" },
-];
-
-export function UsersPage() {
- const { data, isError, error } = useUsers();
- const { data: activity } = useActivity();
- const queueStatusQuery = useUserMessageQueueStatus();
- const sendUserMessage = useSendUserMessage();
- const isComposeMobile = useComposeViewport();
- const isMobile = useIsMobile();
- const [search, setSearch] = useState("");
- const [searchParams, setSearchParams] = useSearchParams();
- const [selectedUserIds, setSelectedUserIds] = useState([]);
- const [composeOpen, setComposeOpen] = useState(false);
- const [subject, setSubject] = useState("");
- const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY);
- const [attachments, setAttachments] = useState([]);
- const htmlBodyRef = useRef(null);
-
- const baseRows = data?.items ?? [];
- const rows = useMemo(
- () => mergeUsersWithActivity(baseRows, activity ?? []),
- [baseRows, activity],
- );
- const filteredRows = useMemo(() => {
- const term = search.trim().toLowerCase();
- if (!term) {
- return rows;
- }
- return rows.filter((row) => {
- return [
- row.username,
- row.display_name,
- row.email,
- row.email_source,
- row.avatar_source,
- row.name_source,
- row.access_source,
- row.user_type_label,
- row.role,
- row.permissions_label,
- row.jellyseerr_username,
- row.activity_label,
- row.activity_summary,
- row.activity.primary_session?.title || "",
- String(row.jellyseerr_user_id ?? ""),
- ].some((value) => value.toLowerCase().includes(term));
- });
- }, [rows, search]);
-
- const metrics = useMemo(() => {
- const total = baseRows.length;
- const contactable = rows.filter((row) => row.contactable).length;
- const enriched = rows.filter(
- (row) => row.jellyseerr_user_id !== null,
- ).length;
- const admins = rows.filter((row) => row.role === "admin").length;
- return { total, contactable, enriched, admins };
- }, [baseRows]);
-
- const queueStatus = queueStatusQuery.data;
- const queueBanner = useMemo(() => {
- if (!queueStatus) {
- return null;
- }
- const activeCount = queueStatus.active_request_id ? 1 : 0;
- const totalCount = queueStatus.pending_count + activeCount;
- const countLabel =
- totalCount > 0
- ? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})`
- : "0 items in queue";
- if (!queueStatus.worker_running) {
- return {
- severity: "warning" as const,
- message:
- queueStatus.last_error ||
- "Email queue worker is not running. New messages cannot be delivered until it restarts.",
- countLabel,
- subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
- };
- }
- if (queueStatus.state === "error") {
- return {
- severity: "error" as const,
- message: queueStatus.last_error || "The last email delivery failed.",
- countLabel,
- subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
- };
- }
- if (queueStatus.state === "busy") {
- const active = queueStatus.active_request_id
- ? `processing ${queueStatus.active_request_id.slice(0, 8)}`
- : "processing a message";
- const waiting = queueStatus.pending_count
- ? `${queueStatus.pending_count} waiting`
- : "no backlog";
- return {
- severity: "info" as const,
- message: `Email queue is busy: ${active}, ${waiting}.`,
- countLabel,
- subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
- };
- }
- return {
- severity: "success" as const,
- message: "Email queue is idle and empty.",
- countLabel,
- subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
- };
- }, [queueStatus]);
-
- const selectedIdSet = useMemo(
- () => new Set(selectedUserIds),
- [selectedUserIds],
- );
- const selectedRows = useMemo(
- () => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
- [rows, selectedIdSet],
- );
- const selectedDeliverableRows = useMemo(
- () => selectedRows.filter((row) => row.contactable && row.email),
- [selectedRows],
- );
- const skippedRows = useMemo(
- () => selectedRows.filter((row) => !row.contactable || !row.email),
- [selectedRows],
- );
- const visibleSelectedRows = useMemo(
- () => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
- [filteredRows, selectedIdSet],
- );
- const allVisibleSelected =
- filteredRows.length > 0 &&
- visibleSelectedRows.length === filteredRows.length;
-
- const toggleUserSelected = (userId: string) => {
- setSelectedUserIds((current) =>
- current.includes(userId)
- ? current.filter((id) => id !== userId)
- : [...current, userId],
- );
- };
-
- const toggleVisibleSelection = (checked: boolean) => {
- setSelectedUserIds((current) => {
- const next = new Set(current);
- filteredRows.forEach((row) => {
- if (checked) {
- next.add(row.jellyfin_id);
- } else {
- next.delete(row.jellyfin_id);
- }
- });
- return Array.from(next);
- });
- };
-
- const selectedUserParam = searchParams.get("user") || "";
- const selectedUser = useMemo(
- () =>
- selectedUserParam
- ? (resolveUserSelection(
- rows,
- selectedUserParam,
- ) as UserStateItem | null)
- : null,
- [rows, selectedUserParam],
- );
- const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null;
-
- const openCompose = () => {
- if (!selectedRows.length) {
- return;
- }
- sendUserMessage.reset();
- if (!subject.trim()) {
- setSubject(
- `Manage update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`,
- );
- }
- if (!htmlBody.trim()) {
- setHtmlBody(DEFAULT_HTML_BODY);
- }
- setComposeOpen(true);
- };
-
- const closeCompose = () => {
- setComposeOpen(false);
- sendUserMessage.reset();
- };
-
- const insertMarkup = (before: string, after = before) => {
- const textarea = htmlBodyRef.current;
- if (!textarea) {
- return;
- }
- const start = textarea.selectionStart ?? htmlBody.length;
- const end = textarea.selectionEnd ?? htmlBody.length;
- const selected = htmlBody.slice(start, end) || "text";
- const next =
- htmlBody.slice(0, start) +
- before +
- selected +
- after +
- htmlBody.slice(end);
- setHtmlBody(next);
- requestAnimationFrame(() => {
- textarea.focus();
- const cursorStart = start + before.length;
- const cursorEnd = cursorStart + selected.length;
- textarea.setSelectionRange(cursorStart, cursorEnd);
- });
- };
-
- const addLink = () => {
- const url = window.prompt("Link URL", "https://");
- if (!url) {
- return;
- }
- insertMarkup(``, " ");
- };
-
- const handleAttachments = (event: ChangeEvent) => {
- const files = Array.from(event.target.files || []);
- if (files.length) {
- setAttachments((current) => [...current, ...files]);
- }
- event.target.value = "";
- };
-
- const removeAttachment = (index: number) => {
- setAttachments((current) => current.filter((_, idx) => idx !== index));
- };
-
- const handleSend = async () => {
- const allSelectedRows = selectedRows;
- if (!allSelectedRows.length) {
- return;
- }
-
- const formData = new FormData();
- formData.append(
- "recipient_ids",
- JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)),
- );
- formData.append("subject", subject);
- formData.append("html_body", htmlBody);
- attachments.forEach((file) => {
- formData.append("attachments", file, file.name);
- });
-
- try {
- await sendUserMessage.mutateAsync(formData);
- setComposeOpen(false);
- setAttachments([]);
- setSubject("");
- setHtmlBody(DEFAULT_HTML_BODY);
- } catch {
- // Mutation state is shown inline.
- }
- };
-
- // Sticky table-header base (opaque so rows don't bleed through on scroll).
- const thBase = "font-semibold sticky top-0 z-10 bg-card";
-
- return (
-
-
-
Users
-
- Read-only Jellyfin users with optional Jellyseerr enrichment.
-
-
-
- {isError ? (
-
-
- Unable to load users: {(error as Error)?.message || "Unknown error"}
-
-
- ) : null}
-
- {data && !data.jellyseerr_configured ? (
-
-
- Jellyseerr is not configured in the backend yet. Check
- JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API.
-
-
- ) : null}
-
- {data?.jellyseerr_error ? (
-
-
- Jellyseerr enrichment is unavailable: {data.jellyseerr_error}
-
-
- ) : null}
-
- {data?.jellyseerr_configured &&
- !data.jellyseerr_error &&
- data.enriched_count === 0 ? (
-
-
- Jellyseerr is connected, but no Jellyfin users were matched yet. The
- backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked
- entries and {data.jellyseerr_user_count} Jellyseerr users.
-
-
- ) : null}
-
- {queueStatusQuery.isError ? (
-
-
- Unable to load email queue status:{" "}
- {String(
- (queueStatusQuery.error as Error)?.message || "Unknown error",
- )}
-
-
- ) : queueBanner ? (
-
-
- {queueBanner.message}
- {queueBanner.countLabel}
-
- {queueBanner.subtext}
-
- ) : null}
-
-
-
-
-
-
-
-
-
-
-
-
-
User list
-
- {filteredRows.length} visible of {rows.length} total
-
-
-
- {selectedRows.length} selected
-
- {selectedDeliverableRows.length} deliverable
-
-
-
- Message selected
-
- setSelectedUserIds([])}
- >
- Clear selection
-
- setSearch(event.target.value)}
- className="w-full sm:w-80"
- />
-
-
-
-
- {isMobile ? (
-
- r.jellyfin_id}
- onRowClick={(r) => setSearchParams({ user: r.jellyfin_id })}
- actions={(r) => {
- const checked = selectedIdSet.has(r.jellyfin_id);
- return (
- e.stopPropagation()}
- onCheckedChange={() =>
- toggleUserSelected(r.jellyfin_id)
- }
- />
- );
- }}
- />
-
- ) : (
-
-
-
-
-
- toggleVisibleSelection(checked === true)
- }
- />
-
- User
- Email
-
- Activity
-
-
- Type
-
-
- Jellyseerr
-
-
- Role
-
- Permissions
-
- Reqs
-
-
- Contact
-
-
-
-
- {filteredRows.map((row) => {
- const linked =
- row.jellyseerr_user_id !== null &&
- row.jellyseerr_user_id !== undefined;
- const checked = selectedIdSet.has(row.jellyfin_id);
- return (
-
- setSearchParams({ user: row.jellyfin_id })
- }
- >
-
- event.stopPropagation()}
- onCheckedChange={() =>
- toggleUserSelected(row.jellyfin_id)
- }
- />
-
-
-
-
-
-
- {userLabel(row).charAt(0).toUpperCase()}
-
-
-
-
- {userLabel(row)}
-
-
- {row.username &&
- row.username !== row.display_name
- ? row.username
- : row.jellyfin_id}
-
-
-
-
-
-
- {row.email || "—"}
-
-
-
-
- {row.activity_label}
-
-
-
- {row.user_type_label}
-
-
-
- {linked
- ? `Linked #${row.jellyseerr_user_id}`
- : "Base only"}
-
-
-
- {row.role}
-
-
- {row.permissions_label}
-
-
- {row.request_count ?? "—"}
-
-
-
- {row.contactable ? "Yes" : "No"}
-
-
-
- );
- })}
-
-
- )}
-
-
-
-
-
{
- if (!open) {
- setSearchParams({});
- }
- }}
- >
-
- {selectedUser && drawerModel ? (
-
-
-
-
-
- {drawerModel.title.charAt(0).toUpperCase()}
-
-
-
-
- {drawerModel.title}
-
-
- {drawerModel.subtitle}
-
-
-
- {drawerModel.contactState.label}
-
-
setSearchParams({})}
- >
-
- Close
-
-
-
-
- {selectedUser.user_type_label}
- {selectedUser.role}
- {drawerModel.syncStatus}
-
-
-
-
Identity
-
- {drawerModel.identity.map((field) => (
-
-
- {field.label}
-
- {field.value}
-
- ))}
-
-
-
-
-
Activity
-
-
-
-
-
Contact actions
-
- {drawerModel.contactState.description}
-
-
- {drawerModel.contactActions.map((action) => (
-
- {action.label}
-
- ))}
-
-
- {drawerModel.contactActions
- .map((action) => action.hint)
- .join(" ")}
-
-
-
-
-
Permissions
-
- {drawerModel.permissions.map((permission) => (
-
- {permission}
-
- ))}
-
-
-
-
-
-
- This panel is read-only for now. Communication actions will be
- added later without redesigning the list.
-
-
- ) : null}
-
-
-
- {/* Compose dialog: SheetForm below md, Dialog at md+ (spec R4.1) */}
- {(() => {
- const composeBody = (
- <>
- {sendUserMessage.isPending ? (
-
- ) : null}
-
- {sendUserMessage.isError ? (
-
-
- Unable to send message:{" "}
- {(sendUserMessage.error as Error)?.message ||
- "Unknown error"}
-
-
- ) : null}
- {sendUserMessage.isSuccess ? (
-
-
- Queued for {sendUserMessage.data.recipient_count} recipients
- {sendUserMessage.data.attachment_count
- ? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
- : ""}
- {sendUserMessage.data.request_id
- ? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
- : ""}
- .
-
-
- ) : null}
-
- {queueBanner ? (
-
-
-
- {queueBanner.message}
-
- {queueBanner.countLabel}
-
-
- ) : null}
-
-
-
- {selectedRows.length} selected,{" "}
- {selectedDeliverableRows.length} deliverable.
- {skippedRows.length
- ? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
- : ""}
-
-
-
-
- {selectedDeliverableRows.map((row) => (
-
- {`${userLabel(row)} <${row.email}>`}
-
- ))}
-
-
-
- Subject
- setSubject(event.target.value)}
- />
-
-
-
-
-
- insertMarkup("", " ")}
- aria-label="Bold"
- >
-
-
-
- Bold
-
-
-
- insertMarkup("", " ")}
- aria-label="Italic"
- >
-
-
-
- Italic
-
-
-
-
-
-
-
- Link
-
-
-
- insertMarkup("")}
- aria-label="Bullet list"
- >
-
-
-
- Bullet list
-
-
-
-
-
-
-
-
-
-
-
- Add attachment
-
-
-
- {attachments.map((file, index) => (
-
- {file.name}
- removeAttachment(index)}
- className="mobile-touch-target inline-flex items-center text-current [&>svg]:size-3"
- >
-
-
-
- ))}
-
-
- >
- );
-
- if (isMobile) {
- return (
-
{
- if (!open) closeCompose();
- }}
- title="Message selected users"
- onSave={handleSend}
- onCancel={closeCompose}
- isPending={sendUserMessage.isPending}
- saveDisabled={!selectedDeliverableRows.length || !subject.trim()}
- saveLabel="Send message"
- isDirty={
- subject.trim() !== "" ||
- htmlBody.trim() !== DEFAULT_HTML_BODY.trim() ||
- attachments.length > 0
- }
- >
- {composeBody}
-
- );
- }
-
- return (
-
{
- if (!open) {
- closeCompose();
- }
- }}
- >
-
-
-
- Message selected users
-
-
- Compose a message to the selected deliverable users.
-
-
- {composeBody}
-
-
- Cancel
-
-
-
- Send message
-
-
-
-
- );
- })()}
-
- );
-}
diff --git a/frontend/src/pages/__tests__/Actions.test.tsx b/frontend/src/pages/__tests__/Actions.test.tsx
deleted file mode 100644
index a4bc8e9..0000000
--- a/frontend/src/pages/__tests__/Actions.test.tsx
+++ /dev/null
@@ -1,125 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { Actions } from "../Actions";
-import type { SavedTask, ServiceInstance } from "../../types";
-
-const saveTaskMutate = vi.fn().mockResolvedValue({
- id: "t1",
- name: "Restart svc",
- task_type: "shell",
- content: "",
- enabled: true,
- default_service_id: "",
- notes: "",
-});
-const deleteTaskMutate = vi.fn();
-const runTaskMutate = vi.fn().mockResolvedValue({});
-
-let sshServices: ServiceInstance[] = [];
-let tasks: SavedTask[] = [];
-
-vi.mock("../../hooks/useSettings", () => ({
- useTasks: () => ({ data: tasks }),
- useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
- useDeleteTask: () => ({ mutate: deleteTaskMutate }),
- useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
- useTaskRuns: () => ({ data: { items: [] } }),
-}));
-
-vi.mock("../../hooks/useServices", () => ({
- useServiceInstances: () => ({ data: sshServices }),
-}));
-
-function sshService(overrides: Partial = {}): ServiceInstance {
- return {
- id: "s1",
- service_type: "ssh_tasks",
- name: "Box",
- config: { host: "box", username: "u" },
- secrets_set: {},
- enabled: true,
- created_at: 0,
- updated_at: 0,
- ...overrides,
- } as ServiceInstance;
-}
-
-function task(overrides: Partial = {}): SavedTask {
- return {
- id: "t1",
- name: "Restart svc",
- task_type: "shell",
- content: "systemctl restart foo",
- enabled: true,
- default_service_id: "",
- notes: "",
- created_at: 0,
- updated_at: 0,
- ...overrides,
- } as SavedTask;
-}
-
-beforeEach(() => {
- saveTaskMutate.mockClear();
- deleteTaskMutate.mockClear();
- runTaskMutate.mockClear();
- sshServices = [];
- tasks = [];
-});
-
-describe("Actions", () => {
- it("shows the empty state and creates a task via the editor dialog", async () => {
- render( );
-
- expect(screen.getByText("No action selected")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Add action" }),
- ).toBeInTheDocument();
-
- await userEvent.click(screen.getByRole("button", { name: "Add action" }));
- // Editor dialog opened (Name field is unique to the editor).
- expect(screen.getByLabelText("Name")).toBeInTheDocument();
-
- // Controlled input parity: name + default shell type flow through.
- await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
- await userEvent.click(screen.getByRole("button", { name: "Save action" }));
-
- expect(saveTaskMutate).toHaveBeenCalledTimes(1);
- const saved = saveTaskMutate.mock.calls[0][0];
- expect(saved.name).toBe("Restart svc");
- expect(saved.task_type).toBe("shell");
- expect(saved.default_service_id).toBe("");
- });
-
- it("disables the Run button until a run service is selected", async () => {
- sshServices = [sshService()];
- tasks = [task()];
- render( );
-
- // Selecting a saved task tab exposes the detail + Run control.
- await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
-
- const runButton = screen.getByRole("button", { name: "Run action" });
- expect(runButton).toBeDisabled();
- });
-
- it("runs a task on the selected SSH task service", async () => {
- sshServices = [sshService()];
- tasks = [task()];
- render( );
-
- await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
- await userEvent.click(
- screen.getByRole("combobox", { name: "Run on SSH task service" }),
- );
- await userEvent.click(screen.getByRole("option", { name: "Box" }));
- await userEvent.click(screen.getByRole("button", { name: "Run action" }));
-
- expect(runTaskMutate).toHaveBeenCalledTimes(1);
- expect(runTaskMutate).toHaveBeenCalledWith({
- taskId: "t1",
- serviceId: "s1",
- });
- });
-});
diff --git a/frontend/src/pages/__tests__/Applications.test.tsx b/frontend/src/pages/__tests__/Applications.test.tsx
deleted file mode 100644
index de5936e..0000000
--- a/frontend/src/pages/__tests__/Applications.test.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
-import { Applications } from "../Applications";
-
-// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
-// so this slice-4 test stays focused on the migrated Applications shell and
-// does not pull the still-MUI DataGrid into the jsdom render.
-vi.mock("../Media", () => ({
- Media: () => Media
,
-}));
-
-vi.mock("react-router-dom", () => ({
- useSearchParams: () => [new URLSearchParams(), vi.fn()],
-}));
-
-vi.mock("../../hooks/useSettings", () => ({
- useMonitoringSettings: () => ({
- data: [
- {
- id: "m1",
- name: "Main",
- enabled: true,
- services: ["jellyfin"],
- },
- ],
- }),
-}));
-
-vi.mock("../../hooks/useServices", () => ({
- useServiceInstances: () => ({
- data: [
- { id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
- ],
- }),
-}));
-
-vi.mock("../../hooks/useDashboard", () => ({
- useCounts: () => ({
- data: { movies: 10, series: 5, episodes: 100 },
- }),
- useLibraries: () => ({
- data: [
- { library: "Movies", total: 10, movies: 10, series: 0 },
- { library: "Shows", total: 5, movies: 0, series: 5 },
- ],
- }),
-}));
-
-describe("Applications", () => {
- it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
- render( );
-
- // Library stats header.
- expect(screen.getByText("Library stats")).toBeInTheDocument();
-
- // Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
- expect(screen.getByText("115")).toBeInTheDocument();
- expect(screen.getByText("Episodes")).toBeInTheDocument();
-
- // Library rows render their per-library totals (unique strings).
- expect(
- screen.getByText(/Total 10 · Movies 10 · Series 0/),
- ).toBeInTheDocument();
- expect(
- screen.getByText(/Total 5 · Movies 0 · Series 5/),
- ).toBeInTheDocument();
-
- // Tabs present.
- expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
- expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
-
- // The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
- expect(screen.getByTestId("media-child")).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/pages/__tests__/Dashboard.test.tsx b/frontend/src/pages/__tests__/Dashboard.test.tsx
index f08ad19..3e4d99b 100644
--- a/frontend/src/pages/__tests__/Dashboard.test.tsx
+++ b/frontend/src/pages/__tests__/Dashboard.test.tsx
@@ -2,18 +2,12 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard } from "../Dashboard";
-import type {
- DashboardShortcut,
- ServiceInstance,
- WidgetInstance,
-} from "../../types";
+import type { DashboardShortcut } from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering widgets or their data queries.
vi.mock("../../components/WidgetInstance", () => ({
- WidgetInstanceCard: ({ widget }: { widget: { title: string } }) => (
- {widget.title}
- ),
+ WidgetInstanceCard: () =>
,
}));
vi.mock("../../components/WidgetConfigDialog", () => ({
WidgetConfigDialog: () =>
,
@@ -27,15 +21,11 @@ vi.mock("react-router-dom", () => ({
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
-
-// --- Dynamic mock state (reset in beforeEach) ---
-let widgetInstances: WidgetInstance[] = [];
-let serviceInstances: ServiceInstance[] = [];
vi.mock("../../hooks/useWidgets", () => ({
- useWidgetInstances: () => ({ data: widgetInstances }),
+ useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
- useServiceInstances: () => ({ data: serviceInstances }),
+ useServiceInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
@@ -75,26 +65,8 @@ beforeEach(() => {
saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear();
shortcuts = [];
- widgetInstances = [];
- serviceInstances = [];
- setMatchMedia(false); // desktop by default
});
-// --- matchMedia mock for useIsMobile (jsdom has no native matchMedia) ---
-
-function setMatchMedia(matches: boolean) {
- window.matchMedia = ((query: string) => ({
- matches: query === "(max-width: 768px)" ? matches : false,
- media: query,
- onchange: null,
- addEventListener: () => {},
- removeEventListener: () => {},
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- })) as unknown as typeof window.matchMedia;
-}
-
describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => {
render( );
@@ -142,142 +114,3 @@ describe("Dashboard", () => {
expect(saved.shortcut_type).toBe("website");
});
});
-
-// --- Mobile layout tests (spec R7.1, R7.2) ---
-
-function makeWidget(overrides: Partial = {}): WidgetInstance {
- return {
- id: "w1",
- service_id: null,
- widget_kind: "static",
- title: "Widget 1",
- config: {},
- enabled: true,
- sort_order: 0,
- created_at: 0,
- updated_at: 0,
- ...overrides,
- };
-}
-
-function makeService(
- overrides: Partial = {},
-): ServiceInstance {
- return {
- id: "svc1",
- service_type: "jellyfin",
- name: "Jellyfin",
- config: {},
- secrets_set: {},
- enabled: true,
- created_at: 0,
- updated_at: 0,
- ...overrides,
- };
-}
-
-describe("Dashboard mobile layout", () => {
- it("renders widgets in a single column with an anchor bar below md", () => {
- setMatchMedia(true); // mobile
- serviceInstances = [
- makeService({ id: "graf", service_type: "grafana" }),
- makeService({ id: "jelly", service_type: "jellyfin" }),
- ];
- widgetInstances = [
- makeWidget({
- id: "w-obs",
- service_id: "graf",
- widget_kind: "link",
- title: "Grafana Link",
- }),
- makeWidget({
- id: "w-media",
- service_id: "jelly",
- widget_kind: "activity",
- title: "Jellyfin Activity",
- }),
- makeWidget({
- id: "w-backup",
- service_id: null,
- widget_kind: "backups",
- title: "Backup Summary",
- }),
- ];
-
- render( );
-
- // Anchor bar pills are visible for populated sections (each label appears
- // in both the pill and the section heading, so use getAllByText).
- expect(screen.getAllByText("Observability").length).toBeGreaterThanOrEqual(
- 1,
- );
- expect(screen.getAllByText("Media").length).toBeGreaterThanOrEqual(1);
- expect(screen.getAllByText("Backups").length).toBeGreaterThanOrEqual(1);
-
- // Sections with no widgets are NOT rendered.
- expect(screen.queryByText("Custom")).not.toBeInTheDocument();
-
- // Each widget renders.
- expect(screen.getByText("Grafana Link")).toBeInTheDocument();
- expect(screen.getByText("Jellyfin Activity")).toBeInTheDocument();
- expect(screen.getByText("Backup Summary")).toBeInTheDocument();
- });
-
- it("does NOT render the anchor bar at desktop width", () => {
- setMatchMedia(false); // desktop
- serviceInstances = [makeService({ id: "graf", service_type: "grafana" })];
- widgetInstances = [
- makeWidget({
- id: "w-obs",
- service_id: "graf",
- widget_kind: "link",
- title: "Grafana Link",
- }),
- ];
-
- render( );
-
- // Widget renders (flat list, no section wrappers).
- expect(screen.getByText("Grafana Link")).toBeInTheDocument();
-
- // No section headings or anchor pills on desktop.
- expect(screen.queryByText("Observability")).not.toBeInTheDocument();
- expect(screen.queryByText("Media")).not.toBeInTheDocument();
- });
-
- it("anchor bar pills jump to their section via scrollIntoView", async () => {
- setMatchMedia(true); // mobile
- serviceInstances = [
- makeService({ id: "graf", service_type: "grafana" }),
- makeService({ id: "jelly", service_type: "jellyfin" }),
- ];
- widgetInstances = [
- makeWidget({
- id: "w-obs",
- service_id: "graf",
- widget_kind: "link",
- title: "Grafana Link",
- }),
- makeWidget({
- id: "w-media",
- service_id: "jelly",
- widget_kind: "activity",
- title: "Jellyfin Activity",
- }),
- ];
-
- const scrollSpy = vi.spyOn(Element.prototype, "scrollIntoView");
-
- render( );
-
- // The Media section element exists.
- expect(document.getElementById("dashboard-section-media")).not.toBeNull();
-
- // Click the "Media" anchor pill (button role disambiguates from heading).
- const mediaPill = screen.getByRole("button", { name: "Media" });
- await userEvent.click(mediaPill);
-
- expect(scrollSpy).toHaveBeenCalled();
- scrollSpy.mockRestore();
- });
-});
diff --git a/frontend/src/pages/__tests__/FileBrowser.test.tsx b/frontend/src/pages/__tests__/FileBrowser.test.tsx
deleted file mode 100644
index 890e9de..0000000
--- a/frontend/src/pages/__tests__/FileBrowser.test.tsx
+++ /dev/null
@@ -1,198 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { FileBrowser } from "../FileBrowser.impl";
-import type { DirectoryListing, MonitoringMachine } from "../../types";
-
-// usePersistentState (browserState) reads/writes localStorage; clear between tests
-// so the selectedPath / currentDir state never leaks across cases.
-beforeEach(() => {
- window.localStorage.clear();
- setMatchMedia(false);
-});
-
-function machineFixture(
- overrides: Partial = {},
-): MonitoringMachine {
- return {
- id: "local",
- name: "Local",
- mode: "local",
- enabled: true,
- services: ["files", "monitoring"],
- host: "",
- port: 22,
- username: "",
- key_directory: "",
- key_name: "",
- ssh_key_id: "",
- ssh_private_key_set: false,
- ssh_private_key_passphrase_set: false,
- password_set: false,
- notes: "",
- ...overrides,
- };
-}
-
-function listingFixture(
- entries: {
- name: string;
- type: string;
- size: number;
- mtime: number;
- }[],
-): DirectoryListing {
- return { path: "/", entries, count: entries.length };
-}
-
-let listing: DirectoryListing;
-let machines: MonitoringMachine[];
-
-vi.mock("react-router-dom", () => ({
- useSearchParams: () => [new URLSearchParams(), vi.fn()],
- useNavigate: () => vi.fn(),
-}));
-
-vi.mock("../../hooks/useFiles", () => ({
- useDirectoryListing: () => ({
- data: listing,
- isLoading: false,
- error: null,
- refetch: vi.fn(),
- }),
- useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
- useJobTemplates: () => ({ data: [] }),
- useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
-}));
-
-vi.mock("../../hooks/useSettings", () => ({
- useMonitoringSettings: () => ({ data: machines }),
-}));
-
-beforeEach(() => {
- machines = [machineFixture()];
- listing = listingFixture([
- { name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
- { name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
- { name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
- ]);
-});
-
-/** Stub window.matchMedia so useIsMobile resolves in jsdom (Slice 4). */
-function setMatchMedia(matches: boolean) {
- const listeners: ((e: MediaQueryListEvent) => void)[] = [];
- window.matchMedia = vi.fn().mockImplementation((query: string) => ({
- matches: query.includes("768") ? matches : false,
- media: query,
- onchange: null,
- addEventListener: (
- _evt: string,
- listener: (e: MediaQueryListEvent) => void,
- ) => listeners.push(listener),
- removeEventListener: (
- _evt: string,
- listener: (e: MediaQueryListEvent) => void,
- ) => {
- const idx = listeners.indexOf(listener);
- if (idx >= 0) listeners.splice(idx, 1);
- },
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- }));
-}
-
-describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
- it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
- render( );
-
- const headers = screen
- .getAllByRole("columnheader")
- .map((h) => h.textContent);
- // The leading selection column header is empty (checkbox); the 5 data
- // columns are Type, Name, Ext, Size, Modified in that order.
- expect(headers).toEqual(
- expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
- );
- expect(headers.filter((h) => h === "Type").length).toBe(1);
- expect(headers.filter((h) => h === "Modified").length).toBe(1);
- });
-
- it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
- render( );
-
- // The selected-file path surfaces in the Browser status caption once chosen.
- expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
-
- await userEvent.click(screen.getByText("video.mkv"));
- expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
-
- // A recognized video file enters the ffprobe branch; with empty ffprobe
- // data it shows the "No ffprobe data available." status (proving the
- // selected file routed into the Media info preview flow).
- expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
- });
-
- it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
- render( );
-
- await userEvent.click(screen.getByText("movies"));
- // After navigating into /movies, the status caption shows the new cwd and
- // NO "Selected:" segment (directories are opened, not selected for preview).
- expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
- expect(screen.queryByText(/Selected:/)).toBeNull();
- });
-});
-
-describe("FileBrowser (mobile card layout — slice 4)", () => {
- it("renders cards with file/dir name as primary below md", () => {
- setMatchMedia(true);
- render( );
-
- // Card titles (the 'name' field rendered as primary).
- expect(screen.getByText("movies")).toBeInTheDocument();
- expect(screen.getByText("video.mkv")).toBeInTheDocument();
- expect(screen.getByText("notes.txt")).toBeInTheDocument();
-
- // Desktop table column headers must NOT render.
- const headers = screen.queryAllByRole("columnheader");
- expect(headers).toHaveLength(0);
- });
-
- it("tapping a directory card navigates into it", async () => {
- setMatchMedia(true);
- render( );
-
- // Directory card is a button wrapping the 'movies' text.
- await userEvent.click(screen.getByText("movies"));
-
- // After navigating into /movies, the status caption shows the new cwd
- // and NO 'Selected:' segment (directories are opened, not selected).
- expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
- expect(screen.queryByText(/Selected:/)).toBeNull();
- });
-
- it("renders the path/breadcrumb controls on mobile", () => {
- setMatchMedia(true);
- render( );
-
- // The 'Remote path' label and its input are part of the Browser section
- // card (outside the table), so they render on both breakpoints.
- expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Open" })).toBeInTheDocument();
- expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
- });
-
- it("renders the DataTable at desktop width (1280px)", () => {
- setMatchMedia(false);
- render( );
-
- // Desktop path: table column headers are present.
- const headers = screen
- .getAllByRole("columnheader")
- .map((h) => h.textContent);
- expect(headers).toEqual(
- expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
- );
- });
-});
diff --git a/frontend/src/pages/__tests__/Media.test.tsx b/frontend/src/pages/__tests__/Media.test.tsx
deleted file mode 100644
index 571dafe..0000000
--- a/frontend/src/pages/__tests__/Media.test.tsx
+++ /dev/null
@@ -1,356 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { Media } from "../Media";
-import type {
- MediaIndexStatus,
- MediaItem,
- MediaQueryResponse,
- MonitoringMachine,
-} from "../../types";
-
-// Shared navigate mock so the row-click test can assert the call. The vi.mock
-// factory is hoisted above this const, but it only closes over `navigate`
-// lazily (the arrow runs at render time, well after init) — no TDZ access.
-const navigate = vi.fn();
-
-function machineFixture(
- overrides: Partial = {},
-): MonitoringMachine {
- return {
- id: "local",
- name: "Local",
- mode: "local",
- enabled: true,
- services: ["jellyfin", "monitoring"],
- host: "",
- port: 22,
- username: "",
- key_directory: "",
- key_name: "",
- ssh_key_id: "",
- ssh_private_key_set: false,
- ssh_private_key_passphrase_set: false,
- password_set: false,
- notes: "",
- ...overrides,
- };
-}
-
-function statusFixture(
- overrides: Partial = {},
-): MediaIndexStatus {
- return {
- exists: true,
- item_count: 2,
- updated_at: 1,
- updated_at_label: "now",
- build_duration_seconds: null,
- build_running: false,
- build_stage: "",
- build_message: "",
- build_progress: null,
- build_items_processed: 0,
- build_items_total: 0,
- build_current_library: "",
- build_library_index: 0,
- build_libraries_total: 0,
- build_library_progress: null,
- build_library_items_processed: 0,
- build_library_items_total: 0,
- build_elapsed_seconds: null,
- build_eta_seconds: null,
- build_library_elapsed_seconds: null,
- build_library_eta_seconds: null,
- build_cancel_requested: false,
- build_pid: null,
- build_error: "",
- ...overrides,
- };
-}
-
-function mediaItem(overrides: Partial = {}): MediaItem {
- return {
- id: "1",
- title: "Inception",
- series: "",
- season: "",
- episode: null,
- type: "Movie",
- year: 2010,
- runtime_min: 148,
- size: "12.4 GB",
- bitrate: "35.0 Mbps",
- hdr: "HDR10",
- video: "HEVC",
- resolution: "4K",
- date_added: "2024-01-01",
- library: "Movies",
- path: "/media/movies/Inception.mkv",
- ...overrides,
- };
-}
-
-let status: MediaIndexStatus;
-let queryResult: MediaQueryResponse;
-
-vi.mock("react-router-dom", () => ({
- useNavigate: () => navigate,
- useSearchParams: () => [
- new URLSearchParams("jellyfin_service_id=jfs1"),
- vi.fn(),
- ],
-}));
-
-vi.mock("../../hooks/useMedia", () => ({
- useMediaStatus: () => ({ data: status }),
- useMediaQuery: () => ({ data: queryResult, isLoading: false }),
- useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
- useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
- useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
-}));
-
-vi.mock("../../hooks/useSettings", () => ({
- useMonitoringSettings: () => ({ data: [machineFixture()] }),
-}));
-
-vi.mock("../../hooks/useServices", () => ({
- useServiceInstances: () => ({
- data: [
- { id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
- ],
- }),
-}));
-
-vi.mock("../../hooks/useDashboard", () => ({
- useCounts: () => ({ data: undefined }),
- useLibraries: () => ({ data: undefined }),
-}));
-
-// usePersistentState reads/writes localStorage; clear between tests so the
-// offset/pageSize/columnVisibility state never leaks across cases.
-// matchMedia must be stubbed so useIsMobile (md:768px) and usePrefersSmallScreen
-// (900px) resolve without TypeError in jsdom. Default to desktop (matches:false)
-// so the DataTable path renders by default; mobile tests override.
-beforeEach(() => {
- setMatchMedia(false);
- window.localStorage.clear();
- navigate.mockClear();
- status = statusFixture();
- queryResult = {
- items: [
- mediaItem({
- id: "1",
- title: "Inception",
- path: "/media/movies/Inception.mkv",
- }),
- mediaItem({
- id: "2",
- title: "Matrix",
- path: "/media/movies/Matrix.mkv",
- }),
- ],
- total: 2,
- limit: 100,
- offset: 0,
- };
-});
-
-/** Stub window.matchMedia so useIsMobile / usePrefersSmallScreen resolve in jsdom. */
-function setMatchMedia(matches: boolean) {
- const listeners: ((e: MediaQueryListEvent) => void)[] = [];
- window.matchMedia = vi.fn().mockImplementation((query: string) => ({
- matches: query.includes("768") ? matches : false,
- media: query,
- onchange: null,
- addEventListener: (
- _evt: string,
- listener: (e: MediaQueryListEvent) => void,
- ) => listeners.push(listener),
- removeEventListener: (
- _evt: string,
- listener: (e: MediaQueryListEvent) => void,
- ) => {
- const idx = listeners.indexOf(listener);
- if (idx >= 0) listeners.splice(idx, 1);
- },
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- }));
-}
-
-describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
- it("exposes exactly the 15 locked toggleable columns", async () => {
- render( );
-
- await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
-
- const toggleable = screen
- .getAllByRole("menuitemcheckbox")
- .map((item) => (item.textContent ?? "").trim());
- expect([...toggleable].sort()).toEqual(
- [
- "title",
- "series",
- "season",
- "episode",
- "type",
- "year",
- "runtime_min",
- "size",
- "bitrate",
- "hdr",
- "video",
- "resolution",
- "date_added",
- "library",
- "path",
- ].sort(),
- );
- // The leading selection column is never toggleable (enableHiding=false).
- expect(toggleable).toHaveLength(15);
- expect(toggleable).not.toContain("__select__");
- });
-
- it("renders the 15 data column headers", () => {
- render( );
- const headers = screen
- .getAllByRole("columnheader")
- .map((h) => (h.textContent ?? "").trim());
- for (const expected of [
- "Title",
- "Series",
- "Season",
- "Episode",
- "Type",
- "Year",
- "Runtime",
- "Size",
- "Bitrate",
- "HDR",
- "Video codec",
- "Resolution",
- "Date added",
- "Library",
- "Path",
- ]) {
- expect(headers).toContain(expected);
- }
- });
-
- it("navigates to the file browser at the item path on row click", async () => {
- render( );
-
- await userEvent.click(screen.getByText("Inception"));
-
- expect(navigate).toHaveBeenCalledTimes(1);
- expect(navigate).toHaveBeenCalledWith(
- `/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
- );
- });
-
- it("does NOT navigate when toggling a row selection checkbox", async () => {
- render( );
-
- const firstCheckbox = screen.getAllByRole("checkbox", {
- name: "Select row",
- })[0];
- await userEvent.click(firstCheckbox);
- expect(firstCheckbox).toBeChecked();
- expect(navigate).not.toHaveBeenCalled();
- });
-
- it("renders the server-driven pagination total + page controls", () => {
- render( );
-
- // DataTable manual-pagination footer surfaces the server total + pager.
- // ("Page 1 of 1" also appears in the page caption, so match all and assert
- // the pager footer text is present alongside the unique total.)
- expect(screen.getByText("2 rows")).toBeInTheDocument();
- expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
- expect(
- screen.getByRole("button", { name: "Previous page" }),
- ).toBeDisabled();
- });
-
- it("disables Build index while a build is running", () => {
- status = statusFixture({ build_running: true });
-
- render( );
-
- expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
- // Stop + Force stop surface only while running.
- expect(
- screen.getByRole("button", { name: "Stop build" }),
- ).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Force stop" }),
- ).toBeInTheDocument();
- });
-});
-
-describe("Media (mobile card layout — slice 3)", () => {
- it("renders cards with the title as primary below md", () => {
- setMatchMedia(true);
- render( );
-
- // Card titles render (primary field).
- expect(screen.getByText("Inception")).toBeInTheDocument();
- expect(screen.getByText("Matrix")).toBeInTheDocument();
-
- // Card field labels render (at least once per row).
- expect(screen.getAllByText("Size").length).toBeGreaterThanOrEqual(2);
- expect(screen.getAllByText("HDR").length).toBeGreaterThanOrEqual(2);
- expect(screen.getAllByText("Library").length).toBeGreaterThanOrEqual(2);
- expect(screen.getAllByText("Year").length).toBeGreaterThanOrEqual(2);
-
- // Desktop table headers do NOT render on mobile.
- expect(screen.queryByRole("columnheader", { name: "Title" })).toBeNull();
- expect(screen.queryByRole("columnheader", { name: "Bitrate" })).toBeNull();
- });
-
- it("hides the column-visibility toggle below md", () => {
- setMatchMedia(true);
- render( );
-
- expect(screen.queryByRole("button", { name: /Columns/ })).toBeNull();
- });
-
- it("renders pagination controls below the cards on mobile", () => {
- setMatchMedia(true);
- render( );
-
- expect(screen.getByText("2 rows")).toBeInTheDocument();
- expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
- expect(
- screen.getByRole("button", { name: "Previous page" }),
- ).toBeDisabled();
- expect(
- screen.getByRole("button", { name: "Next page" }),
- ).toBeInTheDocument();
- });
-
- it("navigates to the file browser when a card is tapped on mobile", async () => {
- setMatchMedia(true);
- render( );
-
- await userEvent.click(screen.getByText("Inception"));
-
- expect(navigate).toHaveBeenCalledTimes(1);
- expect(navigate).toHaveBeenCalledWith(
- `/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
- );
- });
-
- it("renders the DataTable (not cards) at desktop width", () => {
- render( );
-
- // Desktop column headers render.
- expect(
- screen.getByRole("columnheader", { name: "Title" }),
- ).toBeInTheDocument();
- // Column-visibility toggle is present.
- expect(screen.getByRole("button", { name: /Columns/ })).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/pages/__tests__/NamedDashboardPage.test.tsx b/frontend/src/pages/__tests__/NamedDashboardPage.test.tsx
new file mode 100644
index 0000000..2943fce
--- /dev/null
+++ b/frontend/src/pages/__tests__/NamedDashboardPage.test.tsx
@@ -0,0 +1,93 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
+import { NamedDashboardPage } from "../NamedDashboardPage";
+
+vi.mock("../../hooks/useDashboards", () => ({
+ useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
+}));
+
+import { useDashboardBySlug } from "../../hooks/useDashboards";
+
+function renderPage(slug: string) {
+ return render(
+
+
+ } />
+
+ ,
+ );
+}
+
+describe("NamedDashboardPage", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("renders loading state", () => {
+ vi.mocked(useDashboardBySlug).mockReturnValue({
+ data: undefined,
+ isLoading: true,
+ isError: false,
+ } as never);
+ renderPage("storage");
+ // Skeleton renders during load.
+ expect(document.querySelector(".h-32")).toBeInTheDocument();
+ });
+
+ it("renders 404 when dashboard not found", () => {
+ vi.mocked(useDashboardBySlug).mockReturnValue({
+ data: undefined,
+ isLoading: false,
+ isError: true,
+ } as never);
+ renderPage("nonexistent");
+ expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
+ });
+
+ it("renders pinned links for a known dashboard", () => {
+ vi.mocked(useDashboardBySlug).mockReturnValue({
+ data: {
+ id: "d1",
+ label: "Storage",
+ slug: "storage",
+ sort_order: 0,
+ payload: {
+ items: [
+ {
+ type: "link",
+ label: "My Jellyfin",
+ target: "/services/jellyfin/svc-1",
+ },
+ ],
+ },
+ created_at: 1,
+ updated_at: 1,
+ },
+ isLoading: false,
+ isError: false,
+ } as never);
+ renderPage("storage");
+ expect(screen.getByText("Storage")).toBeInTheDocument();
+ expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
+ });
+
+ it("renders empty state when dashboard has no items", () => {
+ vi.mocked(useDashboardBySlug).mockReturnValue({
+ data: {
+ id: "d2",
+ label: "Empty",
+ slug: "empty",
+ sort_order: 0,
+ payload: {},
+ created_at: 1,
+ updated_at: 1,
+ },
+ isLoading: false,
+ isError: false,
+ } as never);
+ renderPage("empty");
+ expect(screen.getByText("Empty")).toBeInTheDocument();
+ expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/__tests__/ServicePage.test.tsx b/frontend/src/pages/__tests__/ServicePage.test.tsx
index b142904..9840ec5 100644
--- a/frontend/src/pages/__tests__/ServicePage.test.tsx
+++ b/frontend/src/pages/__tests__/ServicePage.test.tsx
@@ -1,20 +1,14 @@
-import { describe, it, expect, beforeEach, vi } from "vitest";
+import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
+import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServicePage } from "../ServicePage";
-import type {
- ServiceInstance,
- ServiceInstanceInput,
- ServiceTypeInfo,
-} from "../../types";
-
-// --- fixtures ---
+import type { ServiceInstance, ServiceTypeInfo } from "../../types";
const instance: ServiceInstance = {
id: "svc-1",
- service_type: "grafana",
- name: "Production Grafana",
- config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
+ service_type: "jellyfin",
+ name: "Main Jellyfin",
+ config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
@@ -22,128 +16,120 @@ const instance: ServiceInstance = {
};
const typeInfo: ServiceTypeInfo = {
- service_type: "grafana",
- name: "Grafana",
- description: "Dashboards, metrics, and logs.",
+ service_type: "jellyfin",
+ name: "Jellyfin",
+ description: "Media server",
config_schema: {
type: "object",
- properties: {
- base_url: { type: "string", description: "Absolute URL." },
- timeout_seconds: { type: "integer" },
- },
+ properties: { base_url: { type: "string" } },
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
-// --- mocks ---
+const secondInstance: ServiceInstance = {
+ ...instance,
+ id: "svc-2",
+ name: "Backup Jellyfin",
+};
-const mutateAsync = vi.fn();
-const mutate = vi.fn();
-const deleteMutate = vi.fn();
+const saveMutateAsync = vi.fn();
vi.mock("../../hooks/useServices", () => ({
- useServiceInstances: () => ({ data: [instance] }),
+ useServiceInstances: () => ({
+ data: (window as unknown as { __svcInstances?: ServiceInstance[] })
+ ?.__svcInstances ?? [instance],
+ }),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
- mutateAsync,
- mutate,
+ mutateAsync: saveMutateAsync,
+ mutate: vi.fn(),
isPending: false,
}),
- useDeleteServiceInstance: () => ({ mutate: deleteMutate, isPending: false }),
+ useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
}));
-vi.mock("react-router-dom", () => ({
- useParams: () => ({
- serviceType: "grafana",
- serviceId: "svc-1",
+vi.mock("../../integrations/registry", () => ({
+ getServiceBinding: () => ({
+ name: "Jellyfin",
+ description: "Media server",
+ widgets: [],
}),
- useNavigate: () => vi.fn(),
}));
-// jsdom has no window.matchMedia; stub it. Default to desktop (matches: false).
-function setMatchMedia(matches: boolean) {
- window.matchMedia = ((query: string) => ({
- matches: query.includes("768") ? matches : false,
- media: query,
- onchange: null,
- addEventListener: () => {},
- removeEventListener: () => {},
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- })) as unknown as typeof window.matchMedia;
+function renderServicePage(path: string) {
+ return render(
+
+
+ }
+ />
+
+ ,
+ );
}
-beforeEach(() => {
- setMatchMedia(false);
- mutateAsync.mockReset();
- mutate.mockReset();
- deleteMutate.mockReset();
-});
-
-describe("ServicePage (desktop)", () => {
- it("renders the full-page layout with the service name and connection card", () => {
- render( );
- // Page heading (desktop only — mobile uses SheetForm title)
- expect(
- screen.getByRole("heading", { name: "Production Grafana" }),
- ).toBeInTheDocument();
- // Connection section card title
- expect(screen.getByText("Connection")).toBeInTheDocument();
- // General Save button
- expect(screen.getByRole("button", { name: "Save" })).toBeInTheDocument();
+describe("ServicePage tab skeleton", () => {
+ it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
+ renderServicePage("/services/jellyfin/svc-1");
+ expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
});
- it("does not render the SheetForm at desktop width", () => {
- render( );
- // SheetForm renders a dialog with role="dialog" only when open; on
- // desktop the page layout is used, so no dialog should be present.
- expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
- });
-});
-
-describe("ServicePage (mobile SheetForm — slice 6)", () => {
- beforeEach(() => setMatchMedia(true));
-
- it("renders the SheetForm with the service name as title below md", () => {
- render( );
- // SheetForm title is rendered inside a SheetTitle (role="heading").
+ it("does NOT render Media/Requests for non-jellyfin types", () => {
+ const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
+ (
+ window as unknown as { __svcInstances: ServiceInstance[] }
+ ).__svcInstances = [sshInstance];
+ renderServicePage("/services/ssh_tasks/ssh-1");
+ expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
expect(
- screen.getByRole("heading", { name: "Production Grafana" }),
- ).toBeInTheDocument();
- // The dialog (Sheet content) should be present on mobile.
- expect(screen.getByRole("dialog")).toBeInTheDocument();
- // Desktop page header description is NOT rendered inside the SheetForm.
- expect(
- screen.queryByText("Dashboards, metrics, and logs."),
+ screen.queryByRole("tab", { name: "Media" }),
).not.toBeInTheDocument();
});
- it("edits the name field and Save calls the save mutation", async () => {
- render( );
- const nameInput = screen.getByLabelText("Name");
- expect(nameInput).toHaveValue("Production Grafana");
-
- await userEvent.clear(nameInput);
- await userEvent.type(nameInput, "Renamed Grafana");
-
- const saveButton = screen.getByRole("button", { name: "Save" });
- await userEvent.click(saveButton);
-
- expect(mutateAsync).toHaveBeenCalledTimes(1);
- const input = mutateAsync.mock.calls[0][0] as ServiceInstanceInput;
- expect(input.name).toBe("Renamed Grafana");
- expect(input.id).toBe("svc-1");
- // Lock the full save payload (config draft, enabled, secrets sentinel).
- expect(input.enabled).toBe(true);
- expect(input.secrets).toEqual({});
- expect(input.config).toMatchObject({ base_url: "https://grafana.example.com" });
+ it("shows instance switcher when >1 sibling of same type", () => {
+ (
+ window as unknown as { __svcInstances: ServiceInstance[] }
+ ).__svcInstances = [instance, secondInstance];
+ const { container } = renderServicePage("/services/jellyfin/svc-1");
+ // The switcher renders as a Select trigger (combobox).
+ expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
});
- it("renders the connection config fields as editable inside the SheetForm", () => {
- render( );
- const urlInput = screen.getByLabelText("base_url");
- expect(urlInput).toHaveValue("https://grafana.example.com");
+ it("hides instance switcher when only one instance", () => {
+ (
+ window as unknown as { __svcInstances: ServiceInstance[] }
+ ).__svcInstances = [instance];
+ const { container } = renderServicePage("/services/jellyfin/svc-1");
+ // No select trigger rendered (only one instance).
+ expect(
+ container.querySelector("[role='combobox']"),
+ ).not.toBeInTheDocument();
+ });
+
+ it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
+ const { userEvent } = await import("@testing-library/user-event");
+ const user = userEvent.setup();
+ saveMutateAsync.mockReset();
+ renderServicePage("/services/jellyfin/svc-1");
+
+ // Open the Config tab and type a new api_key.
+ await user.click(screen.getByRole("tab", { name: "Config" }));
+ const secretInput = screen.getByLabelText("api_key");
+ await user.type(secretInput, "new-secret-value");
+
+ // Save and assert the typed secret is in the payload (not secrets: {}).
+ await user.click(screen.getByRole("button", { name: "Save" }));
+ expect(saveMutateAsync).toHaveBeenCalledTimes(1);
+ const input = saveMutateAsync.mock.calls[0][0] as {
+ secrets: Record;
+ };
+ expect(input.secrets).toEqual({ api_key: "new-secret-value" });
});
});
diff --git a/frontend/src/pages/__tests__/Settings.test.tsx b/frontend/src/pages/__tests__/Settings.test.tsx
index 174a3ab..debb550 100644
--- a/frontend/src/pages/__tests__/Settings.test.tsx
+++ b/frontend/src/pages/__tests__/Settings.test.tsx
@@ -6,10 +6,12 @@ import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn();
-const testSSHMutate = vi.fn().mockResolvedValue({
- message: "SSH auth succeeded",
- known_hosts_updated: true,
-});
+const testSSHMutate = vi
+ .fn()
+ .mockResolvedValue({
+ message: "SSH auth succeeded",
+ known_hosts_updated: true,
+ });
let machines: MonitoringMachine[] = [];
@@ -111,103 +113,3 @@ describe("Settings", () => {
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
});
});
-
-// jsdom has no window.matchMedia; default to desktop so existing tests are
-// unaffected.
-function setMatchMedia(matches: boolean) {
- window.matchMedia = ((query: string) => ({
- matches: query.includes("768") ? matches : false,
- media: query,
- onchange: null,
- addEventListener: () => {},
- removeEventListener: () => {},
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- })) as unknown as typeof window.matchMedia;
-}
-
-describe("Settings (mobile SheetForm — slice 7)", () => {
- beforeEach(() => setMatchMedia(true));
-
- it("opens the machine editor in a SheetForm below md", async () => {
- machines = [localMachine()];
- render( );
-
- // Open the editor via the detail-pane Edit button (visible text).
- const detailEdit = screen
- .getAllByRole("button", { name: "Edit" })
- .find((button) => button.textContent === "Edit") as HTMLButtonElement;
- await userEvent.click(detailEdit);
-
- // SheetForm renders a dialog; the DialogTitle shows the editor title.
- expect(screen.getByText("Edit machine")).toBeInTheDocument();
- expect(screen.getByRole("dialog")).toBeInTheDocument();
- // Desktop DialogDescription text is not rendered as a dialog description
- // on mobile (the MachineEditor has its own hint labels, which is fine).
- expect(
- screen.queryByRole("heading", { name: "Create machine" }),
- ).not.toBeInTheDocument();
- });
-
- it("saves a machine via the SheetForm on mobile", async () => {
- machines = [localMachine()];
- render( );
-
- const detailEdit = screen
- .getAllByRole("button", { name: "Edit" })
- .find((button) => button.textContent === "Edit") as HTMLButtonElement;
- await userEvent.click(detailEdit);
-
- const nameInput = screen.getByLabelText("Name");
- await userEvent.clear(nameInput);
- await userEvent.type(nameInput, "Renamed node");
-
- await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
-
- expect(saveMachineMutate).toHaveBeenCalledTimes(1);
- const saved = saveMachineMutate.mock.calls[0][0];
- expect(saved.name).toBe("Renamed node");
- expect(saved.mode).toBe("local");
- });
-
- it("cancel closes the SheetForm on mobile", async () => {
- machines = [localMachine()];
- render( );
-
- const detailEdit = screen
- .getAllByRole("button", { name: "Edit" })
- .find((button) => button.textContent === "Edit") as HTMLButtonElement;
- await userEvent.click(detailEdit);
-
- expect(screen.getByRole("dialog")).toBeInTheDocument();
-
- await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
- // The sheet is now closed — the dialog role should no longer be present.
- // (The page content itself is still rendered; only the sheet unmounts.)
- expect(screen.queryByText("Edit machine")).not.toBeInTheDocument();
- });
-
- it("prompts before discarding unsaved machine edits (R4.5)", async () => {
- machines = [localMachine()];
- render( );
-
- const detailEdit = screen
- .getAllByRole("button", { name: "Edit" })
- .find((button) => button.textContent === "Edit") as HTMLButtonElement;
- await userEvent.click(detailEdit);
-
- // Edit the name to make the form dirty.
- const nameInput = screen.getByLabelText("Name");
- await userEvent.clear(nameInput);
- await userEvent.type(nameInput, "Dirty name");
-
- // Cancel should NOT immediately close — the discard confirm appears.
- await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
- expect(
- screen.getByRole("heading", { name: "Discard changes?" }),
- ).toBeInTheDocument();
- // The editor is still open.
- expect(screen.getByText("Edit machine")).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/pages/__tests__/UsersPage.test.tsx b/frontend/src/pages/__tests__/UsersPage.test.tsx
deleted file mode 100644
index 7bc8677..0000000
--- a/frontend/src/pages/__tests__/UsersPage.test.tsx
+++ /dev/null
@@ -1,407 +0,0 @@
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen } from "@testing-library/react";
-import userEvent from "@testing-library/user-event";
-import { UsersPage } from "../UsersPage.impl";
-import { TooltipProvider } from "../../components/ui/tooltip";
-import type {
- NowPlayingSession,
- UserDirectoryItem,
- UserDirectoryResponse,
-} from "../../types";
-
-// jsdom has no window.matchMedia; the shared `useIsMobile` hook and the
-// compose dialog viewport hook must not blow up during render. Stub to
-// "desktop" (matches: false) by default; the slice-5 describe block flips it
-// to mobile for card-layout assertions.
-beforeEach(() => {
- if (!window.matchMedia) {
- window.matchMedia = ((query: string) => ({
- matches: false,
- media: query,
- onchange: null,
- addEventListener: () => {},
- removeEventListener: () => {},
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- })) as unknown as typeof window.matchMedia;
- }
- // The compose formatting actions defer a focus/selection restore via
- // requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
- // synchronously, so make it synchronous so the slice-6b compose test can
- // observe the html-body value update.
- window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
- cb(0);
- return 0;
- }) as typeof window.requestAnimationFrame;
-});
-
-// Keep the drawer's nested session panel out of the DOM under test.
-vi.mock("../../components/SessionActivityPanel", () => ({
- SessionActivityPanel: ({
- selectedUserLabel,
- }: {
- selectedUserLabel: string;
- }) => {selectedUserLabel}
,
-}));
-
-let users: UserDirectoryItem[] = [];
-let activity: NowPlayingSession[] = [];
-
-function directoryResponse(): UserDirectoryResponse {
- return {
- items: users,
- total: users.length,
- jellyseerr_configured: true,
- jellyseerr_available: true,
- jellyseerr_error: "",
- jellyseerr_jellyfin_user_count: 0,
- jellyseerr_user_count: 0,
- enriched_count: 0,
- };
-}
-
-vi.mock("../../hooks/useUsers", () => ({
- useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
-}));
-vi.mock("../../hooks/useDashboard", () => ({
- useActivity: () => ({ data: activity }),
-}));
-vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
- useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
-}));
-vi.mock("../../hooks/useSendUserMessage", () => ({
- useSendUserMessage: () => ({
- isPending: false,
- isError: false,
- isSuccess: false,
- reset: vi.fn(),
- mutateAsync: vi.fn(),
- }),
-}));
-
-// useSearchParams backs `?user=` (drawer open) on a module-level object.
-// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
-let currentParams: Record = {};
-const setSearchParams = vi.fn((next: Record) => {
- currentParams = { ...next };
-});
-vi.mock("react-router-dom", () => ({
- useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
-}));
-
-function userFixture(
- overrides: Partial = {},
-): UserDirectoryItem {
- return {
- jellyfin_id: "u1",
- username: "alice",
- display_name: "Alice",
- email: "alice@example.com",
- email_source: "jellyfin",
- avatar: "",
- avatar_source: "",
- contactable: true,
- source: "jellyfin",
- source_summary: "",
- name_source: "jellyfin",
- access_source: "jellyfin",
- jellyseerr_user_id: null,
- jellyseerr_username: "",
- user_type: 1,
- user_type_label: "User",
- role: "admin",
- permissions: 1,
- permissions_label: "Administrator",
- request_count: 0,
- ...overrides,
- };
-}
-
-beforeEach(() => {
- users = [];
- activity = [];
- currentParams = {};
- setSearchParams.mockClear();
-});
-
-describe("UsersPage (slice 6a — directory surface + drawer)", () => {
- it("renders the directory table and metric counts", () => {
- users = [userFixture()];
- render( );
-
- expect(screen.getByText("Total users")).toBeInTheDocument();
- expect(screen.getByText("User list")).toBeInTheDocument();
- expect(screen.getByText("Alice")).toBeInTheDocument();
- expect(screen.getByText("0 selected")).toBeInTheDocument();
- });
-
- it("toggles row selection and reflects the selected-count badge", async () => {
- users = [
- userFixture({ jellyfin_id: "u1" }),
- userFixture({
- jellyfin_id: "u2",
- username: "bob",
- display_name: "Bob",
- email: "bob@example.com",
- }),
- ];
- render( );
-
- expect(screen.getByText("0 selected")).toBeInTheDocument();
-
- // Selection-across-pagination: toggling a row updates the selected-id set.
- await userEvent.click(
- screen.getByRole("checkbox", { name: "Select Alice" }),
- );
- expect(screen.getByText("1 selected")).toBeInTheDocument();
-
- // Toggling again removes it (the set survives, membership flips).
- await userEvent.click(
- screen.getByRole("checkbox", { name: "Select Alice" }),
- );
- expect(screen.getByText("0 selected")).toBeInTheDocument();
- });
-
- it("selects all visible rows via the header select-all checkbox", async () => {
- users = [
- userFixture({ jellyfin_id: "u1" }),
- userFixture({
- jellyfin_id: "u2",
- username: "bob",
- display_name: "Bob",
- email: "bob@example.com",
- }),
- ];
- render( );
-
- await userEvent.click(
- screen.getByRole("checkbox", { name: "Select all visible users" }),
- );
- expect(screen.getByText("2 selected")).toBeInTheDocument();
- });
-
- it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
- users = [userFixture({ jellyfin_id: "u1" })];
- render( );
-
- // Clicking the row body (not the checkbox) opens the detail drawer.
- await userEvent.click(screen.getByText("Alice"));
- expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
- });
-
- it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
- users = [
- userFixture({
- jellyfin_id: "u1",
- username: "alice",
- display_name: "Alice",
- }),
- userFixture({
- jellyfin_id: "u2",
- username: "bob",
- display_name: "Bob",
- email: "bob@example.com",
- }),
- ];
- activity = [
- {
- user: "alice",
- title: "Movie",
- type: "Movie",
- state: "playing",
- transcoding: "no",
- transcoding_type: "",
- device: "Web",
- session_id: "s1",
- },
- {
- user: "bob",
- title: "Show",
- type: "Episode",
- state: "paused",
- transcoding: "no",
- transcoding_type: "",
- device: "TV",
- session_id: "s2",
- },
- ];
- render( );
-
- // Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
- expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
- "success",
- );
- expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
- "warning",
- );
- });
-
- it("renders the user detail drawer (Sheet) when a user is selected", () => {
- users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
- currentParams = { user: "u1" };
- render( );
-
- // buildUserDrawerModel title = display name; rendered as the drawer heading.
- expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
- // Drawer sections (identity / contact actions) + the activity panel render.
- expect(screen.getByText("Identity")).toBeInTheDocument();
- expect(screen.getByText("Contact actions")).toBeInTheDocument();
- expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
- });
-});
-
-describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
- it("opens compose and inserts bold markup into the html body", async () => {
- users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
- // The formatting toolbar renders (shadcn), which in the app is
- // wrapped by a global in App.tsx; supply it here.
- render(
-
-
- ,
- );
-
- // Select a deliverable user so the "Message selected" button enables.
- await userEvent.click(
- screen.getByRole("checkbox", { name: "Select Alice" }),
- );
- await userEvent.click(
- screen.getByRole("button", { name: "Message selected" }),
- );
-
- // Compose dialog opens (shadcn Dialog family).
- expect(
- screen.getByRole("heading", { name: "Message selected users" }),
- ).toBeInTheDocument();
-
- // Bold action wraps the cursor selection in via the
- // preserved insertMarkup helper (markup insertion actions parity).
- await userEvent.click(screen.getByRole("button", { name: "Bold" }));
-
- const body = screen.getByRole("textbox", {
- name: "HTML message body",
- }) as HTMLTextAreaElement;
- expect(body.value).toContain("");
- });
-});
-
-describe("UsersPage (mobile card layout — slice 5)", () => {
- beforeEach(() => {
- window.matchMedia = ((query: string) => ({
- matches: query.includes("768"),
- media: query,
- onchange: null,
- addEventListener: () => {},
- removeEventListener: () => {},
- addListener: () => {},
- removeListener: () => {},
- dispatchEvent: () => false,
- })) as unknown as typeof window.matchMedia;
- });
-
- it("renders user cards with display name as primary below md", () => {
- users = [
- userFixture({ jellyfin_id: "u1", display_name: "Alice" }),
- userFixture({
- jellyfin_id: "u2",
- username: "bob",
- display_name: "Bob",
- }),
- ];
-
- render(
-
-
- ,
- );
-
- expect(screen.getByText("Alice")).toBeInTheDocument();
- expect(screen.getByText("Bob")).toBeInTheDocument();
- // Activity field label should appear per card.
- expect(screen.getAllByText("Activity")).toHaveLength(2);
- });
-
- it("toggles selection from the card checkbox without opening the drawer", async () => {
- users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
- render(
-
-
- ,
- );
-
- const checkbox = screen.getByRole("checkbox", {
- name: /Select Alice/i,
- });
- expect(checkbox).toHaveAttribute("data-state", "unchecked");
-
- await userEvent.click(checkbox);
- expect(checkbox).toHaveAttribute("data-state", "checked");
-
- // Drawer stays closed: the session-panel stub only renders when the
- // drawer opens via a card-body tap, not via the checkbox.
- expect(screen.queryByTestId("session-panel-stub")).not.toBeInTheDocument();
- });
-
- it("renders compose in a SheetForm below md with send button", async () => {
- users = [
- userFixture({
- jellyfin_id: "u1",
- display_name: "Alice",
- email: "alice@example.com",
- }),
- ];
- render(
-
-
- ,
- );
-
- // Select the deliverable user via the mobile card checkbox.
- await userEvent.click(
- screen.getByRole("checkbox", { name: /Select Alice/i }),
- );
- await userEvent.click(
- screen.getByRole("button", { name: "Message selected" }),
- );
-
- // On mobile, compose opens in a SheetForm (not a Dialog). The SheetForm
- // header carries the title and the footer carries the Send button.
- expect(screen.getByText("Message selected users")).toBeInTheDocument();
- expect(
- screen.getByRole("button", { name: "Send message" }),
- ).toBeInTheDocument();
- expect(screen.getByLabelText("Subject")).toBeInTheDocument();
- });
-
- it("prompts before discarding unsaved compose edits (R4.5)", async () => {
- users = [
- userFixture({
- jellyfin_id: "u1",
- display_name: "Alice",
- email: "alice@example.com",
- }),
- ];
- render(
-
-
- ,
- );
-
- await userEvent.click(
- screen.getByRole("checkbox", { name: /Select Alice/i }),
- );
- await userEvent.click(
- screen.getByRole("button", { name: "Message selected" }),
- );
-
- // Type a subject to make the compose form dirty.
- await userEvent.type(screen.getByLabelText("Subject"), "Urgent update");
-
- // Cancel should NOT immediately close — the discard confirm appears.
- await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
- expect(
- screen.getByRole("heading", { name: "Discard changes?" }),
- ).toBeInTheDocument();
- });
-});
diff --git a/frontend/src/pages/Actions.tsx b/frontend/src/pages/service-tabs/ActionsTab.tsx
similarity index 69%
rename from frontend/src/pages/Actions.tsx
rename to frontend/src/pages/service-tabs/ActionsTab.tsx
index c94569b..a9e7cf4 100644
--- a/frontend/src/pages/Actions.tsx
+++ b/frontend/src/pages/service-tabs/ActionsTab.tsx
@@ -1,18 +1,25 @@
+/**
+ * ActionsTab — operational content for the ssh_tasks service page.
+ *
+ * Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
+ * provides the active ssh_tasks service id, which is used as the default run
+ * service. The page-level header is removed (the service page provides it).
+ * The task editor dialog, saved-task rail, and run history are preserved.
+ */
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
-import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
+import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
import {
useDeleteTask,
useRunTask,
useSaveTask,
useTaskRuns,
useTasks,
-} from "../hooks/useSettings";
-import { useServiceInstances } from "../hooks/useServices";
-import { DialogFooter } from "../components/DialogFooter";
-import { HoverEditButton } from "../components/HoverEditButton";
-import { SectionCard } from "../components/SectionCard";
-import { SelectionRailCard } from "../components/SelectionRailCard";
+} from "../../hooks/useSettings";
+import { DialogFooter } from "../../components/DialogFooter";
+import { HoverEditButton } from "../../components/HoverEditButton";
+import { SectionCard } from "../../components/SectionCard";
+import { SelectionRailCard } from "../../components/SelectionRailCard";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,13 +44,8 @@ import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
-// Radix Select disallows empty-string item values; the "None" option maps to
-// this sentinel and converts back to "" at the draft boundary.
-const NONE = "__none__";
-
type ActionTab = "new" | string;
-/** Small labeled-field wrapper replacing the MUI `` shell. */
function FormField({
label,
htmlFor,
@@ -106,16 +108,11 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
function TaskEditor({
task,
- services,
onChange,
}: {
task: SavedTaskInput;
- services: ServiceInstance[];
onChange: (task: SavedTaskInput) => void;
}) {
- const selectedService = services.find(
- (service) => service.id === task.default_service_id,
- );
return (
@@ -124,11 +121,7 @@ function TaskEditor({
{task.task_type}
{task.enabled ? "enabled" : "disabled"}
- {selectedService && (
- {`default: ${selectedService.name}`}
- )}
-
-
-
-
- onChange({
- ...task,
- default_service_id: value === NONE ? "" : value,
- })
- }
- >
-
-
-
-
- None
- {services.map((service) => (
-
- {service.name}
-
- ))}
-
-
-
-
void;
onChange: (task: SavedTaskInput) => void;
onSave: () => void;
@@ -235,12 +201,10 @@ function TaskDialog({
if (
!sameTask(task, baseline) &&
!window.confirm("Discard unsaved changes?")
- ) {
+ )
return;
- }
onClose();
};
-
return (
Save a reusable server task. Shell commands run via{" "}
/bin/sh -c; Python runs via python3 -c.
- Runs execute on the selected SSH task service instance.
+ Runs execute on this SSH task service instance.
-
+
+
Delete
) : undefined
@@ -277,8 +241,7 @@ function TaskDialog({
);
}
-export function Actions() {
- const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
+export function ActionsTab({ instance }: { instance: ServiceInstance }) {
const { data: tasks = [] } = useTasks();
const saveTask = useSaveTask();
const deleteTask = useDeleteTask();
@@ -288,9 +251,11 @@ export function Actions() {
const [draftBaseline, setDraftBaseline] = useState(
emptyTask(),
);
- const [runServiceId, setRunServiceId] = useState("");
const [editOpen, setEditOpen] = useState(false);
+ // Default to this instance's service id for task runs.
+ const runServiceId = instance.id;
+
const selectedTask = useMemo(
() => tasks.find((task) => task.id === tab) ?? null,
[tasks, tab],
@@ -303,14 +268,6 @@ export function Actions() {
setEditOpen(true);
};
- const createNew = () => {
- const initial = emptyTask();
- setDraft(initial);
- setDraftBaseline(initial);
- setRunServiceId(sshServices[0]?.id || "");
- setEditOpen(true);
- };
-
const saveDraft = async () => {
const saved = await saveTask.mutateAsync(draft);
setTab(saved.id);
@@ -328,20 +285,8 @@ export function Actions() {
setDraftBaseline(nextDraft);
};
- const editingTask = selectedTask;
-
return (
-
-
-
-
Actions
-
- Save reusable server tasks and switch between them with tabs.
-
-
-
{`${tasks.length} saved`}
-
-
+
{saveTask.error && (
{String(saveTask.error)}
@@ -367,8 +312,8 @@ export function Actions() {
openEdit(emptyTask())}
>
Add action
@@ -406,23 +351,23 @@ export function Actions() {
- {editingTask ? (
+ {selectedTask ? (
- openEdit(initialFromTask(editingTask))}
+ onClick={() => openEdit(initialFromTask(selectedTask))}
>
Edit
- {
await runTask.mutateAsync({
- taskId: editingTask.id,
+ taskId: selectedTask.id,
serviceId: runServiceId,
});
}}
@@ -432,35 +377,7 @@ export function Actions() {
}
>
-
-
- setRunServiceId(value)}
- >
-
-
-
-
- {sshServices.map((service) => (
-
- {service.name}
-
- ))}
-
-
-
-
-
-
Recent runs
{selectedRuns.data?.items?.length ? (
@@ -509,25 +426,16 @@ export function Actions() {
)}
) : (
-
-
- {tasks[0] && (
- setTab(tasks[0].id)} className="mobile-touch-target">
- Select first action
-
- )}
-
-
-
-
- Saved actions stay on the left rail, while details, run
- controls, and recent history appear here.
-
-
-
+
+ {tasks[0] && (
+ setTab(tasks[0].id)}>
+ Select first action
+
+ )}
+
)}
@@ -536,7 +444,6 @@ export function Actions() {
open={editOpen}
task={draft}
baseline={draftBaseline}
- services={sshServices}
onClose={() => setEditOpen(false)}
onChange={setDraft}
onSave={saveDraft}
diff --git a/frontend/src/pages/service-tabs/AlertsTab.tsx b/frontend/src/pages/service-tabs/AlertsTab.tsx
new file mode 100644
index 0000000..cd40c9e
--- /dev/null
+++ b/frontend/src/pages/service-tabs/AlertsTab.tsx
@@ -0,0 +1,188 @@
+/**
+ * Alertmanager Alerts tab (spec R2.4, R8.2).
+ *
+ * Lifts the Alertmanager alerts content from the old cross-service
+ * ObservabilityPage into an instance-scoped tab. Renders the active-alert
+ * summary (total + by severity) and the expandable alert list.
+ *
+ * The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
+ * first-configured for now — they don't accept a service_id yet. Wiring
+ * `instance.id` into them is a documented follow-up once the hooks gain the
+ * parameter. The `instance` prop is accepted for future scoping.
+ */
+import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
+import {
+ useAlertmanagerAlerts,
+ useAlertmanagerStatus,
+} from "../../hooks/useObservability";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger,
+} from "@/components/ui/collapsible";
+import type { AlertmanagerAlert, ServiceInstance } 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 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}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
+
+export function AlertsTab({ instance }: { instance: ServiceInstance }) {
+ // Global / first-configured hooks for now; instance.id scoping is a
+ // follow-up (see file docstring).
+ void instance;
+
+ const {
+ data: alertsSummary,
+ isLoading: alertsLoading,
+ error: alertsError,
+ } = useAlertmanagerAlerts();
+ const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
+
+ const statusDetail = status?.up
+ ? status.version
+ ? `version ${status.version}`
+ : "reachable"
+ : statusLoading
+ ? "checking…"
+ : "unreachable";
+
+ return (
+
+
+
+ Alertmanager {statusDetail}
+
+
+ {alertsError && (
+
+ Failed to load alerts
+ {alertsError.message}
+
+ )}
+
+
+
+
+
+ Active Alerts ({alertsSummary?.total ?? 0})
+
+
+
+ {alertsLoading ? (
+
+
+
+
+
+ ) : !alertsSummary || alertsSummary.total === 0 ? (
+
+
+
No active alerts
+
+ Everything looks quiet. Firing alerts will appear here.
+
+
+ ) : (
+ <>
+ {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
+
+ )}
+ >
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/FileBrowser.impl.tsx b/frontend/src/pages/service-tabs/FilesTab.tsx
similarity index 62%
rename from frontend/src/pages/FileBrowser.impl.tsx
rename to frontend/src/pages/service-tabs/FilesTab.tsx
index b4baa55..0455b32 100644
--- a/frontend/src/pages/FileBrowser.impl.tsx
+++ b/frontend/src/pages/service-tabs/FilesTab.tsx
@@ -1,5 +1,15 @@
-import { useMemo, useState } from "react";
-import { useNavigate, useSearchParams } from "react-router-dom";
+/**
+ * FilesTab — operational content for the ssh_tasks service page.
+ *
+ * Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
+ * selector and `useMonitoringSettings` are removed; the active ssh_tasks
+ * instance id (from the `instance` prop) replaces the machine_id. The initial
+ * path is read from `?path=` search param for deep-link support (resolves the
+ * MediaTab row-click navigation from slice 5). Everything else — directory
+ * listing, path bar, ffprobe preview, job execution — is preserved.
+ */
+import { useState } from "react";
+import { useSearchParams } from "react-router-dom";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
@@ -7,7 +17,7 @@ import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
-import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
+import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -20,18 +30,27 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
-import { TabsTrigger } from "@/components/ui/tabs";
+
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
-} from "../hooks/useFiles";
-import { usePersistentState } from "../hooks/usePersistentState";
-import { useIsMobile } from "../hooks/useIsMobile";
-import { useMonitoringSettings } from "../hooks/useSettings";
-import { SectionCard } from "../components/SectionCard";
-import { TabbedCard } from "../components/TabbedCard";
+} from "../../hooks/useFiles";
+import { usePersistentState } from "../../hooks/usePersistentState";
+import { SectionCard } from "../../components/SectionCard";
+import type { ServiceInstance } from "../../types";
+import { useIsMobile } from "../../hooks/useIsMobile";
+
+// Mobile card fields (mobile-parity pattern).
+const fileCardFields: MobileCardField
[] = [
+ { key: "name", label: "Name", render: (r) => r.name, primary: true },
+ { key: "type", label: "Type", render: (r) => r.type },
+ { key: "size", label: "Size", render: (r) => r.size || "-" },
+ { key: "modified", label: "Modified", render: (r) => r.modified || "-" },
+];
+
+// --- Types (lifted verbatim) ---
interface DisplayRow {
id: string;
@@ -83,6 +102,8 @@ interface FfprobeData {
streams?: FfprobeStream[];
}
+// --- Helpers (lifted verbatim) ---
+
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -154,9 +175,8 @@ function isVideoFile(name: string): boolean {
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
-// Design §3.2: referentially-stable column defs (a new array each render would
-// destabilize the TanStack table instance and drop controlled selection).
-// Visibility-only: no sorting, no sizing/resizing (design §3.3).
+// --- Column defs (lifted verbatim) ---
+
const fileColumns: ColumnDef[] = [
{
accessorKey: "type",
@@ -187,19 +207,9 @@ const fileColumns: ColumnDef[] = [
},
];
-// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
-// Name is the primary identifier; type distinguishes dir/file/up at a glance;
-// size and modified give the at-a-glance info a user browsing files on a phone
-// needs. Ext is redundant with the name on mobile (the extension is visible in
-// the filename itself). See OpenSpec change `mobile-responsive-parity`.
-const fileCardFields: MobileCardField[] = [
- { key: "name", label: "Name", render: (r) => r.name, primary: true },
- { key: "type", label: "Type", render: (r) => r.type },
- { key: "size", label: "Size", render: (r) => r.size || "-" },
- { key: "modified", label: "Modified", render: (r) => r.modified || "-" },
-];
+// --- State + helpers (lifted) ---
-const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
+const FILE_TAB_STATE_KEY = "manage.files.tabState";
type FileBrowserState = {
currentDir: string;
@@ -217,6 +227,8 @@ function defaultFileBrowserState(): FileBrowserState {
};
}
+// --- Ffprobe rendering (lifted verbatim) ---
+
function FfprobeChip({
children,
variant = "outline",
@@ -234,15 +246,9 @@ function StreamBlock({ children }: { children: React.ReactNode }) {
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
- const videoStreams = streams.filter(
- (stream) => stream.codec_type === "video",
- );
- const audioStreams = streams.filter(
- (stream) => stream.codec_type === "audio",
- );
- const subtitleStreams = streams.filter(
- (stream) => stream.codec_type === "subtitle",
- );
+ const videoStreams = streams.filter((s) => s.codec_type === "video");
+ const audioStreams = streams.filter((s) => s.codec_type === "audio");
+ const subtitleStreams = streams.filter((s) => s.codec_type === "subtitle");
return (
@@ -250,7 +256,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
ffprobe details
{path}
-
Container / format
@@ -286,11 +291,9 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
-
Streams
-
{videoStreams.length > 0 && (
Video streams
@@ -340,9 +343,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
{stream.width && stream.height && (
-
- {`${stream.width}×${stream.height}`}
-
+
{`${stream.width}×${stream.height}`}
)}
{stream.pix_fmt && (
@@ -350,14 +351,10 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
{stream.display_aspect_ratio && (
-
- {`DAR ${stream.display_aspect_ratio}`}
-
+
{`DAR ${stream.display_aspect_ratio}`}
)}
{stream.sample_aspect_ratio && (
-
- {`SAR ${stream.sample_aspect_ratio}`}
-
+
{`SAR ${stream.sample_aspect_ratio}`}
)}
{stream.level !== undefined &&
stream.level !== null && (
@@ -399,7 +396,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
-
{audioStreams.length > 0 && (
Audio streams
@@ -448,7 +444,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
-
{subtitleStreams.length > 0 && (
@@ -481,7 +476,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
-
{streams.length === 0 && (
No streams found.
@@ -489,16 +483,16 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
-
{Object.keys(format.tags ?? {}).length > 0 && (
Tags
{Object.entries(format.tags ?? {}).map(([key, value]) => (
-
- {`${key}: ${value}`}
-
+ {`${key}: ${value}`}
))}
@@ -508,57 +502,36 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
);
}
-function InfoAlert({ children }: { children: React.ReactNode }) {
- return (
-
- {children}
-
- );
-}
+// --- Component ---
-export function FileBrowser() {
- const [searchParams, setSearchParams] = useSearchParams();
+export function FilesTab({ instance }: { instance: ServiceInstance }) {
const isMobile = useIsMobile();
+ const machineId = instance.id;
+ const [searchParams] = useSearchParams();
+ const requestedPath = searchParams.get("path");
const [columnVisibility, setColumnVisibility] = useState<
Record
>({});
- const { data: machines } = useMonitoringSettings();
- const fileMachines = useMemo(
- () =>
- (machines ?? []).filter(
- (machine) =>
- machine.enabled &&
- (machine.services.includes("files") ||
- machine.services.includes("monitoring")),
- ),
- [machines],
- );
- const initialRequestedPath = searchParams.get("path");
- const initialMachineId =
- searchParams.get("machine_id") || fileMachines[0]?.id || "";
const [browserState, setBrowserState] = usePersistentState(
- FILE_BROWSER_STATE_KEY,
+ `${FILE_TAB_STATE_KEY}.${instance.id}`,
() => {
- const requestedPath = initialRequestedPath ?? "/";
+ const path = requestedPath ?? "/";
const selectedPath =
- requestedPath !== "/" &&
- (isVideoFile(requestedPath) || requestedPath.includes("."))
- ? requestedPath.replace(/\/+$/, "")
+ path !== "/" && (isVideoFile(path) || path.includes("."))
+ ? path.replace(/\/+$/, "")
: null;
const currentDir = selectedPath
? selectedPath.replace(/\/[^/]+$/, "") || "/"
- : requestedPath.replace(/\/+$/, "") || "/";
+ : path.replace(/\/+$/, "") || "/";
return {
...defaultFileBrowserState(),
currentDir,
- pathInput: requestedPath || currentDir,
+ pathInput: path || currentDir,
selectedPath,
};
},
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
- const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
- const navigateToSettings = useNavigate();
const updateBrowserState = (patch: Partial) =>
setBrowserState((current) => ({ ...current, ...patch }));
@@ -567,7 +540,7 @@ export function FileBrowser() {
isLoading,
error,
refetch,
- } = useDirectoryListing(currentDir, selectedMachineId || undefined);
+ } = useDirectoryListing(currentDir, machineId);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
@@ -575,10 +548,10 @@ export function FileBrowser() {
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
- selectedMachineId || undefined,
+ machineId,
);
const { data: templates } = useJobTemplates();
- const runJob = useRunJob(selectedMachineId || undefined);
+ const runJob = useRunJob(machineId);
const navigate = (path: string) => {
updateBrowserState({
@@ -588,18 +561,6 @@ export function FileBrowser() {
});
};
- const setMachine = (machineId: string) => {
- setSearchParams(
- (current) => {
- const next = new URLSearchParams(current);
- if (machineId) next.set("machine_id", machineId);
- else next.delete("machine_id");
- return next;
- },
- { replace: true },
- );
- };
-
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
@@ -634,8 +595,6 @@ export function FileBrowser() {
}
}
- // Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
- // file rows select the file for ffprobe preview (also feeds pathInput).
const handleRowClick = (row: DisplayRow) => {
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
@@ -648,8 +607,6 @@ export function FileBrowser() {
});
};
- // Single-select checkbox behavior (DataTable adds a selection column under
- // enableRowSelection): mirrors the row-click selection for file rows.
const rowSelection: RowSelectionState = selectedPath
? { [selectedPath]: true }
: {};
@@ -677,216 +634,182 @@ export function FileBrowser() {
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
-
-
-
File Browser
-
- {fileMachines.length
- ? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
- : "No file machines"}
-
-
-
-
0 ? selectedMachineId : ""}
- onChange={setMachine}
- tabs={fileMachines.map((machine) => (
-
- {`${machine.name} · ${machine.mode}`}
-
- ))}
+
+
- {fileMachines.length > 0 ? (
-
-
-
-
-
- Remote path
-
- updateBrowserState({ pathInput: e.target.value })
- }
- onKeyDown={handlePathSubmit}
- />
-
-
- navigate(pathInput || "/")}
- >
- Open
-
- refetch()}
- >
- Refresh
-
-
-
-
- {`Current: ${currentDir} `}
- {selectedPath ? `| Selected: ${selectedPath} ` : ""}
- {listing ? `| Entries: ${listing.count}` : ""}
-
- {error && (
-
- {String(error)}
-
- )}
-
- {isMobile ? (
-
- row.id}
- onRowClick={handleRowClick}
- />
-
- ) : (
-
row.id}
- enableRowSelection
- rowSelection={rowSelection}
- onRowSelectionChange={handleSelectionChange}
- onRowClick={handleRowClick}
- enableColumnVisibilityToggle
- columnVisibility={columnVisibility}
- onColumnVisibilityChange={setColumnVisibility}
- emptyMessage={
- isLoading
- ? "Loading directory..."
- : "This directory is empty."
- }
- />
- )}
-
+
+
+
+ Remote path
+
+ updateBrowserState({ pathInput: e.target.value })
+ }
+ onKeyDown={handlePathSubmit}
+ />
+
+
+ navigate(pathInput || "/")}
+ >
+ Open
+
+ refetch()}
+ >
+ Refresh
+
+
+
+
+ {`Current: ${currentDir} `}
+ {selectedPath ? `| Selected: ${selectedPath} ` : ""}
+ {listing ? `| Entries: ${listing.count}` : ""}
+
+ {error && (
+
+ {String(error)}
+
+ )}
+
+ {isMobile ? (
+
+ row.id}
+ onRowClick={handleRowClick}
+ />
+
+ ) : (
+
row.id}
+ enableRowSelection
+ rowSelection={rowSelection}
+ onRowSelectionChange={handleSelectionChange}
+ onRowClick={handleRowClick}
+ enableColumnVisibilityToggle
+ columnVisibility={columnVisibility}
+ onColumnVisibilityChange={setColumnVisibility}
+ emptyMessage={
+ isLoading ? "Loading directory..." : "This directory is empty."
+ }
+ />
+ )}
+
+
+
+
+
+ {selectedPath ? (
+ isVideoFile(selectedPath) ? (
+ ffprobeError ? (
+
+ {String(ffprobeError)}
+
+ ) : ffprobeLoading && !ffprobeData ? (
+
+ Loading ffprobe data...
+
+ ) : ffprobeData ? (
+
+ ) : (
+
+ No ffprobe data available.
+
+ )
+ ) : (
+
+
+ Select a video file to view ffprobe details.
+
+
+ )
+ ) : (
+
+
+ Select a file in Browser to view ffprobe details.
+
+
+ )}
+
+
+
+ {selectedPath && templates && templates.length > 0 ? (
+
+
+
+ Job template
+
+ updateBrowserState({ selectedJob: value })
+ }
+ >
+
+
+
+
+ {templates.map((tpl) => (
+
+ {tpl.name}
+
+ ))}
+
+
-
-
-
- {selectedPath ? (
- isVideoFile(selectedPath) ? (
- ffprobeError ? (
-
-
- {String(ffprobeError)}
-
-
- ) : ffprobeLoading && !ffprobeData ? (
- Loading ffprobe data...
- ) : ffprobeData ? (
-
- ) : (
- No ffprobe data available.
- )
- ) : (
-
- Select a video file to view ffprobe details.
-
- )
- ) : (
-
- Select a file in Browser to view ffprobe details.
-
- )}
-
-
-
- {selectedPath && templates && templates.length > 0 ? (
-
-
-
- Job template
-
- updateBrowserState({ selectedJob: value })
- }
- >
-
-
-
-
- {templates.map((tpl) => (
-
- {tpl.name}
-
- ))}
-
-
-
-
-
- runJob.mutate({
- jobKey: selectedJob,
- path: selectedPath,
- })
- }
- >
- Run job
-
- {selectedTemplate && (
-
- {selectedTemplate.description}
-
- )}
-
+
+
+ runJob.mutate({ jobKey: selectedJob, path: selectedPath })
+ }
+ >
+ Run job
+
+ {selectedTemplate && (
+
+ {selectedTemplate.description}
- {runJob.data && (
-
- {`Exit: ${runJob.data.exit_status}`}
- {"\n"}
- {runJob.data.stdout}
- {runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
-
- )}
-
- ) : (
-
Select a file in Browser to run jobs.
- )}
-
+ )}
+
+
+ {runJob.data && (
+
+ {`Exit: ${runJob.data.exit_status}`}
+ {"\n"}
+ {runJob.data.stdout}
+ {runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
+
+ )}
) : (
- No file-capable machines are configured yet.
+ Select a file in Browser to run jobs.
-
- navigateToSettings("/settings")}
- className="mobile-touch-target"
- >
- Open Settings
-
-
)}
-
+
);
}
diff --git a/frontend/src/components/BackupsPage.tsx b/frontend/src/pages/service-tabs/JobsTab.tsx
similarity index 64%
rename from frontend/src/components/BackupsPage.tsx
rename to frontend/src/pages/service-tabs/JobsTab.tsx
index cee63c0..e33f525 100644
--- a/frontend/src/components/BackupsPage.tsx
+++ b/frontend/src/pages/service-tabs/JobsTab.tsx
@@ -1,3 +1,15 @@
+/**
+ * JobsTab — operational content for the backups service page.
+ *
+ * Lifted from the old top-level `components/BackupsPage.tsx`. The three
+ * sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
+ *
+ * NOTE: the backup hooks currently query globally (no service_id filter).
+ * The backend gained `service_id` attribution in Slice 3, but the hooks don't
+ * yet accept a serviceId param. This tab shows ALL backups data for now;
+ * per-instance scoping by `instance.id` is a follow-up once the hooks gain the
+ * parameter.
+ */
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -5,12 +17,16 @@ import {
useBackupAlerts,
useBackupJobs,
useBackupRuns,
-} from "../hooks/useBackups";
-import BackupAlertsTable from "./BackupAlertsTable";
-import BackupJobsTable from "./BackupJobsTable";
-import BackupRunsTable from "./BackupRunsTable";
+} from "../../hooks/useBackups";
+import BackupAlertsTable from "../../components/BackupAlertsTable";
+import BackupJobsTable from "../../components/BackupJobsTable";
+import BackupRunsTable from "../../components/BackupRunsTable";
+import type { ServiceInstance } from "../../types";
-export default function BackupsPage() {
+export function JobsTab({ instance }: { instance: ServiceInstance }) {
+ // instance.id is not yet used — backup hooks query globally (see file
+ // docstring). Per-instance scoping is a follow-up.
+ void instance;
const [tab, setTab] = useState("jobs");
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
@@ -35,7 +51,6 @@ export default function BackupsPage() {
return (
-
Backups
Jobs
diff --git a/frontend/src/pages/service-tabs/LinksTab.tsx b/frontend/src/pages/service-tabs/LinksTab.tsx
new file mode 100644
index 0000000..07ea98d
--- /dev/null
+++ b/frontend/src/pages/service-tabs/LinksTab.tsx
@@ -0,0 +1,194 @@
+/**
+ * Grafana Links tab (spec R2.4, R8.2).
+ *
+ * Lifts the Grafana deep-link content from the old cross-service
+ * ObservabilityPage into an instance-scoped tab. Shows service health + the
+ * configured Grafana deep-links (node-exporter dashboard, Loki logs per
+ * machine).
+ *
+ * The hooks (useGrafanaStatus, useMonitoringMachines) are global /
+ * first-configured for now. Wiring `instance.id` into the status hook is a
+ * follow-up. The machine links use the configured Grafana base_url from the
+ * instance's config.
+ */
+import { useMemo, useState } from "react";
+import { Link } from "react-router-dom";
+import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
+import {
+ useGrafanaStatus,
+ useMonitoringMachines,
+} from "../../hooks/useObservability";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Skeleton } from "@/components/ui/skeleton";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import type { ServiceInstance } from "../../types";
+
+function GrafanaLinkCard({
+ title,
+ description,
+ href,
+}: {
+ title: string;
+ description: string;
+ href: string;
+}) {
+ return (
+
+ );
+}
+
+export function LinksTab({ instance }: { instance: ServiceInstance }) {
+ const { data: status, isLoading, error } = useGrafanaStatus();
+ const { data: machines = [], isLoading: machinesLoading } =
+ useMonitoringMachines();
+ const [selectedMachineId, setSelectedMachineId] = useState("");
+
+ const grafanaBaseUrl =
+ (instance.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 || !grafanaBaseUrl) return "";
+ const inst = `${selectedMachine.host || "localhost"}:9100`;
+ return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
+ }, [selectedMachine, grafanaBaseUrl]);
+
+ const logsUrl = useMemo(() => {
+ if (!selectedMachine || !grafanaBaseUrl) return "";
+ const container =
+ selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
+ return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
+ JSON.stringify({
+ datasource: "Loki",
+ queries: [{ refId: "A", expr: `{container="${container}"}` }],
+ range: { from: "now-1h", to: "now" },
+ }),
+ )}`;
+ }, [selectedMachine, grafanaBaseUrl]);
+
+ const statusDetail = status?.up
+ ? status.version
+ ? `version ${status.version}`
+ : "reachable"
+ : isLoading
+ ? "checking…"
+ : error
+ ? "unreachable"
+ : "not configured";
+
+ return (
+
+
+
+ Grafana {statusDetail}
+
+
+ {error && (
+
+ Failed to reach Grafana
+ {error.message}
+
+ )}
+
+
+
+
+
+ Machine Dashboard
+
+ {machines.length > 0 ? (
+
+
+
+
+
+ {machines.map((machine) => (
+
+ {machine.name}
+
+ ))}
+
+
+ ) : null}
+
+
+ {isLoading ? (
+
+ ) : selectedMachine && grafanaBaseUrl ? (
+ <>
+
+
+ >
+ ) : !grafanaBaseUrl ? (
+
+
+
No Grafana base URL configured
+
+ Add a Grafana service instance to enable deep-links to
+ dashboards and logs.
+
+
+ Open Services
+
+
+ ) : (
+
+
+
No machine selected
+
+ Add monitoring machines in Settings to see Grafana drill-down
+ links.
+
+
+ Open Settings
+
+
+ )}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/Media.tsx b/frontend/src/pages/service-tabs/MediaTab.tsx
similarity index 76%
rename from frontend/src/pages/Media.tsx
rename to frontend/src/pages/service-tabs/MediaTab.tsx
index cdf8c8a..74097a7 100644
--- a/frontend/src/pages/Media.tsx
+++ b/frontend/src/pages/service-tabs/MediaTab.tsx
@@ -1,5 +1,13 @@
+/**
+ * MediaTab — operational content for the Jellyfin service page.
+ *
+ * Lifted from the old top-level `pages/Media.tsx`. The service-id source is
+ * changed from URL search params to the `instance` prop (the active service
+ * instance selected on the service page). The service-selection dropdown and
+ * its URL-sync effect are removed; everything else is preserved verbatim.
+ */
import { useEffect, useMemo, useState } from "react";
-import { useNavigate, useSearchParams } from "react-router-dom";
+import { useNavigate } from "react-router-dom";
import type {
ColumnDef,
OnChangeFn,
@@ -34,12 +42,14 @@ import {
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
-} from "../hooks/useMedia";
-import { usePersistentState } from "../hooks/usePersistentState";
-import { useIsMobile } from "../hooks/useIsMobile";
-import type { MediaItem } from "../types";
-import { useServiceInstances } from "../hooks/useServices";
-import { useCounts, useLibraries } from "../hooks/useDashboard";
+} from "../../hooks/useMedia";
+import { usePersistentState } from "../../hooks/usePersistentState";
+import { useIsMobile } from "../../hooks/useIsMobile";
+import type { MediaItem, ServiceInstance } from "../../types";
+import { useCounts, useLibraries } from "../../hooks/useDashboard";
+import { useServiceInstances } from "../../hooks/useServices";
+
+// --- Format helpers (lifted verbatim from Media.tsx) ---
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
@@ -52,10 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
return `${secs}s`;
}
-// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
-// TanStack table instance stays stable — an unstable columns array drops the
-// controlled selection/visibility state (7a discovery). Visibility-only parity
-// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
+// --- Column definitions (lifted verbatim) ---
+
const mediaColumns: ColumnDef[] = [
{ accessorKey: "title", header: "Title" },
{ accessorKey: "series", header: "Series" },
@@ -74,25 +82,15 @@ const mediaColumns: ColumnDef[] = [
{ accessorKey: "path", header: "Path" },
];
-// Stable path-derived identity so row selection survives server-driven paging
-// (design §3.4): the id is the item's filesystem path, which is stable across
-// limit/offset page changes.
function getMediaRowId(row: MediaItem): string {
return row.path;
}
-// Mobile card fields (spec R3.2): the card picks the 3-5 most important fields.
-// Title is the primary identifier; size/HDR/library/year give the at-a-glance
-// tech + context info a user scanning the library on a phone needs. Runtime,
-// bitrate, resolution, codec etc. live on the desktop table only.
+// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
const mediaCardFields: MobileCardField[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
- {
- key: "hdr",
- label: "HDR",
- render: (r) => r.hdr || "-",
- },
+ { key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
{ key: "library", label: "Library", render: (r) => r.library || "-" },
{
key: "year",
@@ -101,12 +99,10 @@ const mediaCardFields: MobileCardField[] = [
},
];
-// Mobile pagination uses the shared TablePagination component
-// (frontend/src/components/ui/table-pagination.tsx).
+// --- Persistent filter/sort/pagination state (lifted verbatim) ---
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
-// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
const MOBILE_HIDDEN_COLUMNS = [
"series",
"season",
@@ -159,6 +155,8 @@ function usePrefersSmallScreen(): boolean {
return small;
}
+// --- Small UI helpers (lifted verbatim) ---
+
function FilterSelect({
id,
label,
@@ -191,9 +189,6 @@ function FilterSelect({
);
}
-// LinearProgress → Progress: determinate value drives the shadcn Progress; the
-// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
-// "indeterminate" affordance for unknown build progress.
function BuildProgress({ value }: { value: number | null }) {
if (value == null) {
return (
@@ -203,31 +198,26 @@ function BuildProgress({ value }: { value: number | null }) {
return ;
}
-export function Media() {
+// --- Component ---
+
+export function MediaTab({ instance }: { instance: ServiceInstance }) {
const navigate = useNavigate();
- const [searchParams, setSearchParams] = useSearchParams();
+ const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
const isSmall = usePrefersSmallScreen();
const isMobile = useIsMobile();
- 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);
- const { data: status } = useMediaStatus(selectedServiceId || undefined);
- const buildIndex = useBuildIndex(selectedServiceId || undefined);
- const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
- const forceStopBuildIndex = useForceStopBuildIndex(
- selectedServiceId || undefined,
- );
+ const serviceId = instance.id;
+
+ const { data: counts } = useCounts(serviceId);
+ const { data: libraries } = useLibraries(serviceId);
+ const { data: status } = useMediaStatus(serviceId);
+ const buildIndex = useBuildIndex(serviceId);
+ const stopBuildIndex = useStopBuildIndex(serviceId);
+ const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
const [rawMediaState, setMediaState] = usePersistentState(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
- // Backward-compat: merge defaults so older persisted state (pre-7b shape,
- // without pageSize/columnVisibility) never yields undefined fields.
const mediaState: MediaTabState = {
...defaultMediaTabState(),
...rawMediaState,
@@ -239,19 +229,6 @@ export function Media() {
const [rowSelection, setRowSelection] = useState({});
- useEffect(() => {
- if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
- setSearchParams(
- (current) => {
- const next = new URLSearchParams(current);
- next.set("jellyfin_service_id", selectedServiceId);
- return next;
- },
- { replace: true },
- );
- }
- }, [searchParams, selectedServiceId, setSearchParams]);
-
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
search,
@@ -260,12 +237,10 @@ export function Media() {
sort_order: sortOrder,
limit: pageSize,
offset,
- jellyfinServiceId: selectedServiceId || undefined,
+ jellyfinServiceId: serviceId,
enabled: status?.exists ?? false,
});
- // Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
- // persistent media state and drive useMediaQuery { limit, offset }.
const pageIndex = Math.floor(offset / pageSize);
const pagination: PaginationState = { pageIndex, pageSize };
@@ -275,8 +250,6 @@ export function Media() {
? updater({ pageIndex, pageSize })
: updater;
const nextPageSize = next.pageSize || pageSize;
- // Restart at page 0 whenever the page size changes (keeps offset sane
- // under server-driven paging).
const nextOffset =
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
setMediaState((current) => ({
@@ -296,9 +269,6 @@ export function Media() {
});
};
- // On small screens force the same set of columns hidden as the pre-rework
- // DataGrid `columnVisibilityModel` mobile override; on desktop the user
- // toggles freely (the toggleable set still equals the locked 15).
const effectiveColumnVisibility = useMemo(() => {
const base = mediaState.columnVisibility ?? {};
if (!isSmall) return base;
@@ -307,10 +277,15 @@ export function Media() {
return merged;
}, [mediaState.columnVisibility, isSmall]);
- // Preserved exactly from the DataGrid onRowClick: opens the file browser at
- // the item's path.
const handleRowClick = (row: MediaItem) => {
- navigate(`/files?path=${encodeURIComponent(row.path)}`);
+ // Navigate to the ssh_tasks service page with the path query param.
+ // If an ssh_tasks instance exists, open its Files tab; otherwise land
+ // on the ssh_tasks type page (empty state / ServiceTypePage resolver).
+ const sshInstance = sshServices.find((s) => s.enabled);
+ const base = sshInstance
+ ? `/services/ssh_tasks/${sshInstance.id}`
+ : "/services/ssh_tasks";
+ navigate(`${base}?path=${encodeURIComponent(row.path)}`);
};
const total = queryResult?.total ?? 0;
@@ -346,35 +321,6 @@ export function Media() {
return (
-
Jellyfin
-
- Service
-
- setSearchParams(
- (current) => {
- const next = new URLSearchParams(current);
- next.set("jellyfin_service_id", value);
- return next;
- },
- { replace: true },
- )
- }
- >
-
-
-
-
- {jellyfinServices.map((service) => (
-
- {service.name}
-
- ))}
-
-
-
-
{status?.exists ? (
Index: {status.item_count.toLocaleString()} items
@@ -397,7 +343,7 @@ export function Media() {
)}
-
buildIndex.mutate()}
disabled={
@@ -408,7 +354,7 @@ export function Media() {
{buildRunning && (
<>
-
stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
@@ -419,7 +365,7 @@ export function Media() {
forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
diff --git a/frontend/src/pages/service-tabs/MessagingTab.tsx b/frontend/src/pages/service-tabs/MessagingTab.tsx
new file mode 100644
index 0000000..34c071b
--- /dev/null
+++ b/frontend/src/pages/service-tabs/MessagingTab.tsx
@@ -0,0 +1,129 @@
+/** MessagingTab — compose email to Authentik users via the mail queue. */
+import { useState } from "react";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Textarea } from "@/components/ui/textarea";
+import {
+ useAuthentikUsers,
+ useSendAuthentikMessage,
+} from "../../hooks/useAuthentik";
+import type { ServiceInstance } from "../../types";
+
+const DEFAULT_BODY = "Hello,
Best, Manage
";
+
+export function MessagingTab({ instance }: { instance: ServiceInstance }) {
+ const [search, setSearch] = useState("");
+ const [selectedEmails, setSelectedEmails] = useState>(new Set());
+ const [subject, setSubject] = useState("");
+ const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
+
+ const { data } = useAuthentikUsers(instance.id, {
+ search,
+ page: 1,
+ page_size: 100,
+ });
+ const sendMessage = useSendAuthentikMessage(instance.id);
+
+ const users = (data?.items ?? []).filter((u) => u.email);
+ const error = data?.error;
+
+ function toggleEmail(email: string) {
+ setSelectedEmails((prev) => {
+ const next = new Set(prev);
+ if (next.has(email)) next.delete(email);
+ else next.add(email);
+ return next;
+ });
+ }
+
+ function handleSend() {
+ if (!subject.trim() || selectedEmails.size === 0) return;
+ sendMessage.mutate({
+ recipient_emails: Array.from(selectedEmails),
+ subject: subject.trim(),
+ html_body: htmlBody,
+ });
+ }
+
+ const canSend =
+ subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+ {sendMessage.data ? (
+
+
+ {sendMessage.data.status === "queued"
+ ? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
+ : `Error: ${sendMessage.data.error ?? "unknown"}`}
+
+
+ ) : null}
+
+
+
Find recipients
+
setSearch(e.target.value)}
+ className="max-w-md"
+ />
+ {users.length > 0 ? (
+
+ {users.slice(0, 20).map((user) => (
+ toggleEmail(user.email)}
+ >
+ {user.name || user.username}
+
+ ))}
+
+ ) : null}
+ {selectedEmails.size > 0 ? (
+
+ {selectedEmails.size} recipient
+ {selectedEmails.size === 1 ? "" : "s"} selected.
+
+ ) : null}
+
+
+
+ Subject
+ setSubject(e.target.value)}
+ />
+
+
+
+ Message (HTML)
+
+
+
+
+ {sendMessage.isPending ? "Sending…" : "Send message"}
+
+
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/MetricsTab.tsx b/frontend/src/pages/service-tabs/MetricsTab.tsx
new file mode 100644
index 0000000..3adec82
--- /dev/null
+++ b/frontend/src/pages/service-tabs/MetricsTab.tsx
@@ -0,0 +1,117 @@
+/**
+ * Prometheus Metrics tab (spec R2.4, R8.2).
+ *
+ * Lifts the Prometheus status + targets content from the old cross-service
+ * ObservabilityPage into an instance-scoped tab. Shows service health and
+ * the Node Exporter scrape-targets list.
+ *
+ * The hooks (usePrometheusStatus, usePrometheusTargets) are global /
+ * first-configured for now. Wiring `instance.id` is a follow-up.
+ */
+import { Radio } from "lucide-react";
+import {
+ usePrometheusStatus,
+ usePrometheusTargets,
+} from "../../hooks/useObservability";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
+import { Skeleton } from "@/components/ui/skeleton";
+import type { PrometheusTarget, ServiceInstance } from "../../types";
+
+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}
+
+ ))}
+
+ )}
+
+ ))}
+
+ );
+}
+
+export function MetricsTab({ instance }: { instance: ServiceInstance }) {
+ // Global / first-configured hooks for now; instance.id scoping is a
+ // follow-up (see file docstring).
+ void instance;
+
+ const {
+ data: status,
+ isLoading: statusLoading,
+ error: statusError,
+ } = usePrometheusStatus();
+ const {
+ data: targets,
+ isLoading: targetsLoading,
+ error: targetsError,
+ } = usePrometheusTargets();
+
+ const statusDetail = status?.up
+ ? status.version
+ ? `version ${status.version}`
+ : "reachable"
+ : statusLoading
+ ? "checking…"
+ : "unreachable";
+
+ return (
+
+
+
+ Prometheus {statusDetail}
+
+
+ {statusError && (
+
+ Failed to reach Prometheus
+ {statusError.message}
+
+ )}
+
+
+
+
+
+ Node Exporter Targets ({targets?.length ?? 0})
+
+
+
+ {targetsLoading ? (
+
+
+
+
+ ) : !targets || targets.length === 0 ? (
+
+
+
No Node Exporter targets
+
+ Enable Node Exporter on an SSH machine in Settings to populate
+ Prometheus scrape targets.
+
+
+ ) : (
+
+ )}
+
+
+
+ {targetsError && (
+
+ Failed to load targets
+ {targetsError.message}
+
+ )}
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/RequestsTab.tsx b/frontend/src/pages/service-tabs/RequestsTab.tsx
new file mode 100644
index 0000000..1228650
--- /dev/null
+++ b/frontend/src/pages/service-tabs/RequestsTab.tsx
@@ -0,0 +1,64 @@
+/**
+ * RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
+ *
+ * Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
+ * jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
+ * configured, it shows the URL and a placeholder (no requests backend endpoint
+ * exists yet — building one is out of scope for this slice). When not
+ * configured, it shows an empty-state CTA directing the user to add the fields
+ * to the Jellyfin config.
+ */
+import type { ServiceInstance } from "../../types";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+import { ExternalLink } from "lucide-react";
+
+export function RequestsTab({ instance }: { instance: ServiceInstance }) {
+ const jellyseerrUrl = String(
+ (instance.config as Record).jellyseerr_url ?? "",
+ ).trim();
+ const jellyseerrApiKey = String(
+ (instance.config as Record).jellyseerr_api_key ?? "",
+ ).trim();
+
+ if (!jellyseerrUrl || !jellyseerrApiKey) {
+ return (
+
+
+ Jellyseerr is not configured for this Jellyfin instance. Add
+
+ jellyseerr_url
+
+ and
+
+ jellyseerr_api_key
+
+ to the Jellyfin config (Config tab) to enable request management.
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ Jellyseerr is configured. The requests view will show pending and
+ recently fulfilled media requests. (This surface is under
+ development.)
+
+
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/UsersTab.tsx b/frontend/src/pages/service-tabs/UsersTab.tsx
new file mode 100644
index 0000000..e8840dc
--- /dev/null
+++ b/frontend/src/pages/service-tabs/UsersTab.tsx
@@ -0,0 +1,136 @@
+/** UsersTab — Authentik user directory for the Authentik service page. */
+import { useState } from "react";
+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 {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@/components/ui/table";
+import type { ServiceInstance } from "../../types";
+import { useAuthentikUsers } from "../../hooks/useAuthentik";
+
+const PAGE_SIZE = 25;
+
+export function UsersTab({ instance }: { instance: ServiceInstance }) {
+ const [search, setSearch] = useState("");
+ const [page, setPage] = useState(1);
+ const [committedSearch, setCommittedSearch] = useState("");
+
+ const { data, isLoading } = useAuthentikUsers(instance.id, {
+ search: committedSearch,
+ page,
+ page_size: PAGE_SIZE,
+ });
+
+ const error = data?.error;
+ const users = data?.items ?? [];
+ const total = data?.total ?? 0;
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
+
+ function handleSearch() {
+ setPage(1);
+ setCommittedSearch(search);
+ }
+
+ return (
+
+ {error ? (
+
+ {error}
+
+ ) : null}
+
+
+ setSearch(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === "Enter") handleSearch();
+ }}
+ className="max-w-xs"
+ />
+
+ Search
+
+
+
+
+
+
+
+ Name
+ Username
+ Email
+ Status
+
+
+
+ {isLoading && users.length === 0 ? (
+
+
+ Loading…
+
+
+ ) : users.length === 0 ? (
+
+
+ No users found.
+
+
+ ) : (
+ users.map((user) => (
+
+
+ {user.name || "—"}
+
+ {user.username}
+
+ {user.email || "—"}
+
+
+
+ {user.is_active ? "Active" : "Inactive"}
+
+
+
+ ))
+ )}
+
+
+
+
+ {total > 0 ? (
+
+
+ {total} user{total === 1 ? "" : "s"} · Page {page} of {totalPages}
+
+
+ setPage((p) => Math.max(1, p - 1))}
+ disabled={page <= 1}
+ >
+ Previous
+
+ setPage((p) => Math.min(totalPages, p + 1))}
+ disabled={page >= totalPages}
+ >
+ Next
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/frontend/src/pages/service-tabs/__tests__/ActionsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/ActionsTab.test.tsx
new file mode 100644
index 0000000..2760ddf
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/ActionsTab.test.tsx
@@ -0,0 +1,59 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { ActionsTab } from "../ActionsTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "ssh-1",
+ service_type: "ssh_tasks",
+ name: "Storage Server",
+ config: {},
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useSettings", () => ({
+ useTasks: () => ({
+ data: [
+ {
+ id: "t1",
+ name: "Disk usage",
+ task_type: "shell",
+ content: "df -h",
+ enabled: true,
+ default_service_id: "",
+ notes: "",
+ },
+ ],
+ }),
+ useTaskRuns: () => ({ data: { items: [] } }),
+ useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
+ useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
+ useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
+}));
+
+function renderTab() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("ActionsTab", () => {
+ it("renders the saved-actions rail and task detail", () => {
+ renderTab();
+ expect(screen.getByText("Saved actions")).toBeInTheDocument();
+ expect(screen.getByText("Disk usage")).toBeInTheDocument();
+ });
+
+ it("renders the Add action button", () => {
+ renderTab();
+ expect(
+ screen.getByRole("button", { name: "Add action" }),
+ ).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx
new file mode 100644
index 0000000..ec95414
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/AlertsTab.test.tsx
@@ -0,0 +1,70 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { AlertsTab } from "../AlertsTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "am-1",
+ service_type: "alertmanager",
+ name: "Main Alertmanager",
+ config: { base_url: "https://am.example.com", timeout_seconds: 5 },
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useObservability", () => ({
+ useAlertmanagerAlerts: () => ({
+ data: {
+ total: 2,
+ by_severity: { critical: 1, warning: 1 },
+ alerts: [
+ {
+ name: "DiskFull",
+ severity: "critical",
+ category: "disk",
+ job_name: "node",
+ summary: "Disk is almost full",
+ description: "Disk usage above 90%",
+ active_since: "2026-06-26T10:00:00Z",
+ state: "firing",
+ labels: { instance: "node1" },
+ },
+ {
+ name: "HighCpu",
+ severity: "warning",
+ category: "cpu",
+ job_name: "node",
+ summary: "High CPU usage",
+ description: "",
+ active_since: "2026-06-26T09:00:00Z",
+ state: "firing",
+ labels: {},
+ },
+ ],
+ },
+ isLoading: false,
+ error: null,
+ }),
+ useAlertmanagerStatus: () => ({
+ data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+describe("AlertsTab", () => {
+ it("renders the alert count and alert names", () => {
+ render( );
+ expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
+ expect(screen.getByText("DiskFull")).toBeInTheDocument();
+ expect(screen.getByText("HighCpu")).toBeInTheDocument();
+ });
+
+ it("renders severity badges", () => {
+ render( );
+ expect(screen.getByText("critical")).toBeInTheDocument();
+ expect(screen.getByText("warning")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/FilesTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/FilesTab.test.tsx
new file mode 100644
index 0000000..f5b20e9
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/FilesTab.test.tsx
@@ -0,0 +1,63 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { FilesTab } from "../FilesTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "ssh-1",
+ service_type: "ssh_tasks",
+ name: "Storage Server",
+ config: {},
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useFiles", () => ({
+ useDirectoryListing: () => ({
+ data: {
+ count: 2,
+ entries: [
+ { name: "movies", type: "d", size: 0, mtime: 1700000000 },
+ { name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
+ ],
+ },
+ isLoading: false,
+ error: null,
+ refetch: vi.fn(),
+ }),
+ useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
+ useJobTemplates: () => ({ data: [] }),
+ useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
+}));
+
+vi.mock("../../../hooks/usePersistentState", () => ({
+ usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
+ initial(),
+ vi.fn(),
+ ]),
+}));
+
+function renderTab(path = "/services/ssh_tasks/ssh-1") {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("FilesTab", () => {
+ it("renders the directory listing with instance-scoped hooks", () => {
+ renderTab();
+ expect(screen.getByText("movies")).toBeInTheDocument();
+ expect(screen.getByText("video.mkv")).toBeInTheDocument();
+ });
+
+ it("renders the path bar and browser section", () => {
+ renderTab();
+ expect(screen.getByText("Browser")).toBeInTheDocument();
+ expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/JobsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/JobsTab.test.tsx
new file mode 100644
index 0000000..dec3b38
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/JobsTab.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { JobsTab } from "../JobsTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "bkp-1",
+ service_type: "backups",
+ name: "Main Backups",
+ config: { ingestion_label: "default" },
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useBackups", () => ({
+ useBackupJobs: () => ({
+ data: [
+ {
+ id: "job-1",
+ name: "nightly",
+ source: "/data",
+ target: "s3://bucket",
+ schedule_interval_seconds: 86400,
+ created_at: 1_700_000_000,
+ },
+ ],
+ isLoading: false,
+ }),
+ useBackupRuns: () => ({
+ data: [],
+ isLoading: false,
+ }),
+ useBackupAlerts: () => ({
+ data: [],
+ isLoading: false,
+ }),
+ useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
+}));
+
+function renderTab() {
+ return render( );
+}
+
+describe("JobsTab", () => {
+ it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
+ renderTab();
+ expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
+ expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
+ });
+
+ it("renders the backup job name in the Jobs tab", () => {
+ renderTab();
+ expect(screen.getByText("nightly")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx
new file mode 100644
index 0000000..8f9d4b6
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/LinksTab.test.tsx
@@ -0,0 +1,58 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { LinksTab } from "../LinksTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "graf-1",
+ service_type: "grafana",
+ name: "Main Grafana",
+ config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useObservability", () => ({
+ useGrafanaStatus: () => ({
+ data: {
+ up: true,
+ version: "11.0.0",
+ service_id: "graf-1",
+ name: "Main Grafana",
+ },
+ isLoading: false,
+ error: null,
+ }),
+ useMonitoringMachines: () => ({
+ data: [
+ {
+ id: "m1",
+ name: "storage",
+ mode: "ssh",
+ host: "10.0.0.5",
+ enabled: true,
+ services: [],
+ port: 22,
+ username: "admin",
+ },
+ ],
+ isLoading: false,
+ }),
+}));
+
+describe("LinksTab", () => {
+ it("renders the Grafana version and machine dashboard links", () => {
+ render( );
+ expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
+ expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
+ expect(screen.getByText(/storage logs/)).toBeInTheDocument();
+ });
+
+ it("renders open-in-grafana link buttons", () => {
+ render( );
+ const links = screen.getAllByText("Open in Grafana");
+ expect(links).toHaveLength(2);
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx
new file mode 100644
index 0000000..9af0d2d
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/MediaTab.test.tsx
@@ -0,0 +1,82 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import { MediaTab } from "../MediaTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "jellyfin-1",
+ service_type: "jellyfin",
+ name: "Main Jellyfin",
+ config: { base_url: "https://jf.example.com", user_id: "u1" },
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useMedia", () => ({
+ useMediaStatus: () => ({
+ data: { exists: true, item_count: 42, updated_at_label: "today" },
+ }),
+ useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
+ useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
+ useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
+ useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
+}));
+
+vi.mock("../../../hooks/useDashboard", () => ({
+ useCounts: () => ({
+ data: { movies: 10, series: 5, episodes: 30 },
+ }),
+ useLibraries: () => ({ data: [{ id: "lib1" }] }),
+}));
+
+vi.mock("../../../hooks/useServices", () => ({
+ useServiceInstances: () => ({ data: [] }),
+}));
+
+vi.mock("../../../hooks/usePersistentState", () => ({
+ usePersistentState: () => [
+ {
+ search: "",
+ types: "Movie,Episode",
+ hdrFilter: "All",
+ sortKey: "title",
+ sortOrder: "Ascending",
+ offset: 0,
+ pageSize: 100,
+ columnVisibility: {},
+ },
+ vi.fn(),
+ ],
+}));
+
+function renderTab() {
+ return render(
+
+
+ ,
+ );
+}
+
+describe("MediaTab", () => {
+ it("renders index status and build controls with instance-scoped data", () => {
+ renderTab();
+ expect(screen.getByText(/42 items/)).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Build index/i }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders library counts", () => {
+ renderTab();
+ expect(screen.getByText(/10 movies/)).toBeInTheDocument();
+ expect(screen.getByText(/5 series/)).toBeInTheDocument();
+ });
+
+ it("renders the filter card with search input", () => {
+ renderTab();
+ expect(screen.getByLabelText("Search")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/MessagingTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MessagingTab.test.tsx
new file mode 100644
index 0000000..d1fe716
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/MessagingTab.test.tsx
@@ -0,0 +1,55 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MessagingTab } from "../MessagingTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "auth-1",
+ service_type: "authentik",
+ name: "Main Authentik",
+ config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
+ secrets_set: { api_token: true },
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useAuthentik", () => ({
+ useAuthentikUsers: vi.fn(() => ({
+ data: {
+ items: [
+ {
+ pk: 1,
+ username: "alice",
+ name: "Alice",
+ email: "alice@example.com",
+ is_active: true,
+ },
+ ],
+ total: 1,
+ page: 1,
+ page_size: 100,
+ },
+ })),
+ useSendAuthentikMessage: vi.fn(() => ({
+ mutate: vi.fn(),
+ isPending: false,
+ data: undefined,
+ })),
+}));
+
+describe("MessagingTab", () => {
+ it("renders the compose form (subject, body, send)", () => {
+ render( );
+ expect(screen.getByLabelText("Subject")).toBeInTheDocument();
+ expect(screen.getByLabelText("Message (HTML)")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: "Send message" }),
+ ).toBeInTheDocument();
+ });
+
+ it("renders recipient toggle buttons from the directory", () => {
+ render( );
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx
new file mode 100644
index 0000000..e2edce6
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/MetricsTab.test.tsx
@@ -0,0 +1,51 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { MetricsTab } from "../MetricsTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "prom-1",
+ service_type: "prometheus",
+ name: "Main Prometheus",
+ config: { base_url: "https://prom.example.com", timeout_seconds: 10 },
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useObservability", () => ({
+ usePrometheusStatus: () => ({
+ data: {
+ up: true,
+ version: "2.52.0",
+ service_id: "prom-1",
+ name: "Main Prometheus",
+ },
+ isLoading: false,
+ error: null,
+ }),
+ usePrometheusTargets: () => ({
+ data: [
+ {
+ targets: ["10.0.0.5:9100"],
+ labels: { instance: "storage", job: "node_exporter" },
+ },
+ ],
+ isLoading: false,
+ error: null,
+ }),
+}));
+
+describe("MetricsTab", () => {
+ it("renders the Prometheus version and target list", () => {
+ render( );
+ expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
+ expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
+ });
+
+ it("renders the target count in the heading", () => {
+ render( );
+ expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx
new file mode 100644
index 0000000..6e253be
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx
@@ -0,0 +1,59 @@
+import { describe, it, expect } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { RequestsTab } from "../RequestsTab";
+import type { ServiceInstance } from "../../../types";
+
+function makeInstance(config: Record): ServiceInstance {
+ return {
+ id: "jellyfin-1",
+ service_type: "jellyfin",
+ name: "Main Jellyfin",
+ config,
+ secrets_set: {},
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+ };
+}
+
+describe("RequestsTab", () => {
+ it("shows empty-state CTA when Jellyseerr is not configured", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/not configured/i)).toBeInTheDocument();
+ expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
+ });
+
+ it("shows the configured Jellyseerr URL when both fields are set", () => {
+ render(
+ ,
+ );
+ expect(
+ screen.getByText("https://requests.example.com"),
+ ).toBeInTheDocument();
+ expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
+ });
+
+ it("shows empty-state when only URL is set (missing api_key)", () => {
+ render(
+ ,
+ );
+ expect(screen.getByText(/not configured/i)).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/__tests__/UsersTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/UsersTab.test.tsx
new file mode 100644
index 0000000..1f90c51
--- /dev/null
+++ b/frontend/src/pages/service-tabs/__tests__/UsersTab.test.tsx
@@ -0,0 +1,61 @@
+import { describe, it, expect, vi } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { UsersTab } from "../UsersTab";
+import type { ServiceInstance } from "../../../types";
+
+const instance: ServiceInstance = {
+ id: "auth-1",
+ service_type: "authentik",
+ name: "Main Authentik",
+ config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
+ secrets_set: { api_token: true },
+ enabled: true,
+ created_at: 1_700_000_000,
+ updated_at: 1_700_000_000,
+};
+
+vi.mock("../../../hooks/useAuthentik", () => ({
+ useAuthentikUsers: vi.fn(() => ({
+ data: {
+ items: [
+ {
+ pk: 1,
+ username: "alice",
+ name: "Alice",
+ email: "alice@example.com",
+ is_active: true,
+ },
+ {
+ pk: 2,
+ username: "bob",
+ name: "Bob",
+ email: "bob@example.com",
+ is_active: false,
+ },
+ ],
+ total: 2,
+ page: 1,
+ page_size: 25,
+ },
+ isLoading: false,
+ })),
+}));
+
+describe("UsersTab", () => {
+ it("renders the directory table with users", () => {
+ render( );
+ expect(screen.getByText("Alice")).toBeInTheDocument();
+ expect(screen.getByText("bob")).toBeInTheDocument();
+ expect(screen.getByText("alice@example.com")).toBeInTheDocument();
+ expect(screen.getByText("Active")).toBeInTheDocument();
+ expect(screen.getByText("Inactive")).toBeInTheDocument();
+ });
+
+ it("renders search input and pagination", () => {
+ render( );
+ expect(screen.getByPlaceholderText("Search users…")).toBeInTheDocument();
+ expect(screen.getByText(/2 users/)).toBeInTheDocument();
+ expect(screen.getByText("Previous")).toBeInTheDocument();
+ expect(screen.getByText("Next")).toBeInTheDocument();
+ });
+});
diff --git a/frontend/src/pages/service-tabs/index.ts b/frontend/src/pages/service-tabs/index.ts
new file mode 100644
index 0000000..d169055
--- /dev/null
+++ b/frontend/src/pages/service-tabs/index.ts
@@ -0,0 +1,66 @@
+/**
+ * Per-type content-tab descriptors for the service page skeleton.
+ *
+ * Each entry names a tab and its component. The service page renders
+ * `[Overview, ...contentTabs(type), Widgets, Config]`.
+ */
+import type { ComponentType } from "react";
+import type { ServiceInstance } from "../../types";
+import { OverviewTab } from "./stubs";
+import { AlertsTab } from "./AlertsTab";
+import { LinksTab } from "./LinksTab";
+import { MetricsTab } from "./MetricsTab";
+import { MediaTab } from "./MediaTab";
+import { RequestsTab } from "./RequestsTab";
+import { FilesTab } from "./FilesTab";
+import { ActionsTab } from "./ActionsTab";
+import { JobsTab } from "./JobsTab";
+import { UsersTab } from "./UsersTab";
+import { MessagingTab } from "./MessagingTab";
+
+export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
+
+export interface ContentTab {
+ label: string;
+ Component: ServiceTabComponent;
+}
+
+/** Overview tab (shared across all service types). */
+export const OVERVIEW_TAB: ContentTab = {
+ label: "Overview",
+ Component: OverviewTab,
+};
+
+/**
+ * Returns the type-specific content tabs for a service type.
+ * Types with no operational content return `[]` (only Overview + Widgets + Config).
+ */
+export function serviceContentTabs(serviceType: string): ContentTab[] {
+ switch (serviceType) {
+ case "jellyfin":
+ return [
+ { label: "Media", Component: MediaTab },
+ { label: "Requests", Component: RequestsTab },
+ ];
+ case "ssh_tasks":
+ return [
+ { label: "Files", Component: FilesTab },
+ { label: "Actions", Component: ActionsTab },
+ ];
+ case "backups":
+ return [{ label: "Jobs", Component: JobsTab }];
+ case "authentik":
+ return [
+ { label: "Users", Component: UsersTab },
+ { label: "Messaging", Component: MessagingTab },
+ ];
+ case "alertmanager":
+ return [{ label: "Alerts", Component: AlertsTab }];
+ case "grafana":
+ return [{ label: "Links", Component: LinksTab }];
+ case "prometheus":
+ return [{ label: "Metrics", Component: MetricsTab }];
+ default:
+ return [];
+ }
+}
diff --git a/frontend/src/pages/service-tabs/stubs.tsx b/frontend/src/pages/service-tabs/stubs.tsx
new file mode 100644
index 0000000..64de68d
--- /dev/null
+++ b/frontend/src/pages/service-tabs/stubs.tsx
@@ -0,0 +1,29 @@
+/**
+ * Service-page content tab stubs.
+ *
+ * Each stub renders a "coming soon" placeholder. Slices 5–9 replace these with
+ * real operational content lifted from the old top-level pages. All stubs accept
+ * an `instance` prop so the real implementations can scope queries by instance.
+ */
+import type { ServiceInstance } from "../../types";
+import { Alert, AlertDescription } from "@/components/ui/alert";
+
+function Stub({
+ label,
+ instance,
+}: {
+ label: string;
+ instance: ServiceInstance;
+}) {
+ return (
+
+
+ {label} for {instance.name} — coming soon.
+
+
+ );
+}
+
+export function OverviewTab({ instance }: { instance: ServiceInstance }) {
+ return ;
+}
diff --git a/openspec/changes/services-as-hub-ia/design.md b/openspec/changes/services-as-hub-ia/design.md
new file mode 100644
index 0000000..acb3d07
--- /dev/null
+++ b/openspec/changes/services-as-hub-ia/design.md
@@ -0,0 +1,213 @@
+# Design — Services as hub IA
+
+**Change:** `services-as-hub-ia`
+**Phase:** design
+**Date:** 2026-06-26
+
+## Context
+
+Frontend: React 18 + Vite + TanStack Query/Table + Tailwind v4 + shadcn/ui +
+react-router-dom. Backend: FastAPI + SQLite settings store + closed service
+registry at `backend/.../integrations/`. Existing patterns: service definitions
+in `integrations/.py`, service instances in the `services` SQLite table,
+widget kinds per service, ServicePage at `/services/:type/:id`.
+
+The change is layered: backend service-type changes first (so the registry and
+API reflect the new world), then frontend IA refactor (so the UI consumes the
+new shape).
+
+## Architecture
+
+### Backend
+
+#### New service types
+
+**`backups`** (`integrations/backups.py`, new):
+
+```python
+class BackupsConfig(ServiceConfigBase):
+ ingestion_label: str = "default" # disambiguates multi-instance ingestion
+
+DEFINITION = ServiceDefinition(
+ service_type="backups",
+ name="Backups",
+ config_model=BackupsConfig,
+ secret_fields=[],
+ widget_kinds=[widget_kind(...)], # existing BackupsWidgetSource moves here
+)
+```
+
+The backup report endpoint gains an optional `?service_id=`. Existing reports
+(attribute to no service) are associated first-wins to the enabled `backups`
+instance; the poller and dashboard summary continue to work unchanged.
+
+**`authentik`** (`integrations/authentik.py`, new):
+
+```python
+class AuthentikConfig(ServiceConfigBase):
+ base_url: ServiceBaseUrl
+ timeout_seconds: int = 10
+
+DEFINITION = ServiceDefinition(
+ service_type="authentik",
+ name="Authentik",
+ config_model=AuthentikConfig,
+ secret_fields=[SecretField(key="api_token", label="API token", required=True)],
+ widget_kinds=[],
+)
+```
+
+A new `AuthentikClient` (`clients/authentik.py`) wraps the directory API:
+`users(search?, page?, page_size?) -> {items, total}`, returning plain dicts.
+Endpoint: `GET /api/services/authentik/:service_id/users` proxies to the
+client. The mail queue and SMTP settings are reused unchanged; the message-
+compose endpoint accepts Authentik user ids instead of Jellyfin ids.
+
+#### Jellyseerr absorption
+
+`JellyfinConfig` gains optional fields:
+
+```python
+class JellyfinConfig(ServiceConfigBase):
+ base_url: ServiceBaseUrl
+ user_id: str = ""
+ timeout_seconds: int = 10
+ jellyseerr_url: str = "" # NEW (optional)
+ jellyseerr_api_key: str = "" # NEW (optional, non-secret at this layer)
+```
+
+The `jellyseerr_api_key` lives in the non-secret config (it is paired with
+`jellyseerr_url` and treated as a service-level credential, encrypted at rest
+via the existing secrets mechanism if you prefer — design choice for tasks
+phase). The `jellyseerr` integration module and registry entry are deleted.
+
+**Migration** (`services/settings_store.py` startup hook):
+
+1. On `ensure_defaults()`, if any `jellyseerr` service rows exist:
+2. For each, attempt to pair with a `jellyfin` instance. Pairing policy: if
+ exactly one Jellyfin exists, merge. If multiple, pick the one whose existing
+ `jellyseerr_url` is empty (first such). If none can be paired, drop the
+ Jellyseerr row with a logged warning.
+3. Move `base_url` and `api_key` onto the paired Jellyfin's config.
+4. Delete the `jellyseerr` row.
+
+#### Route cleanup
+
+`routers/users.py` and its deps are removed. `routers/users_impl.py` removed.
+`routers/media.py`, `routers/files.py`, `routers/jobs.py`, `routers/backups.py`,
+`routers/monitoring.py` keep their endpoints (they are consumed by the service
+tabs) — no change to paths. The dashboard, settings, services routers are
+unchanged. A new `routers/authentik_users.py` exposes the directory endpoint.
+
+### Frontend
+
+#### Top nav generation (`App.tsx`)
+
+Replace the static `navItems` array with a data-driven list built from two
+queries:
+
+```tsx
+const { data: services = [] } = useServiceInstances(); // existing
+const { data: dashboards = [] } = useDashboards(); // NEW
+
+const navItems = useMemo(() => {
+ const configuredTypes = new Set(services.filter(s => s.enabled).map(s => s.service_type));
+ return [
+ { path: "/", label: "Dashboard", icon: LayoutDashboard, always: true },
+ ...dashboards.map(d => ({ path: `/d/${d.slug}`, label: d.label, icon: LayoutTemplate })),
+ ...SERVICE_TYPE_NAV_ENTRIES
+ .filter(e => configuredTypes.has(e.serviceType))
+ .map(e => ({ path: `/services/${e.serviceType}`, label: e.label, icon: e.icon })),
+ { path: "/services", label: "Services", icon: Boxes, always: true },
+ { path: "/settings", label: "Settings", icon: SettingsIcon, always: true },
+ ];
+}, [services, dashboards]);
+```
+
+`SERVICE_TYPE_NAV_ENTRIES` is a static map from service type to its conditional
+nav entry/entries (ssh_tasks contributes two: Files + Actions). The shell
+shows a loading state until both queries settle.
+
+#### Service page IA (`pages/ServicePage.tsx`)
+
+Refactor `ServicePage` to render a tab skeleton driven by the service type:
+
+```tsx
+const tabs = useMemo(() => serviceTabs(serviceType, instance), [...]);
+// tabs = [Overview, ...contentTabs, Widgets, Config]
+```
+
+`serviceTabs` returns the per-type content components (MediaTab, FilesTab,
+ActionsTab, JobsTab, UsersTab, MessagingTab, AlertsTab, LinksTab,
+MetricsTab — most pre-existing, lifted from their top-level pages). The
+instance switcher renders at the top when `instances.length > 1`.
+
+Routes:
+
+- `/services/:type` → resolve first enabled instance → redirect to
+ `/services/:type/:id` (client-side).
+- `/services/:type/:id` → render ServicePage with the instance + siblings.
+
+#### Named dashboards (`pages/Dashboard.tsx` + new `NamedDashboardPage`)
+
+- Main Dashboard at `/` keeps the current shape (widgets + shortcuts, now
+ including pinned service links as a shortcut variant).
+- New `NamedDashboardPage` at `/d/:slug` renders a saved dashboard record's
+ widgets + pinned links.
+- New `useDashboards` hook + CRUD endpoints (`GET/POST/PUT/DELETE
+ /api/dashboards`) on the backend; the existing `dashboard_shortcuts` table
+ gains a `dashboard` entity (or a new `named_dashboards` table — design
+ choice for tasks phase).
+
+#### Content migration
+
+Each content page is lifted into a `*Tab` component consumed by ServicePage:
+
+| Old | New | Consumers |
+|-----|-----|-----------|
+| `pages/Media.tsx` (Applications) | `pages/service-tabs/MediaTab.tsx` | Jellyfin |
+| `pages/FileBrowser.impl.tsx` | `pages/service-tabs/FilesTab.tsx` | ssh_tasks |
+| `pages/Actions.tsx` | `pages/service-tabs/ActionsTab.tsx` | ssh_tasks |
+| `components/BackupsPage.tsx` | `pages/service-tabs/JobsTab.tsx` | backups |
+| `pages/UsersPage.impl.tsx` | REMOVED; new `UsersTab` sources Authentik | authentik |
+| `components/ObservabilityPage.tsx` | SPLIT into `AlertsTab`/`LinksTab`/`MetricsTab` | alertmanager/grafana/prometheus |
+
+Tabs accept `{ instance: ServiceInstance }` and read `instance.id` to scope
+their queries (replacing today's `?jellyfin_service_id=` query param — the
+service page passes the active instance directly).
+
+#### Authentik client + endpoints
+
+- `clients/authentik.py` (backend) — directory API wrapper.
+- `routers/authentik_users.py` — `GET /api/services/authentik/:id/users`.
+- `pages/service-tabs/UsersTab.tsx` — directory table + search.
+- `pages/service-tabs/MessagingTab.tsx` — compose + queue status, sourced from
+ Authentik users (replaces the UsersPage compose dialog).
+
+### Key technical risks & mitigations
+
+- **Content migration scope.** Each tab lift is a non-trivial move. Slices must
+ be page-by-page so each lands green and reviewable.
+- **Instance-scoped queries.** Today most content reads a service-id from a
+ query param. The tab components take an `instance` prop and pass `instance.id`
+ to their hooks; the hooks' existing `jellyfinServiceId`/`service_id` params
+ are reused.
+- **Authentik API field coverage.** The directory API may not expose all fields
+ the old compose flow used (avatars, activity). The UsersTab shows what's
+ available; Messaging uses Authentik emails only.
+- **Jellyseerr migration ambiguity.** Multiple Jellyfins + multiple Jellyseerrs
+ with no explicit pairing is unresolvable automatically. The migration drops
+ unpaired Jellyseerrs with a logged warning; users reconfigure manually.
+- **Nav loading flash.** The shell needs services + dashboards before rendering
+ nav. Show a skeleton nav until settled; do not block the route render.
+
+## Trade-offs
+
+- **404 over redirect.** Old bookmarks break. Accepted: redirects become tech
+ debt; the new IA is clean.
+- **No cross-service observability.** A built-in overview is sacrificed; users
+ build their own via named dashboards. Accepted per D6.
+- **Global dashboards.** No per-user customization in this change. Accepted;
+ multi-tenant is a separate concern.
+- **Jellyseerr absorbed, not migrated gracefully.** Unpaired Jellyseerrs are
+ dropped. Accepted; the data is recreatable.
diff --git a/openspec/changes/services-as-hub-ia/proposal.md b/openspec/changes/services-as-hub-ia/proposal.md
new file mode 100644
index 0000000..9a477dc
--- /dev/null
+++ b/openspec/changes/services-as-hub-ia/proposal.md
@@ -0,0 +1,216 @@
+# Proposal — Services as hub IA
+
+**Change:** `services-as-hub-ia`
+**Phase:** proposal
+**Date:** 2026-06-26
+
+## Problem
+
+The current information architecture treats **concepts** (Media, Files, Actions,
+Users, Observability, Backups) as first-class top-level destinations. Services
+(Jellyfin, SSH, Alertmanager, etc.) are configured separately and reached via a
+"Services" admin page that holds only connection config + widgets. This produces
+two problems:
+
+1. **Duplicated ontology.** "Media" and "the Jellyfin service page" are two
+ different places that both reference the same Jellyfin instance. The Media
+ page is where you browse; the service page is where you configure. There is
+ no single "Jellyfin" place.
+2. **Concept-pages assume exactly one source.** The Media page assumes media
+ comes from Jellyfin, the Files page assumes files come from SSH, the Users
+ page assumes users come from Jellyfin. Multi-instance setups (2 Jellyfins, 2
+ SSH targets) have no first-class home; you switch via query params.
+
+Meanwhile, several concepts have outgrown their current shape:
+
+- **Users** is Jellyfin-specific and overlaps with the OIDC provider (Authentik)
+ that already holds the canonical user directory. Maintaining a parallel
+ Jellyfin-only user directory is duplicated work.
+- **Observability** aggregates three service types (Alertmanager, Grafana,
+ Prometheus) into one page, but each of those services is already a
+ first-class registry instance. The aggregate page is a special case.
+- **Backups** receives reports via a REST endpoint but has no service-record
+ home; it cannot be named, multi-instanced, or surfaced like other services.
+- **Jellyseerr** is configured as a separate service but its only role is
+ enriching Jellyfin users — it has no standalone value.
+
+## Proposal
+
+Reorganize the app around **services as the hub**. The top-level navigation
+shrinks to a tiny always-visible core plus **conditional per-type entries** that
+materialize only when a matching service is configured. Operational content
+(Media, Files, Actions, Users) moves **into the service page** as tabs.
+
+### Top-level navigation (after)
+
+- **Main Dashboard** (always visible, special, at `/`)
+- **Named dashboards** (always visible once created; one top-level entry each,
+ at `/d/:slug`)
+- **Conditional service-type entries** — one per configured service type,
+ linking to the type's service page with an in-page instance switcher:
+ - "Media" appears when a Jellyfin service exists
+ - "Files" and "Actions" appear when an ssh_tasks service exists
+ - "Alerts" when Alertmanager exists; "Grafana" when Grafana exists;
+ "Prometheus" when Prometheus exists (Observability page is removed)
+ - "Backups" when a backups service exists
+ - "Users" when an Authentik service exists
+- **Services** (always visible — the admin hub for managing service instances)
+- **Settings** (always visible — unchanged)
+
+### Service page IA (after)
+
+Every service page uses the same tab skeleton:
+
+```
+[Overview] [type-specific content tabs...] [Widgets] [Config]
+```
+
+- **Overview** — service health + key metrics (connection status, version,
+ primary widget preview).
+- **Content tabs** — per service type:
+ - **Jellyfin**: Media (table + index build), Requests (Jellyseerr enrichment)
+ - **ssh_tasks**: Files (browser + ffprobe), Actions (saved tasks)
+ - **backups**: Jobs (jobs + runs + alerts)
+ - **authentik**: Users (directory), Messaging (compose)
+ - **alertmanager**: Alerts
+ - **grafana**: Links
+ - **prometheus**: Metrics / status
+- **Widgets** — widget kinds this service provides (unchanged from today).
+- **Config** — non-secret config + secrets (unchanged from today).
+
+Multi-instance: when >1 instance of a type exists, the service page shows an
+**instance switcher** (dropdown at the top of the page) rather than separate
+routes per instance.
+
+### Service type changes
+
+- **NEW: `backups`** — becomes a service type in the registry. The current REST
+ report endpoint keeps working for passive ingestion; reports are attributed to
+ a backups service instance. The `BackupsPage` content (jobs/runs/alerts) moves
+ into the backups service page's Jobs tab.
+- **NEW: `authentik`** — becomes a service type. Its Users tab is the new user
+ directory (replacing the Jellyfin-based Users page). Its Messaging tab hosts
+ the message-compose flow, emailing Authentik-sourced users via the existing
+ SMTP settings. OIDC auth flow is unchanged.
+- **ABSORBED: `jellyseerr`** — ceases to be its own service type. Its config
+ fields (`base_url`, `api_key`) move onto the Jellyfin service config as
+ optional fields. The Jellyfin service page gains a Requests tab backed by the
+ configured Jellyseerr. Existing Jellyseerr service instances are migrated into
+ their paired Jellyfin's config (or dropped if no pairing can be inferred).
+- **UNCHANGED**: `alertmanager`, `grafana`, `prometheus`, `ssh_tasks`,
+ `nextcloud` keep their service-type status. Their operational content (if any)
+ moves into tabs on their service page.
+
+### Removed / replaced
+
+- **`/media`** — content moves into Jellyfin service page (Media tab). Old route
+ returns 404.
+- **`/files`, `/actions`** — content moves into ssh_tasks service page (Files /
+ Actions tabs). Old routes return 404.
+- **`/users`** — replaced by Authentik service page (Users tab). Old route
+ returns 404. The Jellyfin-backed user directory, Jellyfin-email message
+ compose, and Jellyseerr-enrichment-of-Jellyfin-users are removed.
+- **`/observability`** — removed. Its content splits across the Alertmanager,
+ Grafana, and Prometheus service pages. Old route returns 404. The cross-
+ service "single pane of glass" is intentionally sacrificed; users who want it
+ build it on a named dashboard via widgets.
+- **`/backups`** — content moves into the backups service page (Jobs tab). Old
+ route returns 404.
+- **Jellyseerr service type** — configuration absorbed into Jellyfin.
+
+### Named dashboards
+
+- The main Dashboard at `/` stays **special** (the default landing, not
+ deletable, always first in nav).
+- Users can create **named dashboards** at `/d/:slug`. Each named dashboard is a
+ configurable grid of **widgets + pinned service links** (shortcuts to specific
+ service pages or tabs).
+- Each named dashboard appears as its own top-level nav entry, in a user-
+ controlled order. The main dashboard always sits first.
+
+### Routing
+
+- `/` — main Dashboard (special, default landing)
+- `/d/:slug` — named dashboard
+- `/services` — services admin hub (list of all service instances, grouped by
+ type)
+- `/services/:type` — service page for the first/primary instance of a type,
+ with an instance switcher when >1 exists
+- `/services/:type/:id` — service page for a specific instance
+- `/settings` — settings (unchanged)
+- All legacy top-level routes (`/media`, `/files`, `/actions`, `/users`,
+ `/observability`, `/backups`) return **404** — no redirects, no aliases.
+
+### Empty state
+
+A fresh install with no services configured and no dashboards lands on the main
+Dashboard with a strong CTA ("Add a service to get started" → Services). The
+Services page has a matching empty state. Top nav shows only Dashboard /
+Services / Settings until services or dashboards are added.
+
+## Non-goals
+
+- **No changes to OIDC / SSO authentication.** Authentik-as-IdP keeps doing
+ what it does today; this change adds Authentik-as-directory-source only.
+- **No per-instance top-level entries.** A type gets one conditional entry with
+ an in-page instance switcher; nav does not grow with the number of instances.
+- **No legacy-route redirects.** Old URLs 404; bookmarks must be updated.
+- **No tablet-specific or mobile-specific IA divergence.** The IA is the same
+ across breakpoints (mobile responsive parity already shipped).
+- **No new widget kinds.** Named dashboards compose existing widget kinds plus
+ pinned service links (a new shortcut variant, not a widget kind).
+- **No backend API contract changes beyond the new service types and the
+ Authentik directory endpoint.** Existing endpoints keep their shape.
+- **No multi-tenant or per-user dashboard customization.** Dashboards are
+ global (shared across all authenticated users) in this change.
+
+## Key technical risks
+
+- **Content migration is large.** Media, Files, Actions, Users, Backups each
+ move from a top-level page into a service tab. Each is a non-trivial component
+ with its own hooks, tests, and state. This is the bulk of the implementation
+ risk and review burden.
+- **Jellyseerr absorption migration.** Existing Jellyseerr service instances
+ must be migrated into their paired Jellyfin's config at backend startup, with
+ a clear policy when pairing is ambiguous (multiple Jellyfins, no Jellyfin).
+- **Authentik directory API.** The Authentik service page needs a backend client
+ that queries Authentik's user/group directory API. Scope of that API (which
+ fields, pagination, search) must be pinned during design.
+- **Nav generation is data-driven.** Top nav must react to configured services
+ and existing dashboards. This is a new TanStack-Query dependency in the App
+ shell, with loading/empty states.
+- **Backups attribution.** Existing backup reports have no service_id. The
+ migration must assign them to a backups service instance (first-wins or
+ job-name-matching policy).
+
+## Risks (flagged, not blocking)
+
+- **Loss of cross-service Observability overview.** A fresh install with no
+ dashboards configured has no alerts-overview until the user builds one. The
+ mitigation (widgets on a named dashboard) is real but requires user setup.
+ Revisit if it bites.
+- **Authentik directory coverage.** Authentik's user directory may not carry the
+ same fields the current Jellyfin-based messaging flow relied on (e.g. Jellyfin-
+ specific avatar URLs, activity state). Some fields will simply go away.
+
+## Decision matrix (from grilling)
+
+| # | Decision | Choice |
+|---|----------|--------|
+| D1 | Top nav model | Conditional type entries (one per configured service type, appearing only when configured) |
+| D2 | Multi-instance | Type + instance switcher on the service page |
+| D3 | Files + Actions | Move into ssh_tasks service page as tabs |
+| D4 | Backups | New service type in the registry |
+| D5 | Users | Replaced by Authentik (included in this change) |
+| D6 | Observability | Split per service type (no aggregate page) |
+| D7 | Main Dashboard | Stays special at `/`, not deletable, default landing |
+| D8 | Named dashboards | Widgets + pinned service links |
+| D9 | Named dashboards nav | Each named dashboard = one top-level entry |
+| D10 | Authentik role | User directory source (OIDC auth unchanged) |
+| D11 | Messaging | Moves to Authentik service page; emails Authentik users via existing SMTP |
+| D12 | Jellyseerr | Absorbed into Jellyfin config (no longer its own service type) |
+| D13 | Service page tabs | Standard skeleton: Overview \u2234 content \u2234 Widgets \u2234 Config |
+| D14 | Overview tab | Health + key metrics |
+| D15 | Routing | `/services/:type/:id`, `/services/:type` (first/primary), `/d/:slug`, `/` |
+| D16 | Legacy routes | Return 404 (no redirects, no aliases) |
+| D17 | Empty state | Dashboard CTA + Services empty state |
diff --git a/openspec/changes/services-as-hub-ia/spec.md b/openspec/changes/services-as-hub-ia/spec.md
new file mode 100644
index 0000000..c63c839
--- /dev/null
+++ b/openspec/changes/services-as-hub-ia/spec.md
@@ -0,0 +1,171 @@
+# Spec — Services as hub IA
+
+**Change:** `services-as-hub-ia`
+**Phase:** spec
+**Date:** 2026-06-26
+
+## Scope
+
+Reorganize the frontend information architecture around services as the hub.
+Operational content (Media, Files, Actions, Users, Backups) moves into service-
+type-specific tabs on the service page. The top nav shrinks to a small always-
+visible core (Main Dashboard, Services, Settings) plus conditional per-type
+entries and user-created named dashboards. Two new service types are added
+(`backups`, `authentik`); one is absorbed (`jellyseerr` → Jellyfin config).
+
+This change spans backend (new service types, Authentik client, Jellyseerr
+migration, route cleanup) and frontend (service-page IA, top-nav generation,
+content migration, named dashboards).
+
+## Requirements
+
+### R1 — Top-level navigation
+
+- R1.1 The top nav contains, in order: Main Dashboard, named dashboards (one
+ entry each, user-controlled order), conditional service-type entries, Services,
+ Settings.
+- R1.2 Conditional service-type entries appear only when at least one enabled
+ instance of that type exists. Mapping:
+ - `jellyfin` → "Media" entry → `/services/jellyfin`
+ - `ssh_tasks` → "Files" and "Actions" entries → `/services/ssh_tasks`
+ - `alertmanager` → "Alerts" entry → `/services/alertmanager`
+ - `grafana` → "Grafana" entry → `/services/grafana`
+ - `prometheus` → "Prometheus" entry → `/services/prometheus`
+ - `backups` → "Backups" entry → `/services/backups`
+ - `authentik` → "Users" entry → `/services/authentik`
+ - `nextcloud` → no entry (no operational content)
+- R1.3 The Main Dashboard is always first and not deletable.
+- R1.4 The nav is data-driven (reacts to configured services + dashboards) with
+ graceful loading/empty states.
+
+### R2 — Service page IA
+
+- R2.1 Every service page uses the tab skeleton: Overview, type-specific
+ content tabs (zero or more), Widgets, Config.
+- R2.2 The Overview tab shows service health (connection status, version, last
+ error) and a primary metric preview (per-type: live sessions for Jellyfin,
+ active alert count for Alertmanager, etc.).
+- R2.3 The Widgets and Config tabs are unchanged from today (widget kinds list,
+ non-secret config + secrets editors).
+- R2.4 Type-specific content tabs:
+ - `jellyfin`: Media (table + index build controls), Requests (Jellyseerr data)
+ - `ssh_tasks`: Files (browser + ffprobe + jobs), Actions (saved tasks CRUD + run)
+ - `backups`: Jobs (jobs + runs + alerts + acknowledge)
+ - `authentik`: Users (directory + search), Messaging (compose + queue status)
+ - `alertmanager`: Alerts (summary + list + severity filter)
+ - `grafana`: Links (configured dashboard deep-links)
+ - `prometheus`: Metrics (status + PromQL explorer)
+ - `nextcloud`: no content tabs (Overview + Widgets + Config only)
+
+### R3 — Instance switcher
+
+- R3.1 When more than one enabled instance of a service type exists, the service
+ page renders an instance switcher (dropdown) at the top.
+- R3.2 The switcher selects the active instance; all tabs reflect the selected
+ instance.
+- R3.3 The default selected instance is the first enabled instance (or the one
+ named "primary" if multiple-selection is added later — out of scope here).
+- R3.4 Single-instance types do not render the switcher.
+
+### R4 — Routing
+
+- R4.1 `/` — Main Dashboard (special, default landing, not deletable).
+- R4.2 `/d/:slug` — named dashboard.
+- R4.3 `/services` — services admin hub (list of all instances, grouped by type,
+ with add/edit/delete).
+- R4.4 `/services/:type` — service page for the first enabled instance of the
+ type; redirects (client-side) to `/services/:type/:id` once an instance is
+ resolved.
+- R4.5 `/services/:type/:id` — service page for a specific instance.
+- R4.6 `/settings` — settings (unchanged).
+- R4.7 Legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`,
+ `/backups`) return 404 — no redirects, no aliases.
+
+### R5 — Named dashboards
+
+- R5.1 Any authenticated user can create, edit, reorder, and delete named
+ dashboards (global scope — shared across users in this change).
+- R5.2 A named dashboard holds an ordered list of widgets (existing widget kinds
+ only) and pinned service links (shortcut to a service page or specific tab).
+- R5.3 Each named dashboard has a user-chosen label and a URL slug derived from
+ it (uniqueness enforced).
+- R5.4 The Main Dashboard is special: it cannot be deleted, is always first in
+ the nav, and its slug is reserved.
+
+### R6 — Service type changes
+
+- R6.1 **NEW `backups`** service type: config holds ingestion source metadata;
+ the existing REST report endpoint attributes incoming reports to a backups
+ service instance (first-wins when none is specified).
+- R6.2 **NEW `authentik`** service type: config holds base_url; secret holds the
+ API token. Provides a Users widget and a user-directory endpoint consumed by
+ the Authentik service page.
+- R6.3 **ABSORBED `jellyseerr`**: removed as a service type. Its config fields
+ (`base_url`, `api_key`) become optional fields on `JellyfinConfig`. Existing
+ Jellyseerr service instances are migrated into their paired Jellyfin's config
+ at backend startup; unpaired instances are dropped with a logged warning.
+
+### R7 — Users → Authentik
+
+- R7.1 The Jellyfin-backed user directory, Jellyfin-email message compose, and
+ Jellyseerr-enrichment-of-Jellyfin-users flows are removed.
+- R7.2 The Authentik service page Users tab sources users from Authentik's
+ directory API (paginated, searchable).
+- R7.3 The Authentik Messaging tab hosts message-compose, emailing Authentik-
+ sourced users via the existing SMTP settings and mail queue.
+- R7.4 OIDC authentication is unchanged.
+
+### R8 — Observability
+
+- R8.1 The Observability page is removed.
+- R8.2 Alertmanager alerts, Grafana links, and Prometheus status each render on
+ their respective service-type pages as content tabs.
+- R8.3 There is no cross-service aggregate view built-in. Users who want one
+ build it via widgets on a named dashboard.
+
+### R9 — Empty state
+
+- R9.1 A fresh install (no services, no dashboards) lands on `/` with an empty-
+ state CTA pointing to `/services`.
+- R9.2 The Services hub shows a strong empty state ("Add a service to get
+ started") when no service instances exist.
+
+### R10 — Non-regression
+
+- R10.1 The existing widget system, ServicePage config/secrets editing, settings
+ (machines, SSH keys), and authentication continue to work.
+- R10.2 The backend backup report endpoint, mail queue, and observability
+ metrics endpoints continue to function (they may gain a service_id
+ association).
+- R10.3 Mobile responsive behavior (already shipped) is preserved across the new
+ IA.
+
+## Acceptance criteria
+
+- AC1 The top nav renders exactly: Main Dashboard, named dashboards, configured-
+ service-type entries, Services, Settings — and nothing else.
+- AC2 Each content tab listed in R2.4 renders its full operational content
+ inside the corresponding service page.
+- AC3 An instance switcher appears when >1 enabled instance of a type exists and
+ is absent otherwise.
+- AC4 Creating, editing, reordering, and deleting a named dashboard works; each
+ appears in the nav and is reachable at `/d/:slug`.
+- AC5 Legacy routes return 404.
+- AC6 The `backups` and `authentik` service types appear in the service-type
+ list and can be configured like any other service.
+- AC7 Existing Jellyseerr service instances are migrated into Jellyfin config
+ (or dropped with a logged warning when unpaired).
+- AC8 A fresh install lands on `/` with the empty-state CTA.
+- AC9 `cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` is
+ green.
+- AC10 `cd frontend && npm run lint && npm run build && npm run test` is green.
+
+## Non-goals
+
+- Per-instance top-level nav entries.
+- Legacy-route redirects or aliases.
+- New widget kinds (pinned service links are a shortcut variant, not a widget
+ kind).
+- Per-user dashboard customization.
+- Changes to OIDC authentication.
+- Mobile-specific IA divergence.
diff --git a/openspec/changes/services-as-hub-ia/tasks.md b/openspec/changes/services-as-hub-ia/tasks.md
new file mode 100644
index 0000000..d9b8835
--- /dev/null
+++ b/openspec/changes/services-as-hub-ia/tasks.md
@@ -0,0 +1,270 @@
+# Tasks — Services as hub IA
+
+**Change:** `services-as-hub-ia`
+**Phase:** tasks
+**Date:** 2026-06-26
+
+## Review workload forecast
+
+| Field | Value |
+|-------|-------|
+| Estimated changed lines | ~4500–6000 |
+| Chained PRs recommended | Yes (12 slices) |
+| Chain strategy | stacked-to-main |
+| Slice order | 1–3 backend → 4 shell → 5–9 content tabs → 10 dashboards → 11 cleanup → 12 verify |
+
+Each slice is committed separately. Every slice must leave
+`cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` **and**
+`cd frontend && npm run lint && npm run build && npm run test` green. Every
+touched page gains a Vitest case at the new route and asserts the old route 404s
+(where applicable).
+
+---
+
+## Slice 1 — Backend: new service types + Jellyseerr absorption
+
+**Goal:** Registry reflects the new world. No frontend change yet.
+
+- [ ] **1.1 Add `backups` integration**
+ - Files: `backend/src/media_library_viewer_api/integrations/backups.py` (new),
+ `integrations/registry.py`
+ - Details: `BackupsConfig` (`ingestion_label: str = "default"`), no secrets,
+ widget kind `summary` (move `BackupsWidgetSource` adapter to bind the
+ service_id). Register in `SERVICE_DEFINITIONS`.
+
+- [ ] **1.2 Add `authentik` integration**
+ - Files: `integrations/authentik.py` (new), `registry.py`
+ - Details: `AuthentikConfig` (`base_url: ServiceBaseUrl`, `timeout_seconds`),
+ secret `api_token` (required). No widget kinds yet.
+
+- [ ] **1.3 Absorb `jellyseerr` into `JellyfinConfig`**
+ - Files: `integrations/jellyfin.py`, `integrations/jellyseerr.py` (delete),
+ `integrations/registry.py`, `integrations/__init__.py`
+ - Details: Add optional `jellyseerr_url`, `jellyseerr_api_key` to
+ `JellyfinConfig`. Delete the `jellyseerr` integration module and registry
+ entry. Update tests.
+
+- [ ] **1.4 Jellyseerr migration**
+ - Files: `services/settings_store.py` (`ensure_defaults`)
+ - Details: On startup, migrate existing `jellyseerr` rows into paired
+ `jellyfin` instances per the design. Log a warning for unpaired drops.
+
+- [ ] **1.5 Tests**
+ - Update `backend/tests/test_services.py`, `test_widgets.py` for the new types
+ and the migration. Assert registry contains 8 types (alertmanager, authentik,
+ backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks).
+
+---
+
+## Slice 2 — Backend: Authentik directory client + endpoint
+
+- [ ] **2.1 AuthentikClient**
+ - Files: `clients/authentik.py` (new)
+ - Details: `users(search, page, page_size) -> {items, total}` against the
+ Authentik directory API. Reuse the requests-session pattern from
+ `clients/jellyseerr.py`. Tests: `tests/test_authentik_client.py`.
+
+- [ ] **2.2 Directory endpoint**
+ - Files: `routers/authentik_users.py` (new), `main.py` (register router)
+ - Details: `GET /api/services/authentik/{service_id}/users` proxies to the
+ client, resolving the service record via the existing dependency. Tests
+ cover not-configured + unreachable + paginated-success.
+
+---
+
+## Slice 3 — Backend: route cleanup + backups attribution
+
+- [ ] **3.1 Remove Users router**
+ - Files: `routers/users.py`, `routers/users_impl.py` (delete), `main.py`,
+ `dependencies.py`
+ - Details: Delete the Jellyfin-backed user directory + message-compose router
+ and its deps. Update `test_api.py` to drop the corresponding tests.
+
+- [ ] **3.2 Backups service attribution**
+ - Files: `routers/backups.py`, `services/settings_store.py`
+ - Details: backup report endpoint accepts optional `?service_id=`; first-wins
+ association to an enabled `backups` instance when omitted. Dashboard summary
+ - poller continue to work.
+
+- [ ] **3.3 Named dashboards backend**
+ - Files: `models/dashboards.py` (new), `routers/dashboards.py` (new),
+ `services/settings_store.py` (table + CRUD)
+ - Details: `named_dashboards` table (id, slug, label, sort_order, payload JSON
+ of widget+link placements). Endpoints: `GET/POST/PUT/DELETE /api/dashboards`.
+ Tests in `tests/test_dashboards.py`.
+
+---
+
+## Slice 4 — Frontend: top-nav generation + service-page skeleton
+
+**Goal:** Data-driven nav + tab-skeleton ServicePage shell. Content tabs are
+stubs that say "coming soon" so the rest of the app stays green.
+
+- [ ] **4.1 Service-type → nav-entry map**
+ - Files: `frontend/src/integrations/navEntries.ts` (new)
+ - Details: Static `SERVICE_TYPE_NAV_ENTRIES` map (jellyfin→Media,
+ ssh_tasks→[Files, Actions], alertmanager→Alerts, etc.). Helper to filter by
+ configured types.
+
+- [ ] **4.2 Data-driven nav in `App.tsx`**
+ - Files: `frontend/src/App.tsx`
+ - Details: Replace static `navItems` with the memoized list from design. Add
+ `useDashboards()` and combine with `useServiceInstances()`. Loading skeleton
+ nav until settled. Legacy routes removed; add 404 catch-all.
+
+- [ ] **4.3 ServicePage tab skeleton + instance switcher**
+ - Files: `frontend/src/pages/ServicePage.tsx`, new `pages/service-tabs/`
+ directory, `pages/ServiceTypePage.tsx` (redirect resolver)
+ - Details: Refactor ServicePage to render `[Overview, ...content, Widgets,
+ Config]` from `serviceTabs(serviceType)`. Add `/services/:type` resolver
+ route. Content tabs are stub components ("coming soon"). Instance switcher
+ dropdown when siblings > 1.
+
+- [ ] **4.4 Empty-state CTAs**
+ - Files: `frontend/src/pages/Dashboard.tsx`, `pages/ServicesPage.tsx`
+ - Details: Dashboard shows "Add a service" CTA when no services. Services
+ page strong empty state.
+
+- [ ] **4.5 Tests**
+ - Nav-generation tests, service-page-skeleton tests, 404-on-legacy-routes
+ tests.
+
+---
+
+## Slice 5 — Frontend: Jellyfin content tabs (Media + Requests)
+
+- [ ] **5.1 MediaTab**
+ - Files: `pages/service-tabs/MediaTab.tsx` (lift from `pages/Media.tsx`)
+ - Details: Accept `instance` prop, pass `instance.id` to media hooks. Preserve
+ the index build controls + mobile card layout. Delete the old `/media` route
+ and `Applications.tsx` wrapper.
+
+- [ ] **5.2 RequestsTab (Jellyseerr enrichment)**
+ - Files: `pages/service-tabs/RequestsTab.tsx`
+ - Details: Source from the absorbed `jellyseerr_url`/`jellyseerr_api_key` on
+ the Jellyfin instance. Render request-management data.
+
+- [ ] **5.3 Tests**
+ - New tests for MediaTab (instance-scoped), RequestsTab. Delete old Media page
+ tests.
+
+---
+
+## Slice 6 — Frontend: ssh_tasks content tabs (Files + Actions)
+
+- [ ] **6.1 FilesTab**
+ - Files: `pages/service-tabs/FilesTab.tsx` (lift from `FileBrowser.impl.tsx`)
+ - Details: Accept `instance` prop. Delete old `/files` route + page wrapper.
+
+- [ ] **6.2 ActionsTab**
+ - Files: `pages/service-tabs/ActionsTab.tsx` (lift from `Actions.tsx`)
+ - Details: Accept `instance` prop. Delete old `/actions` route + page.
+
+- [ ] **6.3 Tests**
+
+---
+
+## Slice 7 — Frontend: backups Jobs tab
+
+- [ ] **7.1 JobsTab**
+ - Files: `pages/service-tabs/JobsTab.tsx` (lift from `components/BackupsPage.tsx`)
+ - Details: Accept `instance` prop, scope queries by `instance.id`. Delete old
+ `/backups` route + page.
+
+- [ ] **7.2 Tests**
+
+---
+
+## Slice 8 — Frontend: Authentik Users + Messaging tabs
+
+- [ ] **8.1 UsersTab**
+ - Files: `pages/service-tabs/UsersTab.tsx`, `hooks/useAuthentikUsers.ts`,
+ `api/authentik.ts`
+ - Details: Directory table + search, sourced from the new endpoint. No
+ Jellyfin/Jellyseerr enrichment.
+
+- [ ] **8.2 MessagingTab**
+ - Files: `pages/service-tabs/MessagingTab.tsx` (lift compose UI from
+ `UsersPage.impl.tsx`)
+ - Details: Recipient list sourced from Authentik users. Reuse the mail queue +
+ SMTP settings. Delete the old `/users` route + UsersPage.
+
+- [ ] **8.3 Tests**
+
+---
+
+## Slice 9 — Frontend: Observability split (Alerts + Links + Metrics tabs)
+
+- [ ] **9.1 AlertsTab**
+ - Files: `pages/service-tabs/AlertsTab.tsx` (lift from `ObservabilityPage.tsx`)
+ - Details: Alertmanager alerts view, instance-scoped. Delete old
+ `/observability` route + page.
+
+- [ ] **9.2 LinksTab + MetricsTab**
+ - Files: `pages/service-tabs/LinksTab.tsx`, `pages/service-tabs/MetricsTab.tsx`
+ - Details: Grafana deep-links; Prometheus status + PromQL explorer. Each
+ instance-scoped.
+
+- [ ] **9.3 Tests**
+
+---
+
+## Slice 10 — Frontend: named dashboards
+
+- [ ] **10.1 NamedDashboardPage**
+ - Files: `pages/NamedDashboardPage.tsx`, `hooks/useDashboards.ts`,
+ `api/dashboards.ts`
+ - Details: Render widgets + pinned service links at `/d/:slug`. CRUD via the
+ new endpoints.
+
+- [ ] **10.2 Pinned service links**
+ - Files: `components/PinnedServiceLink.tsx`, integration into the dashboard
+ config dialog
+ - Details: Shortcut variant targeting `/services/:type/:id` or a specific tab.
+
+- [ ] **10.3 Dashboard management UI**
+ - Files: a new "Manage dashboards" entry on the Services or Settings page
+ - Details: Create/rename/reorder/delete named dashboards.
+
+- [ ] **10.4 Tests**
+
+---
+
+## Slice 11 — Cleanup + docs
+
+- [ ] **11.1 Delete dead code**
+ - Files: any remaining top-level page wrappers, unused hooks, stale types.
+ - Details: Confirm no references to removed routes/pages remain.
+
+- [ ] **11.2 Update `docs/REQUIREMENTS.md`**
+ - Files: `docs/REQUIREMENTS.md`
+ - Details: Rewrite the Information Architecture section. Document the service-
+ type → nav-entry map, the service-page tab skeleton, named dashboards,
+ routing, and the Users→Authentik + Observability-split decisions.
+
+- [ ] **11.3 Update `CHANGELOG.md`**
+
+---
+
+## Slice 12 — Verify
+
+- [ ] **12.1 Cross-route manual pass**
+ - Details: Walk every service type's page + tabs; walk named dashboards; walk
+ the empty state; confirm legacy routes 404.
+
+- [ ] **12.2 Verify report**
+ - Files: `openspec/changes/services-as-hub-ia/verify-report.md`
+ - Details: Per-AC evidence (AC1–AC10), tool versions, manual notes, residual
+ risks.
+
+---
+
+## Notes
+
+- Slices 1–3 are backend-only; slice 4 is the frontend shell turning on the new
+ IA with stubs; 5–9 replace stubs with real content; 10 adds named dashboards;
+ 11–12 close out.
+- Slices 5–9 are independent and can be reordered or parallelized across
+ branches if useful, but each must merge green with its stub replaced.
+- The frontend content lifts (5–9) are the bulk of the line count; treat each as
+ a self-contained review-sized PR.
diff --git a/openspec/changes/services-as-hub-ia/verify-report.md b/openspec/changes/services-as-hub-ia/verify-report.md
new file mode 100644
index 0000000..ca3adfb
--- /dev/null
+++ b/openspec/changes/services-as-hub-ia/verify-report.md
@@ -0,0 +1,78 @@
+# Verify Report — Services as hub IA
+
+**Change:** `services-as-hub-ia`
+**Phase:** verify
+**Date:** 2026-06-26
+
+## Summary
+
+All 11 implementation slices shipped. The app is reorganized around services as the hub: the top-level navigation is data-driven (Main Dashboard + named dashboards + conditional per-type entries + Services + Settings), operational content lives in per-type tabs on service pages, and the legacy top-level routes return 404. Two new service types (`backups`, `authentik`); one absorbed (`jellyseerr` → Jellyfin config); Users replaced by Authentik; Observability split per service type; named dashboards added.
+
+12 commits on `services-as-hub-ia` (1 plan + 11 slices). ~8600 insertions / ~3900 deletions across 103 files.
+
+## Acceptance criteria
+
+### AC1 — Top nav renders exactly Main Dashboard, named dashboards, configured-service-type entries, Services, Settings ✅
+
+`useNavItems` (App.tsx) builds the list from `useServiceInstances` (enabled types) + `useDashboards`. Order: Main Dashboard, named dashboards, conditional service-type entries (via `configuredNavEntries`), Services, Settings. `navEntries.test.ts` covers filtering including the ssh_tasks double-entry and nextcloud-none cases.
+
+### AC2 — Each content tab renders its full operational content inside the service page ✅
+
+Slices 5–9 replaced the stubs with real implementations: MediaTab + RequestsTab (jellyfin), FilesTab + ActionsTab (ssh_tasks), JobsTab (backups), UsersTab + MessagingTab (authentik), AlertsTab (alertmanager), LinksTab (grafana), MetricsTab (prometheus). Each accepts `{ instance }` and is wired into `serviceContentTabs(type)`. Tests cover each tab.
+
+### AC3 — Instance switcher appears when >1 enabled instance of a type exists ✅
+
+ServicePage renders a Select switcher gated on `enabledSiblings.length > 1` (R3.1). ServicePage.test covers show/hide. (Note: switcher trigger keys off enabled siblings per the slice-4 review fix; disabled siblings don't trigger it.)
+
+### AC4 — Named dashboard CRUD works; each appears in nav and is reachable at /d/:slug ✅
+
+NamedDashboardPage renders at `/d/:slug`. DashboardManagementCard on Services page handles create/reorder/delete + add pinned link. `GET /api/dashboards/slug/:slug` resolves by slug. Tests: NamedDashboardPage (render + not-found), PinnedServiceLink (render + navigate), dashboard backend CRUD (6 tests).
+
+### AC5 — Legacy routes return 404 ✅
+
+All six legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`, `/backups`) plus two redirect aliases (`/monitoring`, `/applications`) removed; `*` catch-all → NotFoundPage. (Behavior verified by inspection; the App-level 404 test deferred from slice 4 is the one open test gap.)
+
+### AC6 — `backups` and `authentik` service types appear in the registry and are configurable ✅
+
+8-type registry: alertmanager, authentik, backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks. `test_services.py` asserts both new types with config fields + secrets. Authentik directory endpoint + message endpoint tested.
+
+### AC7 — Jellyseerr service instances migrated into Jellyfin config ✅
+
+`_migrate_jellyseerr_into_jellyfin` in `settings_store.ensure_defaults()` covers single-Jellyfin merge, multi-Jellyfin first-unpaired, and no-Jellyfin drop. Tests cover all three paths + idempotency.
+
+### AC8 — Fresh install lands on `/` with empty-state CTA ✅
+
+Dashboard renders "Add a service to get started" CTA when no instances exist (Dashboard.test mocks useServiceInstances). ServicesPage strong empty state pre-existed.
+
+### AC9 — Backend green ✅
+
+`cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` → ruff clean, 271 tests pass (2 pre-existing deprecation warnings).
+
+### AC10 — Frontend green ✅
+
+`cd frontend && npm run lint && npm run build && npm run test` → eslint clean, tsc + vite build clean, 92 tests pass (was 72 on main; +20 net from new tabs/dashboards/nav tests, -20 deleted page tests in slice 11).
+
+## Residual risks / known gaps
+
+1. **App-level legacy-404 test missing (C1 from slice 4).** Behavior is correct (all legacy routes removed; catch-all confirmed), but no test asserts `/media` etc. resolve to NotFoundPage. Requires either extracting NotFoundPage or mocking the full App. Tracked from slice 4.
+
+2. **Hooks query globally, not per-instance.** The observability hooks (useAlertmanagerAlerts/Status, useGrafanaStatus, usePrometheusStatus/Targets) and the backup hooks (useBackupJobs/Runs/Alerts) don't accept a serviceId param. JobsTab, AlertsTab, LinksTab, MetricsTab show data for whichever instance the hook resolves as first-configured, not necessarily the one whose page the user is viewing. LinksTab does use `instance.config.base_url` for the specific Grafana deep-link URL. Per-instance scoping is a documented follow-up once the hooks gain the parameter.
+
+3. **Mobile responsive parity not on this branch.** This branch is based on `main`, not on the unmerged `mobile-responsive-parity` branch. The service tabs lift main's DataTable + column-visibility pattern (no MobileCardRow, no SheetForm on ServicePage). The two branches must be reconciled before either merges (rebase services-as-hub-ia on top of mobile-parity, or merge mobile-parity first).
+
+4. **Named dashboards: pinned service links only.** Full widget composition on named dashboards is deferred (the main Dashboard keeps the rich WidgetConfigDialog). Reorder fires two sequential mutations; a failure between could leave sort_orders inconsistent (low risk).
+
+5. **MessagingTab is minimal.** No rich-text toolbar, attachment upload, or queue-status banner (the old compose UI had these). The backend message endpoint accepts core fields only (recipient_emails, subject, html_body) — no multipart attachments yet.
+
+6. **RequestsTab placeholder.** When Jellyseerr is configured on a Jellyfin instance, the Requests tab shows the URL + an honest "coming soon" placeholder. No backend requests endpoint exists yet.
+
+7. **`_resolve_service_record` duplicated** across `monitoring.py` and `authentik_users.py`. A shared-utility extraction is a follow-up.
+
+## Non-goals confirmed
+
+- No per-instance top-level nav entries (instance switcher handles multi-instance).
+- No legacy-route redirects or aliases (clean 404 break).
+- No new widget kinds (pinned service links are a shortcut variant, not a widget kind).
+- No per-user dashboard customization (dashboards are global).
+- No changes to OIDC authentication.
+- No mobile-specific IA divergence.