diff --git a/openspec/changes/services-as-hub-ia/design.md b/openspec/changes/services-as-hub-ia/design.md new file mode 100644 index 0000000..acb3d07 --- /dev/null +++ b/openspec/changes/services-as-hub-ia/design.md @@ -0,0 +1,213 @@ +# 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. diff --git a/openspec/changes/services-as-hub-ia/proposal.md b/openspec/changes/services-as-hub-ia/proposal.md new file mode 100644 index 0000000..9a477dc --- /dev/null +++ b/openspec/changes/services-as-hub-ia/proposal.md @@ -0,0 +1,216 @@ +# 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 | diff --git a/openspec/changes/services-as-hub-ia/spec.md b/openspec/changes/services-as-hub-ia/spec.md new file mode 100644 index 0000000..c63c839 --- /dev/null +++ b/openspec/changes/services-as-hub-ia/spec.md @@ -0,0 +1,171 @@ +# Spec — Services as hub IA + +**Change:** `services-as-hub-ia` +**Phase:** spec +**Date:** 2026-06-26 + +## Scope + +Reorganize the frontend information architecture around services as the hub. +Operational content (Media, Files, Actions, Users, Backups) moves into service- +type-specific tabs on the service page. The top nav shrinks to a small always- +visible core (Main Dashboard, Services, Settings) plus conditional per-type +entries and user-created named dashboards. Two new service types are added +(`backups`, `authentik`); one is absorbed (`jellyseerr` → Jellyfin config). + +This change spans backend (new service types, Authentik client, Jellyseerr +migration, route cleanup) and frontend (service-page IA, top-nav generation, +content migration, named dashboards). + +## Requirements + +### R1 — Top-level navigation + +- R1.1 The top nav contains, in order: Main Dashboard, named dashboards (one + entry each, user-controlled order), conditional service-type entries, Services, + Settings. +- R1.2 Conditional service-type entries appear only when at least one enabled + instance of that type exists. Mapping: + - `jellyfin` → "Media" entry → `/services/jellyfin` + - `ssh_tasks` → "Files" and "Actions" entries → `/services/ssh_tasks` + - `alertmanager` → "Alerts" entry → `/services/alertmanager` + - `grafana` → "Grafana" entry → `/services/grafana` + - `prometheus` → "Prometheus" entry → `/services/prometheus` + - `backups` → "Backups" entry → `/services/backups` + - `authentik` → "Users" entry → `/services/authentik` + - `nextcloud` → no entry (no operational content) +- R1.3 The Main Dashboard is always first and not deletable. +- R1.4 The nav is data-driven (reacts to configured services + dashboards) with + graceful loading/empty states. + +### R2 — Service page IA + +- R2.1 Every service page uses the tab skeleton: Overview, type-specific + content tabs (zero or more), Widgets, Config. +- R2.2 The Overview tab shows service health (connection status, version, last + error) and a primary metric preview (per-type: live sessions for Jellyfin, + active alert count for Alertmanager, etc.). +- R2.3 The Widgets and Config tabs are unchanged from today (widget kinds list, + non-secret config + secrets editors). +- R2.4 Type-specific content tabs: + - `jellyfin`: Media (table + index build controls), Requests (Jellyseerr data) + - `ssh_tasks`: Files (browser + ffprobe + jobs), Actions (saved tasks CRUD + run) + - `backups`: Jobs (jobs + runs + alerts + acknowledge) + - `authentik`: Users (directory + search), Messaging (compose + queue status) + - `alertmanager`: Alerts (summary + list + severity filter) + - `grafana`: Links (configured dashboard deep-links) + - `prometheus`: Metrics (status + PromQL explorer) + - `nextcloud`: no content tabs (Overview + Widgets + Config only) + +### R3 — Instance switcher + +- R3.1 When more than one enabled instance of a service type exists, the service + page renders an instance switcher (dropdown) at the top. +- R3.2 The switcher selects the active instance; all tabs reflect the selected + instance. +- R3.3 The default selected instance is the first enabled instance (or the one + named "primary" if multiple-selection is added later — out of scope here). +- R3.4 Single-instance types do not render the switcher. + +### R4 — Routing + +- R4.1 `/` — Main Dashboard (special, default landing, not deletable). +- R4.2 `/d/:slug` — named dashboard. +- R4.3 `/services` — services admin hub (list of all instances, grouped by type, + with add/edit/delete). +- R4.4 `/services/:type` — service page for the first enabled instance of the + type; redirects (client-side) to `/services/:type/:id` once an instance is + resolved. +- R4.5 `/services/:type/:id` — service page for a specific instance. +- R4.6 `/settings` — settings (unchanged). +- R4.7 Legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`, + `/backups`) return 404 — no redirects, no aliases. + +### R5 — Named dashboards + +- R5.1 Any authenticated user can create, edit, reorder, and delete named + dashboards (global scope — shared across users in this change). +- R5.2 A named dashboard holds an ordered list of widgets (existing widget kinds + only) and pinned service links (shortcut to a service page or specific tab). +- R5.3 Each named dashboard has a user-chosen label and a URL slug derived from + it (uniqueness enforced). +- R5.4 The Main Dashboard is special: it cannot be deleted, is always first in + the nav, and its slug is reserved. + +### R6 — Service type changes + +- R6.1 **NEW `backups`** service type: config holds ingestion source metadata; + the existing REST report endpoint attributes incoming reports to a backups + service instance (first-wins when none is specified). +- R6.2 **NEW `authentik`** service type: config holds base_url; secret holds the + API token. Provides a Users widget and a user-directory endpoint consumed by + the Authentik service page. +- R6.3 **ABSORBED `jellyseerr`**: removed as a service type. Its config fields + (`base_url`, `api_key`) become optional fields on `JellyfinConfig`. Existing + Jellyseerr service instances are migrated into their paired Jellyfin's config + at backend startup; unpaired instances are dropped with a logged warning. + +### R7 — Users → Authentik + +- R7.1 The Jellyfin-backed user directory, Jellyfin-email message compose, and + Jellyseerr-enrichment-of-Jellyfin-users flows are removed. +- R7.2 The Authentik service page Users tab sources users from Authentik's + directory API (paginated, searchable). +- R7.3 The Authentik Messaging tab hosts message-compose, emailing Authentik- + sourced users via the existing SMTP settings and mail queue. +- R7.4 OIDC authentication is unchanged. + +### R8 — Observability + +- R8.1 The Observability page is removed. +- R8.2 Alertmanager alerts, Grafana links, and Prometheus status each render on + their respective service-type pages as content tabs. +- R8.3 There is no cross-service aggregate view built-in. Users who want one + build it via widgets on a named dashboard. + +### R9 — Empty state + +- R9.1 A fresh install (no services, no dashboards) lands on `/` with an empty- + state CTA pointing to `/services`. +- R9.2 The Services hub shows a strong empty state ("Add a service to get + started") when no service instances exist. + +### R10 — Non-regression + +- R10.1 The existing widget system, ServicePage config/secrets editing, settings + (machines, SSH keys), and authentication continue to work. +- R10.2 The backend backup report endpoint, mail queue, and observability + metrics endpoints continue to function (they may gain a service_id + association). +- R10.3 Mobile responsive behavior (already shipped) is preserved across the new + IA. + +## Acceptance criteria + +- AC1 The top nav renders exactly: Main Dashboard, named dashboards, configured- + service-type entries, Services, Settings — and nothing else. +- AC2 Each content tab listed in R2.4 renders its full operational content + inside the corresponding service page. +- AC3 An instance switcher appears when >1 enabled instance of a type exists and + is absent otherwise. +- AC4 Creating, editing, reordering, and deleting a named dashboard works; each + appears in the nav and is reachable at `/d/:slug`. +- AC5 Legacy routes return 404. +- AC6 The `backups` and `authentik` service types appear in the service-type + list and can be configured like any other service. +- AC7 Existing Jellyseerr service instances are migrated into Jellyfin config + (or dropped with a logged warning when unpaired). +- AC8 A fresh install lands on `/` with the empty-state CTA. +- AC9 `cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` is + green. +- AC10 `cd frontend && npm run lint && npm run build && npm run test` is green. + +## Non-goals + +- Per-instance top-level nav entries. +- Legacy-route redirects or aliases. +- New widget kinds (pinned service links are a shortcut variant, not a widget + kind). +- Per-user dashboard customization. +- Changes to OIDC authentication. +- Mobile-specific IA divergence. diff --git a/openspec/changes/services-as-hub-ia/tasks.md b/openspec/changes/services-as-hub-ia/tasks.md new file mode 100644 index 0000000..d9b8835 --- /dev/null +++ b/openspec/changes/services-as-hub-ia/tasks.md @@ -0,0 +1,270 @@ +# Tasks — Services as hub IA + +**Change:** `services-as-hub-ia` +**Phase:** tasks +**Date:** 2026-06-26 + +## Review workload forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines | ~4500–6000 | +| Chained PRs recommended | Yes (12 slices) | +| Chain strategy | stacked-to-main | +| Slice order | 1–3 backend → 4 shell → 5–9 content tabs → 10 dashboards → 11 cleanup → 12 verify | + +Each slice is committed separately. Every slice must leave +`cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` **and** +`cd frontend && npm run lint && npm run build && npm run test` green. Every +touched page gains a Vitest case at the new route and asserts the old route 404s +(where applicable). + +--- + +## Slice 1 — Backend: new service types + Jellyseerr absorption + +**Goal:** Registry reflects the new world. No frontend change yet. + +- [ ] **1.1 Add `backups` integration** + - Files: `backend/src/media_library_viewer_api/integrations/backups.py` (new), + `integrations/registry.py` + - Details: `BackupsConfig` (`ingestion_label: str = "default"`), no secrets, + widget kind `summary` (move `BackupsWidgetSource` adapter to bind the + service_id). Register in `SERVICE_DEFINITIONS`. + +- [ ] **1.2 Add `authentik` integration** + - Files: `integrations/authentik.py` (new), `registry.py` + - Details: `AuthentikConfig` (`base_url: ServiceBaseUrl`, `timeout_seconds`), + secret `api_token` (required). No widget kinds yet. + +- [ ] **1.3 Absorb `jellyseerr` into `JellyfinConfig`** + - Files: `integrations/jellyfin.py`, `integrations/jellyseerr.py` (delete), + `integrations/registry.py`, `integrations/__init__.py` + - Details: Add optional `jellyseerr_url`, `jellyseerr_api_key` to + `JellyfinConfig`. Delete the `jellyseerr` integration module and registry + entry. Update tests. + +- [ ] **1.4 Jellyseerr migration** + - Files: `services/settings_store.py` (`ensure_defaults`) + - Details: On startup, migrate existing `jellyseerr` rows into paired + `jellyfin` instances per the design. Log a warning for unpaired drops. + +- [ ] **1.5 Tests** + - Update `backend/tests/test_services.py`, `test_widgets.py` for the new types + and the migration. Assert registry contains 8 types (alertmanager, authentik, + backups, grafana, jellyfin, nextcloud, prometheus, ssh_tasks). + +--- + +## Slice 2 — Backend: Authentik directory client + endpoint + +- [ ] **2.1 AuthentikClient** + - Files: `clients/authentik.py` (new) + - Details: `users(search, page, page_size) -> {items, total}` against the + Authentik directory API. Reuse the requests-session pattern from + `clients/jellyseerr.py`. Tests: `tests/test_authentik_client.py`. + +- [ ] **2.2 Directory endpoint** + - Files: `routers/authentik_users.py` (new), `main.py` (register router) + - Details: `GET /api/services/authentik/{service_id}/users` proxies to the + client, resolving the service record via the existing dependency. Tests + cover not-configured + unreachable + paginated-success. + +--- + +## Slice 3 — Backend: route cleanup + backups attribution + +- [ ] **3.1 Remove Users router** + - Files: `routers/users.py`, `routers/users_impl.py` (delete), `main.py`, + `dependencies.py` + - Details: Delete the Jellyfin-backed user directory + message-compose router + and its deps. Update `test_api.py` to drop the corresponding tests. + +- [ ] **3.2 Backups service attribution** + - Files: `routers/backups.py`, `services/settings_store.py` + - Details: backup report endpoint accepts optional `?service_id=`; first-wins + association to an enabled `backups` instance when omitted. Dashboard summary + - poller continue to work. + +- [ ] **3.3 Named dashboards backend** + - Files: `models/dashboards.py` (new), `routers/dashboards.py` (new), + `services/settings_store.py` (table + CRUD) + - Details: `named_dashboards` table (id, slug, label, sort_order, payload JSON + of widget+link placements). Endpoints: `GET/POST/PUT/DELETE /api/dashboards`. + Tests in `tests/test_dashboards.py`. + +--- + +## Slice 4 — Frontend: top-nav generation + service-page skeleton + +**Goal:** Data-driven nav + tab-skeleton ServicePage shell. Content tabs are +stubs that say "coming soon" so the rest of the app stays green. + +- [ ] **4.1 Service-type → nav-entry map** + - Files: `frontend/src/integrations/navEntries.ts` (new) + - Details: Static `SERVICE_TYPE_NAV_ENTRIES` map (jellyfin→Media, + ssh_tasks→[Files, Actions], alertmanager→Alerts, etc.). Helper to filter by + configured types. + +- [ ] **4.2 Data-driven nav in `App.tsx`** + - Files: `frontend/src/App.tsx` + - Details: Replace static `navItems` with the memoized list from design. Add + `useDashboards()` and combine with `useServiceInstances()`. Loading skeleton + nav until settled. Legacy routes removed; add 404 catch-all. + +- [ ] **4.3 ServicePage tab skeleton + instance switcher** + - Files: `frontend/src/pages/ServicePage.tsx`, new `pages/service-tabs/` + directory, `pages/ServiceTypePage.tsx` (redirect resolver) + - Details: Refactor ServicePage to render `[Overview, ...content, Widgets, + Config]` from `serviceTabs(serviceType)`. Add `/services/:type` resolver + route. Content tabs are stub components ("coming soon"). Instance switcher + dropdown when siblings > 1. + +- [ ] **4.4 Empty-state CTAs** + - Files: `frontend/src/pages/Dashboard.tsx`, `pages/ServicesPage.tsx` + - Details: Dashboard shows "Add a service" CTA when no services. Services + page strong empty state. + +- [ ] **4.5 Tests** + - Nav-generation tests, service-page-skeleton tests, 404-on-legacy-routes + tests. + +--- + +## Slice 5 — Frontend: Jellyfin content tabs (Media + Requests) + +- [ ] **5.1 MediaTab** + - Files: `pages/service-tabs/MediaTab.tsx` (lift from `pages/Media.tsx`) + - Details: Accept `instance` prop, pass `instance.id` to media hooks. Preserve + the index build controls + mobile card layout. Delete the old `/media` route + and `Applications.tsx` wrapper. + +- [ ] **5.2 RequestsTab (Jellyseerr enrichment)** + - Files: `pages/service-tabs/RequestsTab.tsx` + - Details: Source from the absorbed `jellyseerr_url`/`jellyseerr_api_key` on + the Jellyfin instance. Render request-management data. + +- [ ] **5.3 Tests** + - New tests for MediaTab (instance-scoped), RequestsTab. Delete old Media page + tests. + +--- + +## Slice 6 — Frontend: ssh_tasks content tabs (Files + Actions) + +- [ ] **6.1 FilesTab** + - Files: `pages/service-tabs/FilesTab.tsx` (lift from `FileBrowser.impl.tsx`) + - Details: Accept `instance` prop. Delete old `/files` route + page wrapper. + +- [ ] **6.2 ActionsTab** + - Files: `pages/service-tabs/ActionsTab.tsx` (lift from `Actions.tsx`) + - Details: Accept `instance` prop. Delete old `/actions` route + page. + +- [ ] **6.3 Tests** + +--- + +## Slice 7 — Frontend: backups Jobs tab + +- [ ] **7.1 JobsTab** + - Files: `pages/service-tabs/JobsTab.tsx` (lift from `components/BackupsPage.tsx`) + - Details: Accept `instance` prop, scope queries by `instance.id`. Delete old + `/backups` route + page. + +- [ ] **7.2 Tests** + +--- + +## Slice 8 — Frontend: Authentik Users + Messaging tabs + +- [ ] **8.1 UsersTab** + - Files: `pages/service-tabs/UsersTab.tsx`, `hooks/useAuthentikUsers.ts`, + `api/authentik.ts` + - Details: Directory table + search, sourced from the new endpoint. No + Jellyfin/Jellyseerr enrichment. + +- [ ] **8.2 MessagingTab** + - Files: `pages/service-tabs/MessagingTab.tsx` (lift compose UI from + `UsersPage.impl.tsx`) + - Details: Recipient list sourced from Authentik users. Reuse the mail queue + + SMTP settings. Delete the old `/users` route + UsersPage. + +- [ ] **8.3 Tests** + +--- + +## Slice 9 — Frontend: Observability split (Alerts + Links + Metrics tabs) + +- [ ] **9.1 AlertsTab** + - Files: `pages/service-tabs/AlertsTab.tsx` (lift from `ObservabilityPage.tsx`) + - Details: Alertmanager alerts view, instance-scoped. Delete old + `/observability` route + page. + +- [ ] **9.2 LinksTab + MetricsTab** + - Files: `pages/service-tabs/LinksTab.tsx`, `pages/service-tabs/MetricsTab.tsx` + - Details: Grafana deep-links; Prometheus status + PromQL explorer. Each + instance-scoped. + +- [ ] **9.3 Tests** + +--- + +## Slice 10 — Frontend: named dashboards + +- [ ] **10.1 NamedDashboardPage** + - Files: `pages/NamedDashboardPage.tsx`, `hooks/useDashboards.ts`, + `api/dashboards.ts` + - Details: Render widgets + pinned service links at `/d/:slug`. CRUD via the + new endpoints. + +- [ ] **10.2 Pinned service links** + - Files: `components/PinnedServiceLink.tsx`, integration into the dashboard + config dialog + - Details: Shortcut variant targeting `/services/:type/:id` or a specific tab. + +- [ ] **10.3 Dashboard management UI** + - Files: a new "Manage dashboards" entry on the Services or Settings page + - Details: Create/rename/reorder/delete named dashboards. + +- [ ] **10.4 Tests** + +--- + +## Slice 11 — Cleanup + docs + +- [ ] **11.1 Delete dead code** + - Files: any remaining top-level page wrappers, unused hooks, stale types. + - Details: Confirm no references to removed routes/pages remain. + +- [ ] **11.2 Update `docs/REQUIREMENTS.md`** + - Files: `docs/REQUIREMENTS.md` + - Details: Rewrite the Information Architecture section. Document the service- + type → nav-entry map, the service-page tab skeleton, named dashboards, + routing, and the Users→Authentik + Observability-split decisions. + +- [ ] **11.3 Update `CHANGELOG.md`** + +--- + +## Slice 12 — Verify + +- [ ] **12.1 Cross-route manual pass** + - Details: Walk every service type's page + tabs; walk named dashboards; walk + the empty state; confirm legacy routes 404. + +- [ ] **12.2 Verify report** + - Files: `openspec/changes/services-as-hub-ia/verify-report.md` + - Details: Per-AC evidence (AC1–AC10), tool versions, manual notes, residual + risks. + +--- + +## Notes + +- Slices 1–3 are backend-only; slice 4 is the frontend shell turning on the new + IA with stubs; 5–9 replace stubs with real content; 10 adds named dashboards; + 11–12 close out. +- Slices 5–9 are independent and can be reordered or parallelized across + branches if useful, but each must merge green with its stub replaced. +- The frontend content lifts (5–9) are the bulk of the line count; treat each as + a self-contained review-sized PR.