Files
manage/openspec/changes/services-as-hub-ia/design.md
T
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

8.7 KiB

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):

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):

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:

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:

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:

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.pyGET /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.