Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9370e52cfc | |||
| b3b167c075 | |||
| fe028b0e6f |
@@ -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):
|
||||
"""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):
|
||||
|
||||
@@ -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 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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -21,6 +21,9 @@ 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 services as services_router
|
||||
@@ -142,6 +145,7 @@ app.include_router(settings_router)
|
||||
app.include_router(backups_router.router)
|
||||
app.include_router(widgets_router.router)
|
||||
app.include_router(services_router.router)
|
||||
app.include_router(authentik_users_router.router)
|
||||
|
||||
|
||||
@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
|
||||
|
||||
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"]
|
||||
@@ -415,6 +418,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()
|
||||
|
||||
@@ -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
|
||||
@@ -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") == []
|
||||
|
||||
@@ -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 |
|
||||
@@ -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 | ~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.
|
||||
Reference in New Issue
Block a user