Files
manage/openspec/changes/services-as-hub-ia/proposal.md
T
Developer 01527ae4f0 Rebase services-as-hub-ia onto mobile-responsive-parity
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.
2026-06-26 21:08:51 +00:00

217 lines
11 KiB
Markdown

# 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 |