01527ae4f0
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.
214 lines
8.7 KiB
Markdown
214 lines
8.7 KiB
Markdown
# 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.
|