Compare commits

...

3 Commits

Author SHA1 Message Date
Developer 9370e52cfc Backend: Authentik directory client + endpoint (Slice 2)
AuthentikClient (clients/authentik.py) wraps Authentik's directory API:
- Bearer-token requests.Session, base_url normalization (rstrip / and
  trailing /api/v3), get() helper mirroring JellyseerrClient.
- users(search, page, page_size) calls GET /api/v3/core/users/ and
  normalizes Authentik's {pagination, results} shape into
  {items, total, page, page_size} for frontend consumption.

Directory endpoint (routers/authentik_users.py):
- GET /api/services/authentik/{service_id}/users resolves the service
  record, builds the client from decrypted api_token, returns the
  normalized user list.
- Graceful error handling matching monitoring.py: not-configured and
  unreachable return {items:[], total:0, error} with 200 (no 500s).
- _resolve_service_record copied in (self-contained; shared-utility
  extraction is a follow-up).

Router registered in main.py.

Tests: 12 new (8 client unit + 4 endpoint integration covering success,
not-configured, unreachable, URL/params). 268 backend tests pass; ruff
clean.

Refs openspec/changes/services-as-hub-ia/ (spec R6.2/R7.2, tasks slice 2).
2026-06-26 18:11:44 +00:00
Developer b3b167c075 Backend: add backups + authentik service types, absorb jellyseerr (Slice 1)
New service types:
- backups: BackupsConfig(ingestion_label), no secrets, summary widget kind.
  Modeled as a service so it can be named/multi-instanced like others.
- authentik: AuthentikConfig(base_url, timeout_seconds), api_token secret
  (required). Directory source for the upcoming Users tab.

Jellyseerr absorption:
- JellyfinConfig gains optional jellyseerr_url + jellyseerr_api_key fields.
- integrations/jellyseerr.py deleted; registry entry removed.
- clients/jellyseerr.py stays (JellyseerrClient still used by enrichment).
- One-time idempotent migration in settings_store.ensure_defaults():
  jellyseerr service rows merge into a paired Jellyfin (exactly-one merges;
  multiple picks first unpaired; none/all-paired drops with a logged
  warning). The api_key is decrypted from secrets before moving to config.

Registry is now 8 types: alertmanager, authentik, backups, grafana,
jellyfin, nextcloud, prometheus, ssh_tasks.

Tests: registry count updated to 8, jellyseerr-absent assertion, new-type
definition assertions, and migration tests (single-jellyfin merge, no-
jellyfin drop, idempotency). 256 backend tests pass; ruff clean.

Refs openspec/changes/services-as-hub-ia/ (spec R6, tasks slice 1).
2026-06-26 18:02:59 +00:00
Developer fe028b0e6f Plan services-as-hub IA rework (OpenSpec change)
Reorganize the app around services as the hub. Operational content
(Media, Files, Actions, Users, Backups) moves into type-specific tabs on
the service page. Top nav shrinks to Main Dashboard + named dashboards +
conditional per-type entries (appear when configured) + Services + Settings.

Decisions (D1-D17): conditional type entries; instance switcher for
multi-instance; Files/Actions into ssh_tasks tabs; Backups = new service
type; Users -> Authentik (in scope); Observability split per type (no
aggregate); main dashboard special at /; named dashboards = widgets +
pinned service links; each named dashboard = top entry; Authentik =
directory source (OIDC unchanged); Messaging -> Authentik users via SMTP;
Jellyseerr absorbed into Jellyfin config; standard tab skeleton
(Overview | content | Widgets | Config); Overview = health + metrics;
routing /services/:type/:id + /d/:slug; legacy routes 404; empty-state
CTAs.

Authentik directory client + endpoint included. 12 chained PRs forecast
(backend types -> frontend shell -> content tabs -> dashboards ->
cleanup -> verify).
2026-06-26 17:16:52 +00:00
15 changed files with 1555 additions and 40 deletions
@@ -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,
}
@@ -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=[],
)
@@ -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,
),
],
)
@@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase): 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 base_url: ServiceBaseUrl
user_id: str = "" user_id: str = ""
timeout_seconds: int = 10 timeout_seconds: int = 10
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase): class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -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=[],
)
@@ -7,10 +7,11 @@ There is no runtime plugin loading.
from __future__ import annotations from __future__ import annotations
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER 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.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA 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.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.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS 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, PROMETHEUS.service_type: PROMETHEUS,
ALERTMANAGER.service_type: ALERTMANAGER, ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN, JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD, NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS, SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
} }
@@ -21,6 +21,9 @@ from media_library_viewer_api.observability import (
record_request, record_request,
set_current_request_id, 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 backups as backups_router
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
from media_library_viewer_api.routers import services as services_router from media_library_viewer_api.routers import services as services_router
@@ -142,6 +145,7 @@ app.include_router(settings_router)
app.include_router(backups_router.router) app.include_router(backups_router.router)
app.include_router(widgets_router.router) app.include_router(widgets_router.router)
app.include_router(services_router.router) app.include_router(services_router.router)
app.include_router(authentik_users_router.router)
@app.get("/api/health") @app.get("/api/health")
@@ -0,0 +1,82 @@
"""Authentik directory router — user lookup for the Authentik service page.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies a paginated directory query. 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 media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.dependencies import get_settings_store
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"])
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")
@@ -8,6 +8,7 @@ in the same UI.
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import sqlite3 import sqlite3
import time import time
import uuid import uuid
@@ -19,6 +20,8 @@ import paramiko
from media_library_viewer_api.models.widgets import _validate_config_keys 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") DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local" LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"] DEFAULT_SERVICES = ["monitoring", "files"]
@@ -415,6 +418,75 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0: if not row or int(row[0]) == 0:
self._seed_local_machine() self._seed_local_machine()
self._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]]: def list_machines(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
+183
View File
@@ -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
+132 -4
View File
@@ -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) == { assert set(SERVICE_DEFINITIONS) == {
"grafana", "grafana",
"prometheus", "prometheus",
"alertmanager", "alertmanager",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"ssh_tasks", "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(): 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("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("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("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
assert get_service_definition("nextcloud").widget_kinds == [] 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"} 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()} types = {item["service_type"] for item in response.json()}
assert types == { assert types == {
"alertmanager", "alertmanager",
"authentik",
"backups",
"grafana", "grafana",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"prometheus", "prometheus",
"ssh_tasks", "ssh_tasks",
@@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url):
@pytest.mark.parametrize( @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): def test_service_base_url_accepts_absolute_urls(service_type):
model = get_service_definition(service_type).config_model 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 len(runs) == 1
assert runs[0]["status"] == "success" assert runs[0]["status"] == "success"
assert runs[0]["stdout_tail"] == "ok" 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") == []
@@ -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/<type>.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.
@@ -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 |
+171
View File
@@ -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.
@@ -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 | ~45006000 |
| Chained PRs recommended | Yes (12 slices) |
| Chain strategy | stacked-to-main |
| Slice order | 13 backend → 4 shell → 59 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 (AC1AC10), tool versions, manual notes, residual
risks.
---
## Notes
- Slices 13 are backend-only; slice 4 is the frontend shell turning on the new
IA with stubs; 59 replace stubs with real content; 10 adds named dashboards;
1112 close out.
- Slices 59 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 (59) are the bulk of the line count; treat each as
a self-contained review-sized PR.