# Design — Services as hub IA **Change:** `services-as-hub-ia` **Phase:** design **Date:** 2026-06-26 ## Context Frontend: React 18 + Vite + TanStack Query/Table + Tailwind v4 + shadcn/ui + react-router-dom. Backend: FastAPI + SQLite settings store + closed service registry at `backend/.../integrations/`. Existing patterns: service definitions in `integrations/.py`, service instances in the `services` SQLite table, widget kinds per service, ServicePage at `/services/:type/:id`. The change is layered: backend service-type changes first (so the registry and API reflect the new world), then frontend IA refactor (so the UI consumes the new shape). ## Architecture ### Backend #### New service types **`backups`** (`integrations/backups.py`, new): ```python class BackupsConfig(ServiceConfigBase): ingestion_label: str = "default" # disambiguates multi-instance ingestion DEFINITION = ServiceDefinition( service_type="backups", name="Backups", config_model=BackupsConfig, secret_fields=[], widget_kinds=[widget_kind(...)], # existing BackupsWidgetSource moves here ) ``` The backup report endpoint gains an optional `?service_id=`. Existing reports (attribute to no service) are associated first-wins to the enabled `backups` instance; the poller and dashboard summary continue to work unchanged. **`authentik`** (`integrations/authentik.py`, new): ```python class AuthentikConfig(ServiceConfigBase): base_url: ServiceBaseUrl timeout_seconds: int = 10 DEFINITION = ServiceDefinition( service_type="authentik", name="Authentik", config_model=AuthentikConfig, secret_fields=[SecretField(key="api_token", label="API token", required=True)], widget_kinds=[], ) ``` A new `AuthentikClient` (`clients/authentik.py`) wraps the directory API: `users(search?, page?, page_size?) -> {items, total}`, returning plain dicts. Endpoint: `GET /api/services/authentik/:service_id/users` proxies to the client. The mail queue and SMTP settings are reused unchanged; the message- compose endpoint accepts Authentik user ids instead of Jellyfin ids. #### Jellyseerr absorption `JellyfinConfig` gains optional fields: ```python class JellyfinConfig(ServiceConfigBase): base_url: ServiceBaseUrl user_id: str = "" timeout_seconds: int = 10 jellyseerr_url: str = "" # NEW (optional) jellyseerr_api_key: str = "" # NEW (optional, non-secret at this layer) ``` The `jellyseerr_api_key` lives in the non-secret config (it is paired with `jellyseerr_url` and treated as a service-level credential, encrypted at rest via the existing secrets mechanism if you prefer — design choice for tasks phase). The `jellyseerr` integration module and registry entry are deleted. **Migration** (`services/settings_store.py` startup hook): 1. On `ensure_defaults()`, if any `jellyseerr` service rows exist: 2. For each, attempt to pair with a `jellyfin` instance. Pairing policy: if exactly one Jellyfin exists, merge. If multiple, pick the one whose existing `jellyseerr_url` is empty (first such). If none can be paired, drop the Jellyseerr row with a logged warning. 3. Move `base_url` and `api_key` onto the paired Jellyfin's config. 4. Delete the `jellyseerr` row. #### Route cleanup `routers/users.py` and its deps are removed. `routers/users_impl.py` removed. `routers/media.py`, `routers/files.py`, `routers/jobs.py`, `routers/backups.py`, `routers/monitoring.py` keep their endpoints (they are consumed by the service tabs) — no change to paths. The dashboard, settings, services routers are unchanged. A new `routers/authentik_users.py` exposes the directory endpoint. ### Frontend #### Top nav generation (`App.tsx`) Replace the static `navItems` array with a data-driven list built from two queries: ```tsx const { data: services = [] } = useServiceInstances(); // existing const { data: dashboards = [] } = useDashboards(); // NEW const navItems = useMemo(() => { const configuredTypes = new Set(services.filter(s => s.enabled).map(s => s.service_type)); return [ { path: "/", label: "Dashboard", icon: LayoutDashboard, always: true }, ...dashboards.map(d => ({ path: `/d/${d.slug}`, label: d.label, icon: LayoutTemplate })), ...SERVICE_TYPE_NAV_ENTRIES .filter(e => configuredTypes.has(e.serviceType)) .map(e => ({ path: `/services/${e.serviceType}`, label: e.label, icon: e.icon })), { path: "/services", label: "Services", icon: Boxes, always: true }, { path: "/settings", label: "Settings", icon: SettingsIcon, always: true }, ]; }, [services, dashboards]); ``` `SERVICE_TYPE_NAV_ENTRIES` is a static map from service type to its conditional nav entry/entries (ssh_tasks contributes two: Files + Actions). The shell shows a loading state until both queries settle. #### Service page IA (`pages/ServicePage.tsx`) Refactor `ServicePage` to render a tab skeleton driven by the service type: ```tsx const tabs = useMemo(() => serviceTabs(serviceType, instance), [...]); // tabs = [Overview, ...contentTabs, Widgets, Config] ``` `serviceTabs` returns the per-type content components (MediaTab, FilesTab, ActionsTab, JobsTab, UsersTab, MessagingTab, AlertsTab, LinksTab, MetricsTab — most pre-existing, lifted from their top-level pages). The instance switcher renders at the top when `instances.length > 1`. Routes: - `/services/:type` → resolve first enabled instance → redirect to `/services/:type/:id` (client-side). - `/services/:type/:id` → render ServicePage with the instance + siblings. #### Named dashboards (`pages/Dashboard.tsx` + new `NamedDashboardPage`) - Main Dashboard at `/` keeps the current shape (widgets + shortcuts, now including pinned service links as a shortcut variant). - New `NamedDashboardPage` at `/d/:slug` renders a saved dashboard record's widgets + pinned links. - New `useDashboards` hook + CRUD endpoints (`GET/POST/PUT/DELETE /api/dashboards`) on the backend; the existing `dashboard_shortcuts` table gains a `dashboard` entity (or a new `named_dashboards` table — design choice for tasks phase). #### Content migration Each content page is lifted into a `*Tab` component consumed by ServicePage: | Old | New | Consumers | |-----|-----|-----------| | `pages/Media.tsx` (Applications) | `pages/service-tabs/MediaTab.tsx` | Jellyfin | | `pages/FileBrowser.impl.tsx` | `pages/service-tabs/FilesTab.tsx` | ssh_tasks | | `pages/Actions.tsx` | `pages/service-tabs/ActionsTab.tsx` | ssh_tasks | | `components/BackupsPage.tsx` | `pages/service-tabs/JobsTab.tsx` | backups | | `pages/UsersPage.impl.tsx` | REMOVED; new `UsersTab` sources Authentik | authentik | | `components/ObservabilityPage.tsx` | SPLIT into `AlertsTab`/`LinksTab`/`MetricsTab` | alertmanager/grafana/prometheus | Tabs accept `{ instance: ServiceInstance }` and read `instance.id` to scope their queries (replacing today's `?jellyfin_service_id=` query param — the service page passes the active instance directly). #### Authentik client + endpoints - `clients/authentik.py` (backend) — directory API wrapper. - `routers/authentik_users.py` — `GET /api/services/authentik/:id/users`. - `pages/service-tabs/UsersTab.tsx` — directory table + search. - `pages/service-tabs/MessagingTab.tsx` — compose + queue status, sourced from Authentik users (replaces the UsersPage compose dialog). ### Key technical risks & mitigations - **Content migration scope.** Each tab lift is a non-trivial move. Slices must be page-by-page so each lands green and reviewable. - **Instance-scoped queries.** Today most content reads a service-id from a query param. The tab components take an `instance` prop and pass `instance.id` to their hooks; the hooks' existing `jellyfinServiceId`/`service_id` params are reused. - **Authentik API field coverage.** The directory API may not expose all fields the old compose flow used (avatars, activity). The UsersTab shows what's available; Messaging uses Authentik emails only. - **Jellyseerr migration ambiguity.** Multiple Jellyfins + multiple Jellyseerrs with no explicit pairing is unresolvable automatically. The migration drops unpaired Jellyseerrs with a logged warning; users reconfigure manually. - **Nav loading flash.** The shell needs services + dashboards before rendering nav. Show a skeleton nav until settled; do not block the route render. ## Trade-offs - **404 over redirect.** Old bookmarks break. Accepted: redirects become tech debt; the new IA is clean. - **No cross-service observability.** A built-in overview is sacrificed; users build their own via named dashboards. Accepted per D6. - **Global dashboards.** No per-user customization in this change. Accepted; multi-tenant is a separate concern. - **Jellyseerr absorbed, not migrated gracefully.** Unpaired Jellyseerrs are dropped. Accepted; the data is recreatable.