Combine both branches into a single coherent branch: - Full mobile responsive parity (useIsMobile, MobileCardRow, SheetForm, .mobile-touch-target, mobile cards, SheetForm forms, 44px targets, dirty-state confirm, TablePagination, refetchIntervalInBackground). - Full services-as-hub IA (data-driven nav, service-page tab skeleton, new service types, Authentik directory + messaging, named dashboards, legacy routes 404, Observability split, Jellyseerr absorbed). Enhancement: service tabs now use mobile-parity primitives: - MediaTab: MobileCardRow below md (title/size/HDR/library/year) + TablePagination; DataTable at md+ (desktop branch preserved). - FilesTab: MobileCardRow below md (name/type/size/modified) + handleRowClick; DataTable at md+. - ServicePage: SheetForm branch below md (open-on-mount, sticky header + save bar, cancel navigates back to /services, dirty-state guard). - Dashboard: single-column + section anchors below md (from mobile-parity) + empty-state CTA (from services-hub). - App.tsx: useIsMobile() replaces inline matchMedia (from mobile-parity) + data-driven useNavItems (from services-hub). - Backup tables (BackupAlerts/Jobs/Runs) already have MobileCardRow from mobile-parity; JobsTab inherits mobile behavior through its sub-components. Conflict resolutions: - Backend: entirely from services-hub (mobile didn't touch it). - Deleted pages (Media/FileBrowser/Actions/Users/UsersPage/Applications/ ObservabilityPage/BackupsPage + hooks/useUsers + tests): kept deleted (services-hub deleted them; content moved into service tabs). - New service-tabs/*: from services-hub, enhanced with mobile patterns. - App.tsx: services-hub's data-driven nav + mobile-parity's useIsMobile. - Dashboard.tsx: merged (services-hub CTA + mobile-parity sections/anchors). - ServicePage.tsx: services-hub's tab skeleton + mobile-parity's SheetForm. - Primitives (useIsMobile/mobile-card/sheet-form/etc.): from mobile-parity. 117 frontend tests pass (mobile-parity's 122 - 5 deleted page tests + services-hub's new tab/dashboard tests); 271 backend tests pass; lint/ build green both sides.
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):
- On
ensure_defaults(), if anyjellyseerrservice rows exist: - For each, attempt to pair with a
jellyfininstance. Pairing policy: if exactly one Jellyfin exists, merge. If multiple, pick the one whose existingjellyseerr_urlis empty (first such). If none can be paired, drop the Jellyseerr row with a logged warning. - Move
base_urlandapi_keyonto the paired Jellyfin's config. - Delete the
jellyseerrrow.
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
NamedDashboardPageat/d/:slugrenders a saved dashboard record's widgets + pinned links. - New
useDashboardshook + CRUD endpoints (GET/POST/PUT/DELETE /api/dashboards) on the backend; the existingdashboard_shortcutstable gains adashboardentity (or a newnamed_dashboardstable — 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
instanceprop and passinstance.idto their hooks; the hooks' existingjellyfinServiceId/service_idparams 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.