Compare commits

...

21 Commits

Author SHA1 Message Date
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
Developer b583d5a365 Update verify report: 4 of 5 residual risks resolved
R1 (R4.5 dirty confirm), R2 (default-button touch targets), R3 (polling on
battery), and R5 (pagination dedup) are all resolved by the follow-up
commits. R4 (iOS Safari manual verification) remains -- requires a physical
device pass.
2026-06-26 15:59:00 +00:00
Developer 32fa01cc12 Extract shared TablePagination (dedupe DataTable + Media mobile)
Pull the duplicated pagination footer into a single shared component at
frontend/src/components/ui/table-pagination.tsx. Both the desktop
DataTable (which had an internal DataTablePagination driven by a TanStack
table instance) and the Media mobile card list (which had a standalone
MediaMobilePagination driven by raw PaginationState) now consume it.

The shared component takes the raw primitives (pageIndex, pageSize,
pageCount, totalRows, pageSizeOptions, onPaginationChange, optional
className) so it backs both an adapter view (DataTable extracts state
from its table instance and passes table.setPagination) and a direct
state view (Media passes its pagination state directly). Includes the
44px mobile-touch-target on prev/next buttons (previously only on the
Media mobile variant).

Removes ~90 lines of duplication across data-table.tsx and Media.tsx;
adds the focused 122-line shared component. The DataTable Select imports
are dropped (now unused). 122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #5.
2026-06-26 15:58:12 +00:00
Developer ac703eecd2 Pause TanStack interval refetches when the tab is hidden (D8)
Set refetchIntervalInBackground: false as a QueryClient default so all
interval-based polls (widgets ~30s, message-queue 5s, media build progress
1s) pause when document.visibilityState === 'hidden'. Battery-friendly on
mobile -- the dashboard is the page most likely to be left open on a phone.

The media build-progress poll previously forced refetchIntervalInBackground:
true; that override is removed so it inherits the default. The build keeps
running server-side; the poll resumes and catches up when the user returns
to the tab.

122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #3 (D8 battery follow-up).
2026-06-26 15:48:43 +00:00
Developer d05de0aacd Touch-target pass: 44px min on default-size buttons (WCAG 2.5.5)
Applies .mobile-touch-target to 32 default-size <Button> elements (32px
tall, below the mobile minimum) across 9 files for strict WCAG 2.5.5
compliance: Save, Cancel, Delete, Validate SSH, Run job, Build index,
Update connection, Add service, etc. Plus the shared DialogFooter Cancel
+ Confirm buttons (used by every ConfirmDialog).

The class applies min-height/min-width: 44px only below md
(max-width: 767px); no-op at md+, so desktop sizing is unchanged.

Completes the touch-target audit started in Slice 9 (which covered icon
buttons, size=sm buttons, checkboxes, switches). 122 tests pass; lint/
build green. No new tests (@media queries aren't honored by jsdom).

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #2.
2026-06-26 15:45:34 +00:00
Developer 09b9c45665 SheetForm dirty-state confirm + wire isDirty into all form consumers (R4.5)
SheetForm gains an isDirty prop. When true, any close attempt (Cancel
button, header X, Radix overlay click, Escape) opens a 'Discard changes?'
ConfirmDialog instead of discarding unsaved edits. Radix dismiss callbacks
(onEscapeKeyDown, onPointerDownOutside) are intercepted when dirty so the
guard applies uniformly.

All four form consumers now compute and pass isDirty:
- ServicePage: name/enabled/config differ from the persisted instance.
- Settings machine editor: field-by-field draft vs editingMachine
  (create mode is always dirty; secret write-only fields excluded).
- Message compose: subject non-empty, body differs from default, or
  attachments present.
- WidgetConfigDialog: draft !== null (only draft mode is guarded; list
  mode has nothing to discard).

Tests: 3 new SheetForm dirty-guard cases (prompt on cancel, abort discard,
clean close when not dirty) + one focused dirty-guard test per consumer.
122 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/verify-report.md residual
risk #1.
2026-06-26 15:31:30 +00:00
Developer 32516f6e3b Docs + verify report for mobile responsive parity (Slice 10)
Add Mobile Responsive Design section to docs/REQUIREMENTS.md documenting
the breakpoint policy (single md:768px), hybrid table strategy (cards below
md), SheetForm edit flows, 44px touch targets, dashboard single-column +
anchors, unchanged polling, and HoverEditButton behavior.

Add openspec verify-report.md with per-AC evidence (AC1-AC8), residual
risks (R4.5 dirty-state confirm, default-button touch targets, polling on
battery, iOS Safari manual verification, pagination duplication), and
non-goals confirmation.

All 9 routes fully operable at 375px. 116 frontend tests pass; lint/build
green. Desktop layout unchanged. No backend changes.

Refs openspec/changes/mobile-responsive-parity/ (tasks slice 10).
2026-06-26 14:40:04 +00:00
Developer 30f1b6e6db Touch-target audit: 44px minimum on mobile interactive elements (Slice 9)
Apply the mobile-touch-target CSS class to 40 interactive elements across
12 files. The class applies min-height/min-width:44px only below md
(max-width:767px), satisfying WCAG 2.5.5 / Apple HIG on touch devices.
Desktop behavior is unchanged.

Audit log (before -> after hit-area):
- App.tsx: hamburger/dark-mode/sign-out (32/32/28 -> 44)
- Dashboard.tsx: shortcut open/edit/delete (28 -> 44), enabled switch (18 -> 44)
- Media.tsx: mobile pagination prev/next (28 -> 44)
- FileBrowser.impl.tsx: 'Open Settings' alert button (28 -> 44)
- UsersPage.impl.tsx: compose toolbar bold/italic/link/list (32 -> 44),
  attachment remove button (16 -> 44)
- Settings.tsx: machine switch (18 -> 44), clear/add-machine buttons (28 -> 44),
  reset-db checkboxes x3 (16 -> 44)
- Actions.tsx: 'Add action' button (28 -> 44)
- ServicePage.tsx: service enabled switch (18 -> 44)
- ServicesPage.tsx: service switch/open-link/delete-icon (18/28/32 -> 44)
- ObservabilityPage.tsx: retry + 4 asChild link buttons (28 -> 44)
- WidgetConfigDialog.tsx: 4 icon buttons (32 -> 44), 2 switches (18 -> 44),
  2 add-widget buttons (28 -> 44)
- SessionActivityPanel.tsx: 'Open in Users' button (28 -> 44)

Deliberately skipped: default-size text buttons (32px, borderline), desktop-only
sidebar toggle, DataTable internals (desktop-only below md), Select triggers.
Dashboard anchor pills and HoverEditButton already had the class from Slices 1/2.

No new tests (the class applies via @media which jsdom doesn't honor).
116 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R6, tasks slice 9).
2026-06-26 14:37:40 +00:00
Developer 7808822a55 Mobile message compose + WidgetConfigDialog SheetForms (Slice 8)
Below md, both the message-compose Dialog and the WidgetConfigDialog
render inside a SheetForm instead of a centered Dialog.

Message compose (UsersPage.impl.tsx): the form body (subject, formatting
toolbar, HTML textarea, preview, attachments) is extracted into a shared
composeBody const consumed by both SheetForm (mobile) and Dialog
(desktop). SheetForm wired with title, onSave=handleSend (which already
closes on success per R4.5), onCancel=closeCompose, isPending,
saveDisabled, saveLabel='Send message'.

WidgetConfigDialog: the draftBody const is shared between branches. The
two-mode flow (list vs draft) maps to dynamic SheetForm props -- list
mode ('Dashboard widgets' / Done / Cancel both close), draft mode
('Add/Edit widget' / Save widget / Cancel=reset back to list). The
inline Back/Save buttons are hidden on mobile (!isMobile) since the
SheetForm footer provides them.

Desktop (md+) is token-identical for both components -- the
isComposeMobile (900px) fullscreen styling on compose is preserved for
the 768-900px band. The large diff (~860 lines) is dominated by
extraction/re-indentation of shared form bodies into consts; the
behavioral delta is ~80 lines.

Tests: 3 new (compose mobile send/subject, WidgetConfigDialog desktop +
mobile titles/Done). 116 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 8).
2026-06-26 14:17:30 +00:00
Developer e805c624b2 Mobile Settings: machine editor SheetForm (Slice 7)
Below md, the machine editor Dialog renders as a SheetForm (triggered by
the same Edit/Add buttons via machineDialogOpen state). The shared
MachineEditor body (fields + SSH validate button) renders inside the
sheet; the ConfirmDialog is a sibling outside. Desktop Dialog is
byte-for-byte identical.

No navigation needed on close -- the Settings page content (tabbed cards,
machine list) is always visible behind the sheet, so there is no stranding
risk (unlike ServicePage where the sheet was the whole page).

Added saveDisabled prop to SheetForm (additive, default false) so the
machine editor can gate Save on required fields (name + host for SSH
mode), matching the desktop DialogFooter confirmDisabled semantics.

Scope note: SSHKeyManager is an inline two-panel layout (SelectionRailCard
+ SectionCard), not a dialog, and already stacks responsively via
grid-cols-1 md:grid-cols-[...]. Wrapping it in SheetForm would break its
always-visible selection rail. Left as-is.

Tests: 3 new mobile cases (SheetForm render, save payload, cancel closes)
+ desktop unchanged. 113 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 7).
2026-06-26 13:56:27 +00:00
Developer f7b63fead5 Mobile ServicePage: SheetForm edit + close-on-save navigation (Slice 6)
Below md, ServicePage renders the edit form inside a SheetForm (open on
mount -- this page always edits an existing instance reached via
/services/:type/:id). The sheet body holds Name + Enabled + Connection
fields (no SectionCard wrapper, the sheet is the container) + Delete +
Widgets. At md+ the existing full-page layout renders token-identical.

Refactor: extracted the desktop inline JSX into configFields/widgetsCard/
confirmDelete consts and renamed ServiceConnectionCard ->
ServiceConnectionFields (isMobile prop drops the SectionCard wrapper on
mobile). Desktop output unchanged.

Fixes from Slice 6 review:
- R4.5: save() now closes the sheet on successful save (was staying open).
- Closing the sheet (save or cancel) navigates back to /services -- on
  mobile the sheet IS the page, so closing it would strand the user on a
  blank div. Added useNavigate.
- Strengthened the mobile save test to assert the full payload
  (name, id, enabled, secrets:{}, config), not just name+id.

Out of scope (flagged for verify pass): R4.5 dirty-state outside-click
confirm is a broader SheetForm concern not yet implemented.

Tests: 5 new (2 desktop non-regression + no-dialog, 3 mobile sheet render +
save payload + editable config). useNavigate added to the router mock.
110 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R4, tasks slice 6).
2026-06-26 13:44:50 +00:00
Developer 2eb649eceb Mobile Users + Backups tables: stacked cards + selection (Slice 5)
Below md, the Users directory and the three Backups tables render as
MobileCardRow cards:

- UsersPage: display name primary; username/activity/email fields. Each
  card carries a selection checkbox (44px via mobile-touch-target) in the
  actions slot with stopPropagation so toggling selection does not open
  the drawer; card-body tap still opens the drawer.
- BackupAlertsTable: alert message primary; severity/type/created fields;
  Acknowledge action preserved in actions slot.
- BackupJobsTable: job name primary; source/schedule/last-status fields
  (joins latestRuns into a JobCardRow).
- BackupRunsTable: run job_id primary; status/duration/size/started fields;
  status-filter Select renders above both layouts (preserved on mobile).

Desktop (md+) is byte-for-byte identical for all four components -- the
UsersPage diff is dominated by re-indenting the existing Table into the
isMobile ternary else branch.

Fix from Slice 5 review: MobileCardRow now renders the clickable card as
<div role=button tabIndex=0> with Enter/Space keyboard handling instead
of <button>, so nesting a Radix Checkbox (which renders a <button>) in
the actions slot produces valid HTML. The desktop-parity argument for
<button>-in-<button> did not hold (desktop rows are <tr>, not buttons).

Cross-cutting: useIsMobile hardened with typeof window.matchMedia guard
(safe in real browsers; only changes jsdom crash -> false). The file-local
900px compose hook was renamed useComposeViewport to avoid collision with
the shared 768px useIsMobile.

Tests: BackupJobsTable test file added (was untested), UsersPage mobile
selection round-trip + stopPropagation, mobile card render across all
four components. 105 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 5).
2026-06-26 13:24:19 +00:00
Developer 2076ab76fa Mobile FileBrowser: stacked cards for file list (Slice 4)
Below md, the file table renders as MobileCardRow cards: name as primary,
plus type/size/modified. Whole-card tap triggers handleRowClick (dir rows
navigate into the directory; file rows select for ffprobe preview). No
pagination needed (FileBrowser does not paginate).

The ext column is omitted from the card -- the extension is already visible
in the filename itself, so it's redundant on mobile and would waste card
space.

Path bar / breadcrumbs / Open / Refresh live outside the table and already
stack on mobile via existing md:flex-row. ffprobe and Jobs sections are
unaffected.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props.

Tests: 4 new covering mobile card render + dir-tap navigation + path
controls present + desktop DataTable. matchMedia mocked per-breakpoint.
98 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 4).
2026-06-26 12:53:25 +00:00
Developer 2e3e7b3850 Mobile Media table: stacked cards + mobile pagination (Slice 3)
Below md, the Media DataTable renders as MobileCardRow cards: title as
primary, plus size/HDR/library/year (3-5 fields, null-safe). Card tap
navigates to /files?path=... (same handleRowClick as desktop). The TanStack
column-visibility toggle is absent below md (the card picks the fields).

Pagination is preserved via a standalone MediaMobilePagination component
that mirrors DataTablePagination semantics (rows count, page-size select,
page indicator, prev/next with correct disabled states) off the raw
PaginationState. The duplication is flagged tech debt -- extracting a shared
TablePagination is a follow-up, out of scope for this slice.

Desktop (md+) is byte-for-byte identical: the isMobile===false branch
renders the same DataTable with the same props. enableRowSelection state is
vestigial (no batch consumer on either path); navigation is the correct
primary mobile interaction.

Tests: 5 new covering mobile cards + hidden column toggle + pagination +
card-tap navigation, and desktop DataTable + column toggle. matchMedia
mocked per-breakpoint. 94 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R3, tasks slice 3).
2026-06-26 12:43:09 +00:00
Developer c447dfe68d Mobile dashboard layout: single column + section anchors (Slice 2)
Below md, widgets render in a single column grouped by section
(Observability / Media / Backups / Custom) with a horizontally-scrollable
anchor pill bar that smooth-scrolls to each section. Empty sections are
omitted from both the bar and the list. scroll-mt-16 keeps the sticky
TopBar from covering section headings.

Section mapping: observability (alertmanager/prometheus/grafana services),
media (jellyfin), backups (builtin backups widget), custom (static,
ssh_tasks, nextcloud, unknown, orphans). Within each section the user's
configured sort order is preserved.

Desktop (md+) is byte-for-byte unchanged -- the isMobile===false branch
emits the original visibleWidgets.map(...) sequence with no wrapper.
useServiceInstances() is cache-shared with WidgetInstanceCard (same
TanStack key), so no extra network requests.

Tests: 3 new (6 total) covering mobile single-column + anchors, desktop
non-regression, and scrollIntoView jump. matchMedia mocked per-breakpoint.
89 tests pass; lint/build green.

Refs openspec/changes/mobile-responsive-parity/ (spec R7, tasks slice 2).
2026-06-26 12:25:42 +00:00
Developer 688a18af22 Add mobile responsive primitives (Slice 1)
Foundation for the mobile-responsive-parity change. Adds:
- useIsMobile() hook: single source of truth for the md:768px cut (SSR-safe)
- MobileCardRow<T>: stacked card list for wide tables below md, with getRowId
  stable keys, primary field as title, optional onRowClick + actions slot
- SheetForm: full-height form host (h-[100dvh], flex column, sticky header +
  footer via flex not position:sticky) for mobile edit flows
- HoverEditButton: mobile prop (default 'always') -- always visible below md,
  hover-revealed at md+; desktop aesthetic preserved
- .mobile-touch-target CSS utility: 44x44 min hit area below md (WCAG 2.5.5)
- App.tsx refactored to use useIsMobile(); shell behavior unchanged

Tests cover primary/field rendering, onRowClick, actions slot, empty rows,
no-primary, stable keys (no duplicate-key warning), and all SheetForm
interactions. 86 tests pass; lint/build green.

MobileCardRow key strategy: uses getRowId when provided (falls back to index);
per design §trade-offs, fields are declared per-table to prioritize by mobile
importance rather than auto-derived from column defs.

Refs openspec/changes/mobile-responsive-parity/ (design §Shared primitives,
spec R1/R5/R6, tasks slice 1).
2026-06-26 12:09:21 +00:00
Developer 18ee77a4e4 Plan mobile responsive parity (OpenSpec change)
Add proposal/spec/design/tasks for full mobile parity across all 9 routes.
Decisions: hybrid tables (cards below md for big four), Sheet-based forms,
always-visible edit affordance, 44px touch targets, single-column dashboard
with anchors, responsive web only (no PWA), phone portrait at md:768px cut.
Polling unchanged (risk flagged). Delivery: 10 chained PRs, primitives first.
2026-06-26 11:49:47 +00:00
Developer 3d331e4c72 Make service connection config editable on service page
The service detail page showed non-secret connection config (base_url,
user_id, username, timeout_seconds) as read-only. Render schema-driven
editable inputs (reusing the create-dialog pattern) with a draftConfig
state hydrated from the instance, and unify the save button to persist
both config and secrets. Number fields render as type=number; the base_url
schema description surfaces as helper text.
2026-06-26 09:52:43 +00:00
Developer eebc86a52b Enforce http(s) schema on service base_url fields
Add a shared ServiceBaseUrl type (BeforeValidator + Field description) in
integrations/base.py and apply it to base_url across all six service configs
(grafana, prometheus, alertmanager, jellyfin, jellyseerr, nextcloud). Missing
http:// or https:// schema now fails fast with a clear 422 instead of breaking
HTTP clients silently. Tests cover reject/accept cases; REQUIREMENTS updated.
2026-06-26 09:52:20 +00:00
Developer 56b919ea1f style(frontend/api): apply formatter to backups.ts and client.ts
Convert indentation to tabs and reflow long import lines. No behavior
change.

Co-authored-by: el Gentleman <gentleman@pi.local>
2026-06-26 09:10:32 +00:00
Developer 648320abfd chore(project-map): refresh .pi-map role/arch summaries
Regenerate project map artifacts across backend, docs, openspec, and
root to refresh role descriptions and architectural notes after recent
service-registry and observability changes.

Co-authored-by: el Gentleman <gentleman@pi.local>
2026-06-26 09:10:19 +00:00
141 changed files with 8144 additions and 4446 deletions
+2 -1
View File
@@ -16,7 +16,7 @@ dir: .
Trust boundary: index routes, map orients, source decides.
## role
Root project directory for "Manage," a media library viewer and server operations tool with a FastAPI backend and React frontend.
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
## parent
-
## children
@@ -66,6 +66,7 @@ Root project directory for "Manage," a media library viewer and server operation
- docker-compose.dev.yml
- docker-compose.observability.yml
- docker-compose.yml
- swap-pane
- token-usage-output.txt
## links
index: ./.pi-map.index.md
+8 -7
View File
@@ -18,25 +18,26 @@ index: ./.pi-map.index.md
Trust boundary: index routes, map orients, source decides.
## role
Root project directory for "Manage," a media library viewer and server operations tool with a FastAPI backend and React frontend.
Root project configuration and orchestration package for a media library management application with observability, defining Docker deployment stacks, environment templates, and project documentation.
## files
- .dockerignore | Specifies files and directories to exclude from Docker build context to reduce image size and improve build performance | dep: Docker
- .env.example | Provides a template of environment variables for configuring application hosts, backend settings, OIDC authentication, SMTP, Grafana, and alerting across a Docker Compose deployment.
- .gitignore | Configures Git to ignore Python artifacts, virtual environments, secrets, editor files, frontend builds, and tool-specific metadata from version control.
- AGENTS.md | Provides project-specific guidance for AI agents working on a media library viewer application with FastAPI backend and Vite React frontend | dep: FastAPI, Vite, React, Docker Compose, uvicorn, pytest, Ruff, TypeScript, Python 3.11
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage project across versions.
- CONTRIBUTING.md | Provides guidelines for setting up a development environment, coding standards, validation steps, and contribution requirements for a Streamlit-based media library viewer application. | dep: python, venv, pip, streamlit, py_compile
- CHANGELOG.md | Documents notable changes, breaking changes, and migration steps for the Manage application across recent versions.
- CONTRIBUTING.md | Provides contribution guidelines and setup instructions for the Manage project's backend (FastAPI) and frontend (React) codebases. | dep: FastAPI, React, Vite, TypeScript, Ruff, pytest, Docker Compose, Tailwind CSS, TanStack Query
- LICENSE | Provides the MIT open-source software license terms for the project
- README.md | This file is the project README, serving as the primary documentation and setup guide for "Manage," a media and server operations tool with a FastAPI + React architecture. | dep: FastAPI, React, Jellyfin, Docker Compose, SQLite, Prometheus, Grafana, Alertmanager
- README.md | Project README documenting a media and server operations tool with Jellyfin integration, SSH file inspection, and server monitoring capabilities. | dep: FastAPI, React, TypeScript, Docker Compose, SQLite, Traefik, OIDC/Authentik, Jellyfin, Prometheus, Grafana, Alertmanager
- context.md | Documentation file providing a historical and architectural overview of an observability stack (Prometheus, Grafana, Loki, Alertmanager) for a containerized media management application. | dep: Prometheus, Grafana, Loki, Alertmanager, Grafana Alloy, Node Exporter, Docker Compose, FastAPI
- docker-compose.dev.yml | Defines a development Docker Compose stack for a backend (FastAPI/Uvicorn) and frontend (Vite) application with hot-reload and disabled authentication. | dep: uvicorn, Docker
- docker-compose.observability.yml | Defines an optional standalone Docker Compose observability stack with Prometheus, Loki, Grafana, Alertmanager, Alloy, and Node Exporter for monitoring hosts without the main Manage application. | dep: prom/prometheus, grafana/loki, grafana/alloy, grafana/grafana, prom/alertmanager, prom/node-exporter, Traefik
- docker-compose.yml | Defines a Docker Compose production stack for a backend and frontend application with OIDC authentication, Traefik reverse proxy routing, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, SMTP server, external observability stack (Prometheus/Grafana/Loki/Alertmanager)
- docker-compose.yml | Defines a production Docker Compose stack for a backend-frontend application with OIDC authentication, Traefik routing, TLS, and Prometheus metrics exposure. | dep: Traefik, OIDC provider, Docker, Vite, external observability stack
- swap-pane | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- token-usage-output.txt | Displays a detailed token usage and cost analysis report for an AI coding session, including breakdowns by category, tool usage, cache efficiency, subagent costs, and pricing comparisons.
## arch
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for reverse proxy routing, OIDC for authentication, and an optional observability stack (Prometheus, Grafana, Loki, Alertmanager).
Containerized full-stack architecture using Docker Compose for orchestration, Traefik for production routing/TLS, dual dev/production environments, and an optional standalone observability stack (Prometheus/Grafana/Loki/Alertmanager).
## tags
docker, grafana, application, compose, fastapi, prometheus, alertmanager, loki
docker, grafana, application, fastapi, compose, prometheus, backend, frontend
## symbols
-
## workflows
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend
## role
FastAPI backend service providing REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+3 -3
View File
@@ -4,13 +4,13 @@ dir: backend
index: backend/.pi-map.index.md
## role
FastAPI backend service providing REST API endpoints for Jellyfin media browsing, SSH file inspection, and server monitoring.
FastAPI backend service providing Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected API endpoints.
## files
- Dockerfile | Builds a Docker container for a Python 3.11 backend API service using uvicorn | dep: python:3.11-slim, pip, uvicorn, pyproject.toml-based package
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints of a FastAPI backend for Jellyfin media browsing, SSH file inspection, and server monitoring. | dep: FastAPI, uvicorn, pydantic-settings, Jellyfin, Jellyseerr, SSH, Docker Compose, Alertmanager, Prometheus, Grafana, Authentik/OIDC
- README.md | Documentation describing the setup, configuration, Docker deployment, and API endpoints for a FastAPI backend that provides Jellyfin media browsing, SSH file inspection, server monitoring, and JWT-protected access. | dep: FastAPI, uvicorn, pydantic-settings, Docker Compose
- pyproject.toml | Project configuration file defining dependencies, build system, linting, and testing settings for a FastAPI media library viewer backend. | dep: FastAPI, uvicorn, pydantic-settings, paramiko, requests, python-dotenv, pandas, PyJWT, prometheus-client, python-json-logger, cryptography, hatchling, ruff, pytest, httpx
## arch
Containerized Python 3.11 service using FastAPI with uvicorn ASGI server, configured via pyproject.toml with linting/testing pipelines and Docker-based deployment.
Containerized Python 3.11 REST API using FastAPI/uvicorn with JWT authentication, configured via pyproject.toml with linting and testing support.
## tags
uvicorn, fastapi, python, backend, pyproject, settings, docker, api
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend/src
## role
Root source directory serving as the entry point for the backend application.
Root source directory serving as the main entry point and organizational container for the backend application.
## parent
index: backend/.pi-map.index.md
map: backend/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: backend/src
index: backend/src/.pi-map.index.md
## role
Root source directory serving as the entry point for the backend application.
Root source directory serving as the main entry point and organizational container for the backend application.
## files
## arch
Likely follows a modular/framework-dependent architecture (e.g., MVC, layered, or hexagonal); contains core application setup, routing, configuration, and business logic modules.
Standard layered architecture entry point, typically initializing the application, wiring up configurations, modules, routes, and services (e.g., MVC, modular monolith, or Clean Architecture).
## tags
-
## symbols
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api
## role
FastAPI backend providing authenticated API endpoints and remote SSH job execution for viewing and managing media library metadata from Jellyfin/Jellyseerr services.
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
## parent
index: backend/src/.pi-map.index.md
map: backend/src/.pi-map.md
@@ -4,12 +4,12 @@ dir: backend/src/media_library_viewer_api
index: backend/src/media_library_viewer_api/.pi-map.index.md
## role
FastAPI backend providing authenticated API endpoints and remote SSH job execution for viewing and managing media library metadata from Jellyfin/Jellyseerr services.
FastAPI backend service that provides authenticated, observable APIs for viewing and managing media library data across Jellyfin, Jellyseerr, and remote SSH/local systems.
## files
- __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh
- auth.py | Implements OIDC/JWT and API key authentication for a FastAPI backend with middleware-based route protection. | exp: func:_normalize_issuer_url(issuer_url: str) → str, call:issuer_url.rstrip, func:get_oidc_metadata(issuer_url: str) → dict[str, Any], call:_normalize_issuer_url, call:urljoin, call:requests.get, call:response.raise_for_status, call:response.json, call:isinstance, raise:RuntimeError, func:get_jwk_client(jwks_url: str) → PyJWKClient, call:PyJWKClient, func:_split_audience(audience: str) → list[str], call:item.strip, call:audience.split, func:validate_auth_settings(settings: Settings) → None, raise:RuntimeError, func:validate_bearer_jwt(authorization: str | None, settings) → dict[str, Any], call:get_settings, call:validate_auth_settings, call:authorization.partition, call:scheme.lower, call:token.strip, call:_normalize_issuer_url, call:get_oidc_metadata, call:settings.oidc_jwks_url.strip, call:str, call:metadata.get, call:get_jwk_client, call:jwk_client.get_signing_key_from_jwt, call:_split_audience, call:jwt.decode, call:list, call:len, call:int, raise:PermissionError, raise:RuntimeError, func:require_jwt_auth(request: Request, call_next), call:get_settings, call:path.startswith, call:call_next, call:validate_bearer_jwt, call:request.headers.get, call:logger.warning, call:JSONResponse, call:str, call:logger.exception, call:claims.get, call:isinstance, func:get_api_key() → str, call:get_settings_store, call:store.get_settings, call:settings.get, call:secrets.token_urlsafe, call:store.update_setting, func:require_api_key(authorization) → str, call:get_api_key, call:secrets.compare_digest, raise:HTTPException | dep: logging, secrets, functools, typing, urllib.parse, jwt, requests, fastapi, fastapi.responses, jwt.exceptions, media_library_viewer_api.config, media_library_viewer_api.dependencies
- config.py | Defines a flat pydantic-settings configuration model that loads application settings from environment variables and .env files with cached access. | exp: class:Settings, func:_find_env_file() → str | None, call:Path.cwd, call:candidate.is_file, call:str, call:(directory / ".git").exists, func:get_settings() → Settings, call:_find_env_file, call:Settings, call:logger.info, call:describe_settings | dep: logging, functools, pathlib, pydantic_settings, media_library_viewer_api.logging_utils, functools.lru_cache, pathlib.Path, pydantic_settings.BaseSettings
- dependencies.py | Provides FastAPI dependency injection for Jellyfin/Jellyseerr clients and SSH/local command clients, resolving service instances from query parameters or configuration with caching and fallback logic. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:RuntimeError, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:RuntimeError, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:RuntimeError | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
- dependencies.py | Provides FastAPI dependency injection functions for resolving and caching service clients (Jellyfin, Jellyseerr, SSH/Local) and settings based on request query parameters. | exp: func:_request_machine_id(request: Request | None) → str | None, call:request.query_params.get, func:_request_jellyfin_service_id(request: Request | None) → str | None, call:request.query_params.get, func:_service_record(store: SettingsStore, service_type: str, service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:candidate.get, call:store.list_services, call:s.get, call:row.get, call:decrypt_secrets, call:logger.exception, func:_jellyfin_client_for(cache_key: tuple[str, str, str]) → JellyfinClient, call:logger.info, call:url.rstrip, call:JellyfinClient, func:_ssh_client_for(cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None]) → RemoteSSHClient, call:logger.info, call:RemoteSSHClient, call:client.connect, call:str, call:message.lower, call:logger.exception, raise:HTTPException, func:_resolve_machine(service: str, request) → dict[str, Any] | None, call:get_settings_store, call:_request_machine_id, call:store.get_machine, call:machine.get, call:store.list_machines_for_service, func:get_jellyfin_client(request) → JellyfinClient, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:_jellyfin_client_for, raise:HTTPException, func:get_jellyseerr_client(request) → JellyseerrClient | None, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:logger.info, call:str, call:service.get("config", {}).get, call:service.get("secrets", {}).get, call:JellyseerrClient, func:_ssh_client_from_machine_config(machine: dict[str, Any], store) → RemoteSSHClient, call:get_settings_store, call:get_settings, call:str(machine.get("ssh_key_id") or "").strip, call:machine.get, call:store.get_ssh_key, call:ssh_key.get, call:int, call:_ssh_client_for, func:get_ssh_client(request), call:get_settings_store, call:_request_machine_id, call:store.get_machine_config, call:_resolve_machine, call:str(machine.get("mode") or "local").strip().lower, call:machine.get, call:logger.info, call:LocalCommandClient, call:_ssh_client_from_machine_config, call:get_settings, call:_ssh_client_for, raise:HTTPException, func:get_mail_queue() → MailQueue, call:_get_mail_queue, func:get_settings_store() → SettingsStore, call:_get_settings_store, func:get_user_id(request) → str, call:get_settings_store, call:_request_jellyfin_service_id, call:_service_record, call:service.get("config", {}).get, call:str, call:get_jellyfin_client, call:client.users, raise:HTTPException | dep: logging, functools, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.clients.local, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.secrets
- jobs.py | Defines template-based remote SSH jobs with shell-safe rendering for a media library viewer API. | exp: class:JobTemplate, method:render(self, values: Mapping[str, str]) → str, call:shlex.quote, call:values.items, call:self.command_template.format, func:run_job(ssh: RemoteSSHClient, job_key: str, path: str, timeout) → CommandResult, call:template.render, call:logger.info, call:ssh.run | dep: logging, shlex, dataclasses, typing, media_library_viewer_api.clients.ssh
- logging_utils.py | Configures structured JSON/text logging with secret-safe settings introspection and log field sanitization for a backend application. | exp: func:_json_formatter() → logging.Formatter, call:jsonlogger.JsonFormatter, func:_text_formatter() → logging.Formatter, call:logging.Formatter, func:configure_logging(level_name, log_format) → int, call:(level_name or os.getenv("LOG_LEVEL", "INFO")).upper, call:os.getenv, call:getattr, call:(log_format or os.getenv("LOG_FORMAT", "text")).lower, call:logging.StreamHandler, call:handler.setFormatter, call:_json_formatter, call:_text_formatter, call:logging.basicConfig, call:root.setLevel, call:logging.getLogger("media_library_viewer_api").setLevel, call:logging.getLogger("uvicorn").setLevel, call:logging.getLogger("uvicorn.error").setLevel, call:logging.getLogger("uvicorn.access").setLevel, call:logging.getLogger("paramiko").setLevel, call:logging.getLogger("urllib3").setLevel, func:_sanitize_url(url: str | None) → str, call:urlsplit, call:url.strip, call:url.rstrip, func:describe_settings(settings: object) → dict[str, str], call:str(getattr(settings, "log_level", "INFO") or "INFO").upper, call:getattr, call:str(getattr(settings, "log_format", "text") or "text").lower, call:bool, call:_sanitize_url, func:sanitize_log_extra(extra: dict[str, Any] | None) → dict[str, Any], call:extra.items, call:key.lower, call:any, call:lower_key.endswith | dep: logging, os, typing, urllib.parse, pythonjsonlogger
- main.py | FastAPI application entrypoint that configures middleware, registers routers, manages startup/shutdown lifecycle, and exposes health/version/metrics endpoints. | exp: func:lifespan(app: FastAPI), call:get_settings, call:configure_logging, call:validate_auth_settings, call:validate_encryption_key, call:logger.info, call:describe_settings, call:get_settings_store().ensure_defaults, call:logger.exception, call:get_mail_queue, call:get_backup_poller, call:mail_queue.start, call:backup_poller.start, call:backup_poller.stop, call:mail_queue.stop, func:enforce_jwt_auth(request: Request, call_next), call:call_next, call:require_jwt_auth, func:log_requests(request: Request, call_next), call:time.perf_counter, call:get_request_id, call:set_current_request_id, call:sanitize_log_extra, call:logger.info, call:call_next, call:logger.exception, call:record_request, call:round, func:health_check() → dict[str, str], call:logger.debug, func:version_info() → dict[str, str], call:logger.debug, call:get_version_info, func:metrics() → Response, call:metrics_payload, call:FastAPIResponse | dep: logging, time, contextlib, uvicorn, fastapi, fastapi.middleware.cors, fastapi.responses, media_library_viewer_api.auth, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.logging_utils, media_library_viewer_api.observability, media_library_viewer_api.routers, media_library_viewer_api.routers.settings, .services.backup_poller, .version, media_library_viewer_api.services.secrets, media_library_viewer_api.services.backup_poller, media_library_viewer_api.version
@@ -18,7 +18,7 @@ FastAPI backend providing authenticated API endpoints and remote SSH job executi
- utils.py | Provides UI-framework-independent formatting helpers and ffprobe output summarizers for video, audio, and subtitle streams. | exp: func:ticks_to_minutes(ticks: int | None) → int | None, call:round, func:human_size(num: int | float | None) → str, call:float, call:int, func:timestamp_to_local(ts: float | None) → str, call:datetime.fromtimestamp(ts).strftime, func:is_known_video_file(path: str | None) → bool, call:PurePosixPath(path).suffix.lower, func:format_duration(seconds: str | int | float | None) → str, call:float, call:str, call:int, func:format_bitrate(bit_rate: str | int | float | None) → str, call:float, call:str, func:_tags(stream: dict[str, Any]) → dict[str, Any], call:stream.get, func:_disposition(stream: dict[str, Any], key: str) → str, call:(stream.get("disposition") or {}).get, call:stream.get, func:_side_data_types(stream: dict[str, Any]) → str, call:stream.get, call:item.get, call:values.append, call:", ".join, func:ffprobe_format_summary(ffprobe: dict[str, Any]) → dict[str, str], call:ffprobe.get, call:fmt.get, call:format_duration, call:human_size, call:float, call:format_bitrate, call:str, func:summarize_video_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:_side_data_types, call:tags.get, call:_disposition, func:summarize_audio_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:format_bitrate, call:tags.get, call:_disposition, func:summarize_subtitle_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:stream.get, call:_tags, call:rows.append, call:tags.get, call:_disposition, func:summarize_streams(ffprobe: dict[str, Any]) → list[dict[str, Any]], call:ffprobe.get, call:rows.append, call:format_bitrate, call:stream.get("tags", {}).get | dep: datetime, pathlib, typing
- version.py | Provides version retrieval and formatting utilities for a backend service, falling back through environment variables, package metadata, and default values. | exp: func:get_backend_version() → str, call:os.getenv("APP_VERSION", "").strip, call:package_version, func:get_backend_build_info() → str, call:os.getenv("APP_BUILD_INFO", "").strip, call:os.getenv("GIT_COMMIT", "").strip, call:os.getenv("BUILD_COMMIT", "").strip, func:format_version_label(version: str, build_info: str) → str, call:version.strip, call:build_info.strip, func:get_version_info() → dict[str, str], call:get_backend_version, call:get_backend_build_info, call:format_version_label | dep: os, importlib.metadata
## arch
Layered FastAPI architecture using dependency injection (providers/clients), Pydantic settings configuration, OIDC/API-key middleware authentication, and template-based remote SSH execution with structured observability (Prometheus/JSON logging).
Layered FastAPI architecture using dependency injection for cached service clients, Pydantic settings configuration, middleware-based OIDC/JWT/API-key authentication, Prometheus observability with structured logging, and template-based remote job execution.
## tags
call:, settings, call:get, request, get, client, call:str, id
## symbols
@@ -0,0 +1,115 @@
"""Authentik directory API client.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). This client wraps the Authentik REST API for browsing the user directory
with pagination and search. OIDC authentication is unchanged — this client is
for the directory, not SSO.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class AuthentikClient:
"""Small wrapper around the Authentik core directory API."""
def __init__(self, base_url: str, api_token: str, timeout: float = 10.0):
if not base_url:
raise ValueError("Authentik base_url is required")
if not api_token:
raise ValueError("Authentik API token is required")
self.base_url = base_url.rstrip("/")
if self.base_url.endswith("/api/v3"):
self.base_url = self.base_url[:-7]
self.api_token = api_token
self.timeout = timeout
self.session = requests.Session()
self.session.headers.update(
{
"Authorization": f"Bearer {api_token}",
"Accept": "application/json",
}
)
def get(self, path: str, **params: Any) -> Any:
"""GET an Authentik endpoint and include useful response text on errors."""
clean_params = {k: v for k, v in params.items() if v is not None and v != ""}
logger.debug("Authentik GET %s params=%s", path, sorted(clean_params.keys()))
response = self.session.get(
f"{self.base_url}/api/v3{path}",
params=clean_params,
timeout=self.timeout,
)
try:
response.raise_for_status()
except requests.HTTPError as exc:
detail = response.text[:500]
logger.warning(
"Authentik GET %s failed status=%s url=%s",
path,
response.status_code,
response.url,
)
raise requests.HTTPError(
f"{response.status_code} for {response.url}: {detail}",
response=response,
) from exc
logger.debug("Authentik GET %s ok status=%s", path, response.status_code)
return response.json()
def users(
self,
search: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
"""Return a normalized page of Authentik users.
Calls ``GET /api/v3/core/users/`` and normalizes the paginated
Authentik response into ``{items, total, page, page_size}``. Each item
is the raw Authentik user dict (pk, username, name, email, avatar, …)
so the frontend can pick the fields it needs.
"""
payload = self.get(
"/core/users/",
search=search,
page=page,
page_size=page_size,
)
if not isinstance(payload, dict):
logger.warning("Authentik users payload was not a dict: %s", type(payload).__name__)
return {"items": [], "total": 0, "page": page, "page_size": page_size}
results = payload.get("results")
items: list[dict[str, Any]] = (
[item for item in results if isinstance(item, dict)] if isinstance(results, list) else []
)
pagination = payload.get("pagination") or {}
total = 0
if isinstance(pagination, dict):
try:
total = int(pagination.get("count") or 0)
except (TypeError, ValueError):
total = 0
logger.info(
"Authentik users page=%s page_size=%s -> %s items (total=%s)",
page,
page_size,
len(items),
total,
)
return {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
}
@@ -18,7 +18,6 @@ from typing import Any
from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
@@ -178,22 +177,6 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient:
return _jellyfin_client_for(cache_key)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store()
service_id = _request_jellyfin_service_id(request)
service = _service_record(store, "jellyseerr", service_id)
if service is None:
logger.info("Jellyseerr client not configured (no jellyseerr service)")
return None
base_url = str(service.get("config", {}).get("base_url") or "")
api_key = str(service.get("secrets", {}).get("api_key") or "")
if not base_url or not api_key:
logger.info("Jellyseerr service is missing base_url or api_key")
return None
return JellyseerrClient(base_url, api_key)
def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict."""
store = store or get_settings_store()
@@ -2,7 +2,7 @@
dir: backend/src/media_library_viewer_api/integrations
## role
Defines and registers third-party service integrations (e.g., Alertmanager, Grafana, Jellyfin, Prometheus) with typed configs, secrets, and widget definitions for the media library viewer API.
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
## parent
index: backend/src/media_library_viewer_api/.pi-map.index.md
map: backend/src/media_library_viewer_api/.pi-map.md
@@ -4,22 +4,22 @@ dir: backend/src/media_library_viewer_api/integrations
index: backend/src/media_library_viewer_api/integrations/.pi-map.index.md
## role
Defines and registers third-party service integrations (e.g., Alertmanager, Grafana, Jellyfin, Prometheus) with typed configs, secrets, and widget definitions for the media library viewer API.
Provides a plugin-style integration framework for declaring and registering external service connections (e.g., Grafana, Jellyfin, Prometheus) with config schemas, secrets, and widget definitions for the media library viewer API.
## files
- __init__.py | Defines a closed registry module for service integrations.
- alertmanager.py | Defines the Alertmanager service integration, including connection config, widget definitions, and alert summarization logic. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
- base.py | Defines base classes and utilities for creating compile-time service integration definitions with Pydantic-based config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
- grafana.py | Defines the Grafana service configuration, secret fields, and widget types for integration with the media library viewer API. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
- jellyfin.py | Defines the Jellyfin media server service configuration and activity widget definition for a media library viewer API. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
- jellyseerr.py | Defines a service configuration and metadata for Jellyseerr, a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
- alertmanager.py | Defines the Alertmanager service integration configuration, widget definitions, and alert summarization logic for a media library viewer API. | exp: class:AlertmanagerConfig, class:AlertmanagerAlertsWidgetConfig, func:summarize_alerts(alerts: list[dict[str, Any]], severity_filter) → dict[str, Any], call:alert.get, call:labels.get, call:by_severity.get, call:open_alerts.append, call:annotations.get, call:open_alerts.sort, call:len | dep: typing, media_library_viewer_api.integrations.base
- base.py | Provides abstract base classes and dataclass definitions for declaring external service integrations with config schemas, secret fields, and widget kinds. | exp: class:ServiceConfigBase, class:WidgetConfigBase, class:SecretField, class:WidgetKind, class:ServiceDefinition, method:widget_kind(self, kind: str) → WidgetKind | None, func:_validate_service_base_url(value: Any) → str, call:isinstance, call:value.strip, call:text.lower, call:lowered.startswith, raise:ValueError, func:widget_kind(kind: str, name: str, description: str, model_cls: type[WidgetConfigBase], default_config, refresh_interval_ms) → WidgetKind, call:model_cls.model_json_schema, call:schema.pop, call:WidgetKind, call:dict, func:validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) → dict[str, Any], call:model_cls.model_validate, call:instance.model_dump | dep: dataclasses, typing, pydantic
- grafana.py | Defines the Grafana service integration configuration, including connection settings, API key secrets, and dashboard link widget support. | exp: class:GrafanaConfig, class:GrafanaLinkWidgetConfig | dep: media_library_viewer_api.integrations.base
- jellyfin.py | Defines the Jellyfin service configuration and activity widget for a media library viewer API integration. | exp: class:JellyfinConfig, class:JellyfinActivityWidgetConfig | dep: media_library_viewer_api.integrations.base
- jellyseerr.py | Defines the Jellyseerr service configuration and its service definition schema for integration as a request management companion to Jellyfin. | exp: class:JellyseerrConfig | dep: media_library_viewer_api.integrations.base
- nextcloud.py | Defines the Nextcloud service configuration model and service definition for integration into the media library viewer API. | exp: class:NextcloudConfig | dep: media_library_viewer_api.integrations.base
- prometheus.py | Defines the service configuration, widget types, and service definition for integrating Prometheus as a metrics data source. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
- prometheus.py | Defines the service definition and configuration models for integrating Prometheus as a metrics data source with PromQL query widgets. | exp: class:PrometheusConfig, class:PrometheusMetricWidgetConfig | dep: media_library_viewer_api.integrations.base
- registry.py | Provides a closed registry of service definitions with lookup and enumeration functions. | exp: func:list_service_types() → list[str], call:sorted, func:get_service_definition(service_type: str) → ServiceDefinition | None, call:SERVICE_DEFINITIONS.get, func:get_widget_kind(service_type: str, widget_kind: str) → WidgetKind | None, call:get_service_definition, call:definition.widget_kind, func:require_service_definition(service_type: str) → ServiceDefinition, call:get_service_definition, raise:ValueError | dep: media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.grafana, media_library_viewer_api.integrations.jellyfin, media_library_viewer_api.integrations.jellyseerr, media_library_viewer_api.integrations.nextcloud, media_library_viewer_api.integrations.prometheus, media_library_viewer_api.integrations.ssh_tasks
- ssh_tasks.py | Defines a service configuration for an SSH task runner that executes reusable saved tasks over SSH and records run history. | exp: class:SshTasksConfig, class:SshTaskOutputWidgetConfig | dep: media_library_viewer_api.integrations.base
## arch
Plugin/registry pattern using Pydantic-based config schemas with a closed registry for compile-time service definition lookups, base class extension for per-service widgets and secrets, and a modular file-per-integration structure.
Registry pattern with abstract base classes and dataclass-driven configuration models; each integration is a self-contained module registered in a closed registry that supports lookup, enumeration, and declarative widget/kind definitions.
## tags
config, service, widget, integrations, media_library_viewer_api, base, definition, kind
config, service, widget, integrations, base, media_library_viewer_api, definition, kind
## symbols
- AlertmanagerConfig
- AlertmanagerAlertsWidgetConfig
@@ -6,6 +6,7 @@ from typing import Any
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
class AlertmanagerConfig(ServiceConfigBase):
"""Non-secret Alertmanager connection config."""
base_url: str
base_url: ServiceBaseUrl
timeout_seconds: int = 5
@@ -0,0 +1,35 @@
"""Authentik service definition.
Authentik is the user-directory source (replacing the Jellyfin-backed Users
page). Its directory API is queried via :class:`AuthentikClient` and surfaced
on the Authentik service page (Users + Messaging tabs). OIDC authentication
is unchanged -- this service type is for the directory, not SSO.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class AuthentikConfig(ServiceConfigBase):
"""Non-secret Authentik connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 10
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
description="User directory and identity provider integration.",
config_model=AuthentikConfig,
secret_fields=[
SecretField(key="api_token", label="API token", required=True),
],
widget_kinds=[],
)
@@ -0,0 +1,48 @@
"""Backups service definition.
Backups is modeled as a service type so it can be configured, named, and
multi-instanced like other services. Reports arrive via the existing REST
report endpoint; the ``ingestion_label`` disambiguates multi-instance
ingestion.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class BackupsConfig(ServiceConfigBase):
"""Non-secret Backups connection config."""
ingestion_label: str = "default"
class BackupsSummaryWidgetConfig(WidgetConfigBase):
"""Backup dashboard summary (jobs, runs, alerts)."""
# No user-overridable fields; the widget reads the internal backup tables.
pass
DEFINITION = ServiceDefinition(
service_type="backups",
name="Backups",
description="Backup job monitoring, run history, and alerting.",
config_model=BackupsConfig,
secret_fields=[],
widget_kinds=[
widget_kind(
kind="summary",
name="Summary",
description="Backup job summary and active alerts.",
model_cls=BackupsSummaryWidgetConfig,
default_config={},
refresh_interval_ms=60_000,
),
],
)
@@ -16,9 +16,37 @@ map. There is no runtime plugin loading.
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from typing import Annotated, Any
from pydantic import BaseModel
from pydantic import BaseModel, BeforeValidator, Field
def _validate_service_base_url(value: Any) -> str:
"""Require an absolute http(s) URL for service ``base_url`` fields.
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients
because ``requests`` treats them as relative paths, so we fail fast with a
clear error instead of letting the call silently malfunction.
"""
if not isinstance(value, str):
raise ValueError("base_url must be a string starting with http:// or https://")
text = value.strip()
if not text:
raise ValueError("base_url must not be empty")
lowered = text.lower()
if not (lowered.startswith("http://") or lowered.startswith("https://")):
raise ValueError("base_url must start with http:// or https:// (include the schema)")
return text
#: Shared annotated type for service ``base_url`` fields. applying the validator
#: uniformly across every integration so missing schemas are rejected at the
#: config boundary with a helpful message.
ServiceBaseUrl = Annotated[
str,
Field(description="Absolute URL including the http:// or https:// schema."),
BeforeValidator(_validate_service_base_url),
]
class ServiceConfigBase(BaseModel):
@@ -26,6 +54,9 @@ class ServiceConfigBase(BaseModel):
Subclass this in each integration module and declare the connection fields.
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
Connection URLs should use the :data:`ServiceBaseUrl` type so the
``http(s)://`` schema is enforced consistently across integrations.
"""
@@ -4,6 +4,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
class GrafanaConfig(ServiceConfigBase):
"""Non-secret Grafana connection config."""
base_url: str
base_url: ServiceBaseUrl
timeout_seconds: int = 5
@@ -4,6 +4,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
@@ -12,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config."""
"""Non-secret Jellyfin connection config.
base_url: str
The optional ``jellyseerr_url`` / ``jellyseerr_api_key`` fields carry the
paired Jellyseerr companion config, absorbed from the former standalone
``jellyseerr`` service type (see OpenSpec change ``services-as-hub-ia``).
When both are set, the Jellyfin service page renders a Requests tab backed
by Jellyseerr.
"""
base_url: ServiceBaseUrl
user_id: str = ""
timeout_seconds: int = 10
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -1,32 +0,0 @@
"""Jellyseerr service definition.
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
own service type so multiple Jellyseerr instances are supported independently of
Jellyfin. It provides no dashboard widgets today.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: str
DEFINITION = ServiceDefinition(
service_type="jellyseerr",
name="Jellyseerr",
description="Request management companion to Jellyfin.",
config_model=JellyseerrConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[],
)
@@ -8,6 +8,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
@@ -16,7 +17,7 @@ from media_library_viewer_api.integrations.base import (
class NextcloudConfig(ServiceConfigBase):
"""Non-secret Nextcloud connection config."""
base_url: str
base_url: ServiceBaseUrl
username: str = ""
@@ -4,6 +4,7 @@ from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
@@ -14,7 +15,7 @@ from media_library_viewer_api.integrations.base import (
class PrometheusConfig(ServiceConfigBase):
"""Non-secret Prometheus connection config."""
base_url: str
base_url: ServiceBaseUrl
timeout_seconds: int = 10
@@ -7,10 +7,11 @@ There is no runtime plugin loading.
from __future__ import annotations
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER
from media_library_viewer_api.integrations.authentik import DEFINITION as AUTHENTIK
from media_library_viewer_api.integrations.backups import DEFINITION as BACKUPS
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
@@ -20,9 +21,10 @@ SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
PROMETHEUS.service_type: PROMETHEUS,
ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
}
+7 -2
View File
@@ -21,8 +21,12 @@ from media_library_viewer_api.observability import (
record_request,
set_current_request_id,
)
from media_library_viewer_api.routers import (
authentik_users as authentik_users_router,
)
from media_library_viewer_api.routers import backups as backups_router
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users
from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks
from media_library_viewer_api.routers import dashboards as dashboards_router
from media_library_viewer_api.routers import services as services_router
from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router
@@ -136,12 +140,13 @@ app.include_router(monitoring.router)
app.include_router(media.router)
app.include_router(files.router)
app.include_router(jobs.router)
app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)
app.include_router(backups_router.router)
app.include_router(widgets_router.router)
app.include_router(dashboards_router.router)
app.include_router(services_router.router)
app.include_router(authentik_users_router.router)
@app.get("/api/health")
@@ -0,0 +1,29 @@
"""Pydantic models for the named-dashboards API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field
class NamedDashboardInput(BaseModel):
"""Input for create/update of a named dashboard."""
id: str | None = None
label: str = Field(default="Dashboard")
slug: str | None = None
sort_order: int = 0
payload: dict[str, Any] = Field(default_factory=dict)
class NamedDashboard(BaseModel):
"""A named dashboard record."""
id: str
label: str
slug: str
sort_order: int
payload: dict[str, Any]
created_at: int
updated_at: int
@@ -0,0 +1,144 @@
"""Authentik directory + messaging router.
Resolves an ``authentik`` service instance from the registry, builds an
:class:`AuthentikClient` from its config + decrypted ``api_token`` secret, and
proxies paginated directory queries plus message-compose (email enqueue).
Graceful "not configured" / "unreachable" payloads (matching the monitoring
router's pattern) so the UI always renders.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mailer import validate_smtp_settings
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/services/authentik", tags=["authentik"])
class MessageRequest(BaseModel):
"""Compose-request body for the Authentik messaging endpoint."""
recipient_emails: list[str]
subject: str
html_body: str
def _resolve_service_record(
store: SettingsStore,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested authentik instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled ``authentik`` instance is configured.
"""
service_type = "authentik"
if service_id:
row = store.get_service(service_id)
if not row or row.get("service_type") != service_type:
return None
if not row.get("enabled", True):
return None
return build_service_record(store, row)
for row in store.list_services(service_type):
if row.get("enabled", True):
return build_service_record(store, row)
return None
def _build_client(service: ServiceRecord) -> AuthentikClient:
base_url = str(service.config.get("base_url") or "").rstrip("/")
api_token = str(service.secrets.get("api_token") or "")
try:
timeout = float(service.config.get("timeout_seconds") or 10)
except (TypeError, ValueError):
timeout = 10.0
return AuthentikClient(base_url=base_url, api_token=api_token, timeout=timeout)
def _empty(error: str) -> dict[str, Any]:
return {"items": [], "total": 0, "page": 1, "page_size": 50, "error": error}
@router.get("/{service_id}/users")
def get_authentik_users(
service_id: str,
search: str | None = None,
page: int = 1,
page_size: int = 50,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Paginated Authentik user directory for a specific service instance."""
service = _resolve_service_record(store, service_id)
if service is None:
logger.info("Authentik users requested but no enabled authentik service for id=%s", service_id)
return _empty("Authentik service not configured")
try:
client = _build_client(service)
return client.users(search=search, page=page, page_size=page_size)
except Exception:
logger.exception("Authentik users query failed for service %s", service_id)
return _empty("Authentik is unreachable")
@router.get("/{service_id}/message/status")
def get_authentik_message_status(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Mail-queue status snapshot for the Authentik messaging tab."""
service = _resolve_service_record(store, service_id)
if service is None:
return {"state": "stopped", "worker_running": False, "error": "Authentik service not configured"}
return mail_queue.status()
@router.post("/{service_id}/message")
def post_authentik_message(
service_id: str,
body: MessageRequest,
store: SettingsStore = Depends(get_settings_store),
mail_queue: MailQueue = Depends(get_mail_queue),
) -> dict[str, Any]:
"""Enqueue an email to Authentik-sourced recipients via the mail queue."""
service = _resolve_service_record(store, service_id)
if service is None:
return {"status": "error", "error": "Authentik service not configured"}
recipients = [r.strip() for r in body.recipient_emails if r.strip()]
if not recipients:
return {"status": "error", "error": "No recipients with valid email addresses."}
settings = get_settings()
try:
validate_smtp_settings(settings)
except ValueError as exc:
return {"status": "error", "error": f"SMTP settings invalid: {exc}"}
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=body.subject,
html_body=body.html_body,
)
logger.info("Authentik message enqueued for service %s (%d recipients)", service_id, len(recipients))
return {
"status": "queued",
"request_id": request_id,
"recipient_count": len(recipients),
}
@@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store
router = APIRouter(prefix="/api/backups", tags=["backups"])
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]:
def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str:
"""Return the service_id for backup attribution.
First-wins: if no explicit service_id is given, pick the first enabled
``backups`` service instance (spec R6.1). Returns an empty string when
none is configured (backward-compatible with pre-service reports).
"""
if explicit:
return explicit
candidates = store.list_services("backups")
for svc in candidates:
if svc.get("enabled"):
return svc["id"]
return ""
def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]:
job = store.get_backup_job_by_name(report.name)
if not job:
job = store.upsert_backup_job(
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source,
"target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
}
)
elif report.schedule_interval_seconds:
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source,
"target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
}
)
job = store.get_backup_job(job["id"])
@@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
@router.post("/report")
def post_backup_report(
report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key),
) -> BackupRunResponse:
job = _get_or_create_job(store, report)
resolved_service_id = _resolve_backup_service_id(store, service_id)
job = _get_or_create_job(store, report, resolved_service_id)
# Check for duplicate (same job + started_at within 1s)
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
@@ -88,10 +108,12 @@ def post_backup_report(
@router.post("/report/start")
def post_backup_start(
report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key),
) -> BackupRunResponse:
job = _get_or_create_job(store, report)
resolved_service_id = _resolve_backup_service_id(store, service_id)
job = _get_or_create_job(store, report, resolved_service_id)
run_data = {
"job_id": job["id"],
@@ -0,0 +1,53 @@
"""Named dashboards CRUD router."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/dashboards", tags=["dashboards"])
@router.get("")
def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]:
rows = store.list_dashboards()
return [NamedDashboard(**row) for row in rows]
@router.get("/slug/{slug}")
def get_dashboard_by_slug(slug: str, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
row = store.get_dashboard_by_slug(slug)
if not row:
raise HTTPException(status_code=404, detail="Dashboard not found")
return NamedDashboard(**row)
@router.post("")
def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard:
row = store.upsert_dashboard(body.model_dump())
return NamedDashboard(**row)
@router.put("/{dashboard_id}")
def update_dashboard(
dashboard_id: str,
body: NamedDashboardInput,
store: SettingsStore = Depends(get_settings_store),
) -> NamedDashboard:
if not store.get_dashboard(dashboard_id):
raise HTTPException(status_code=404, detail="Dashboard not found")
if body.id and body.id != dashboard_id:
raise HTTPException(status_code=400, detail="ID mismatch")
row = store.upsert_dashboard(body.model_dump(), dashboard_id)
return NamedDashboard(**row)
@router.delete("/{dashboard_id}")
def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]:
if not store.get_dashboard(dashboard_id):
raise HTTPException(status_code=404, detail="Dashboard not found")
store.delete_dashboard(dashboard_id)
return {"status": "deleted"}
@@ -1 +0,0 @@
from .users_impl import * # noqa: F401,F403
@@ -1,389 +0,0 @@
"""Users router — Jellyfin list plus optional Jellyseerr enrichment."""
from __future__ import annotations
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.jellyseerr import JellyseerrClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
)
from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/users", tags=["users"])
_PERMISSION_FLAGS = [
(2, "admin"),
(4, "manage_settings"),
(8, "manage_users"),
(16, "manage_requests"),
(32, "request"),
(64, "vote"),
(128, "auto_approve"),
(256, "auto_approve_movie"),
(512, "auto_approve_tv"),
(1024, "request_4k"),
(2048, "request_4k_movie"),
(4096, "request_4k_tv"),
(8192, "request_advanced"),
(16384, "request_view"),
(32768, "auto_approve_4k"),
(65536, "auto_approve_4k_movie"),
(131072, "auto_approve_4k_tv"),
(262144, "request_movie"),
(524288, "request_tv"),
(1048576, "manage_issues"),
(2097152, "view_issues"),
]
_USER_TYPES = {
1: "plex",
2: "local",
3: "jellyfin",
4: "emby",
}
def _safe_int(value: Any) -> int:
try:
return int(value)
except (TypeError, ValueError):
return 0
def _permission_labels(permissions: int) -> list[str]:
labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit]
return labels or ["none"]
def _role_label(permissions: int) -> str:
if permissions & 2:
return "admin"
if permissions & (4 | 8 | 16):
return "manager"
if permissions & (32 | 64 | 128):
return "requester"
return "user"
def _account_type(user_type: Any) -> str:
return _USER_TYPES.get(_safe_int(user_type), "unknown")
def _merge_users(
jellyfin_users: list[dict[str, Any]],
jellyseerr_jellyfin_users: list[dict[str, Any]] | None,
jellyseerr_users: list[dict[str, Any]] | None,
jellyseerr_client: JellyseerrClient | None,
) -> dict[str, Any]:
def _normalize(value: Any) -> str:
return str(value or "").strip().lower()
def _looks_like_email(value: Any) -> bool:
text = str(value or "").strip()
return bool(text and "@" in text and " " not in text)
def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
if _looks_like_email(value):
return source, str(value).strip()
return "", ""
def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]:
for source, value in candidates:
text = str(value or "").strip()
if text:
return source, text
return "", ""
def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str:
return ", ".join(
[
f"name={name_source or 'none'}",
f"email={email_source or 'none'}",
f"avatar={avatar_source or 'none'}",
f"access={access_source or 'none'}",
]
)
def _lookup_keys(item: dict[str, Any]) -> list[str]:
return [
_normalize(item.get("id")),
_normalize(item.get("Id")),
_normalize(item.get("userId")),
_normalize(item.get("user_id")),
_normalize(item.get("jellyfinUserId")),
_normalize(item.get("jellyfin_user_id")),
_normalize(item.get("jellyfinUsername")),
_normalize(item.get("jellyfin_username")),
_normalize(item.get("username")),
_normalize(item.get("displayName")),
_normalize(item.get("display_name")),
]
linked_by_jellyfin_id: dict[str, dict[str, Any]] = {}
for item in jellyseerr_jellyfin_users or []:
for key in (
item.get("id"),
item.get("Id"),
item.get("userId"),
item.get("user_id"),
item.get("jellyfinUserId"),
item.get("jellyfin_user_id"),
):
normalized = _normalize(key)
if normalized:
linked_by_jellyfin_id[normalized] = item
seerr_by_key: dict[str, dict[str, Any]] = {}
for item in jellyseerr_users or []:
for key in _lookup_keys(item):
if key:
seerr_by_key[key] = item
items: list[dict[str, Any]] = []
enriched_count = 0
for user in jellyfin_users:
jellyfin_id = str(user.get("Id") or user.get("id") or "")
jellyfin_name = str(user.get("Name") or user.get("name") or "")
jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id))
seerr_user = None
for candidate in [
jellyfin_name,
(jf_link or {}).get("jellyfinUsername"),
(jf_link or {}).get("jellyfin_username"),
(jf_link or {}).get("username"),
(jf_link or {}).get("displayName"),
(jf_link or {}).get("display_name"),
]:
seerr_user = seerr_by_key.get(_normalize(candidate))
if seerr_user:
break
email_source, email = _pick_source_and_value(
[
("jellyseerr:user", (seerr_user or {}).get("email")),
("jellyseerr:jellyfin", (jf_link or {}).get("email")),
]
)
avatar_source, avatar = _first_value(
[
("jellyseerr:user", (seerr_user or {}).get("avatar")),
("jellyseerr:jellyfin", (jf_link or {}).get("thumb")),
("jellyseerr:jellyfin", (jf_link or {}).get("avatar")),
]
)
if avatar and jellyseerr_client:
avatar = jellyseerr_client.absolute_url(avatar)
permissions = _safe_int((seerr_user or {}).get("permissions"))
user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type"))
role = _role_label(permissions)
access_source = "jellyseerr:user" if seerr_user else ""
name_source = "jellyfin"
summary = _source_summary(name_source, email_source, avatar_source, access_source)
if seerr_user or jf_link:
enriched_count += 1
items.append(
{
"jellyfin_id": jellyfin_id,
"username": jellyfin_name,
"display_name": jellyfin_name,
"email": email,
"email_source": email_source,
"avatar": avatar,
"avatar_source": avatar_source,
"contactable": bool(email),
"source": summary,
"source_summary": summary,
"name_source": name_source,
"access_source": access_source,
"jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId"))
or None,
"jellyseerr_username": str(
(seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or ""
),
"user_type": user_type or None,
"user_type_label": _account_type(user_type),
"role": role,
"permissions": permissions,
"permissions_label": ", ".join(_permission_labels(permissions)),
"request_count": _safe_int((seerr_user or {}).get("requestCount")) or None,
}
)
logger.info(
"Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s",
len(jellyfin_users),
len(jellyseerr_jellyfin_users or []),
len(jellyseerr_users or []),
enriched_count,
)
return {
"items": items,
"total": len(items),
"jellyseerr_configured": jellyseerr_client is not None,
"jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users),
"jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []),
"jellyseerr_user_count": len(jellyseerr_users or []),
"enriched_count": enriched_count,
}
@router.get("")
def get_users(
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
) -> dict[str, Any]:
"""Return the known users, enriched with Jellyseerr data when available."""
jellyfin_users = jellyfin.users()
logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users))
jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None
jellyseerr_users: list[dict[str, Any]] | None = None
jellyseerr_error = ""
if jellyseerr:
try:
jellyseerr_jellyfin_users = jellyseerr.jellyfin_users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr Jellyfin-linked user fetch failed")
jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}"
try:
jellyseerr_users = jellyseerr.users()
except Exception as exc: # pragma: no cover - network fallback
logger.exception("Jellyseerr user list fetch failed")
jellyseerr_error = (
f"{jellyseerr_error}; " if jellyseerr_error else ""
) + f"Jellyseerr user list fetch failed: {exc}"
result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr)
result["jellyseerr_error"] = jellyseerr_error
logger.info(
"Users response total=%s configured=%s available=%s enriched=%s error=%s",
result["total"],
result["jellyseerr_configured"],
result["jellyseerr_available"],
result["enriched_count"],
bool(jellyseerr_error),
)
return result
@router.get("/message/status")
def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]:
"""Return the current background email queue status."""
return mail_queue.status()
@router.post("/message", status_code=status.HTTP_202_ACCEPTED)
async def post_user_message(
recipient_ids: str = Form(...),
subject: str = Form(...),
html_body: str = Form(""),
text_body: str = Form(""),
attachments: list[UploadFile] | None = File(default=None),
jellyfin: JellyfinClient = Depends(get_jellyfin_client),
jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client),
mail_queue=Depends(get_mail_queue),
) -> dict[str, Any]:
"""Queue a single email to the selected users without blocking the API."""
try:
requested_ids = json.loads(recipient_ids)
except json.JSONDecodeError as exc:
raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc
if not isinstance(requested_ids, list):
raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list")
cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()]
if not cleaned_ids:
raise HTTPException(status_code=400, detail="At least one recipient is required")
subject = subject.strip()
if not subject:
raise HTTPException(status_code=400, detail="Subject is required")
directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr)
users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])}
recipients: list[str] = []
recipient_labels: list[str] = []
skipped: list[dict[str, str]] = []
for user_id in cleaned_ids:
item = users_by_id.get(user_id)
if not item:
skipped.append({"jellyfin_id": user_id, "reason": "not found"})
continue
email = str(item.get("email") or "").strip()
if not email:
skipped.append({"jellyfin_id": user_id, "reason": "missing email"})
continue
recipients.append(email)
recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>")
if not recipients:
raise HTTPException(status_code=400, detail="No selected users have a deliverable email address")
settings = get_settings()
validate_smtp_settings(settings)
queue_status = mail_queue.status()
if not queue_status["worker_running"]:
raise HTTPException(status_code=503, detail="Email queue worker is not running")
attachment_payloads: list[EmailAttachment] = []
for upload in attachments or []:
data = await upload.read()
if not data:
continue
attachment_payloads.append(
EmailAttachment(
filename=upload.filename or "attachment",
content_type=upload.content_type or "application/octet-stream",
data=data,
)
)
request_id = mail_queue.enqueue(
settings=settings,
recipients=recipients,
subject=subject,
html_body=html_body,
text_body=text_body,
attachments=attachment_payloads,
)
from_address = (
str(getattr(settings, "smtp_from_address", "") or "").strip()
or str(getattr(settings, "smtp_username", "") or "").strip()
)
logger.info(
"Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s",
request_id,
subject,
len(recipients),
len(attachment_payloads),
len(skipped),
)
return {
"status": "queued",
"request_id": request_id,
"from_address": from_address,
"recipient_count": len(recipients),
"attachment_count": len(attachment_payloads),
"subject": subject,
"recipient_labels": recipient_labels,
"skipped": skipped,
}
@@ -8,6 +8,7 @@ in the same UI.
from __future__ import annotations
import json
import logging
import sqlite3
import time
import uuid
@@ -19,6 +20,8 @@ import paramiko
from media_library_viewer_api.models.widgets import _validate_config_keys
logger = logging.getLogger(__name__)
DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"]
@@ -172,6 +175,9 @@ class SettingsStore:
created_at INTEGER NOT NULL
)
""")
backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()}
if "service_id" not in backup_job_cols:
conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT")
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_runs (
id TEXT PRIMARY KEY,
@@ -244,6 +250,19 @@ class SettingsStore:
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS named_dashboards (
id TEXT PRIMARY KEY,
label TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
sort_order INTEGER NOT NULL DEFAULT 0,
payload_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
@staticmethod
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
@@ -415,6 +434,75 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
self._migrate_jellyseerr_into_jellyfin()
def _migrate_jellyseerr_into_jellyfin(self) -> None:
"""Absorb standalone ``jellyseerr`` services into their paired Jellyfin.
Idempotent: once no ``jellyseerr`` rows remain the method is a no-op.
Pairing policy: exactly-one Jellyfin merges; multiple picks the first
Jellyfin whose ``jellyseerr_url`` is still empty; no Jellyfin or all
paired -> drop with a logged warning.
"""
from media_library_viewer_api.services.secrets import decrypt_value
self.init_schema()
jellyseerr_rows: list[sqlite3.Row] = []
with self.connect() as conn:
jellyseerr_rows = conn.execute(
"SELECT * FROM services WHERE service_type = 'jellyseerr' ORDER BY name ASC"
).fetchall()
if not jellyseerr_rows:
return
jellyfin_rows = self.list_services("jellyfin")
for js_row in jellyseerr_rows:
js_config = json.loads(js_row["config_json"] or "{}")
js_secrets = json.loads(js_row["secrets_json"] or "{}")
js_url = str(js_config.get("base_url", "")).strip()
js_api_key = str(js_secrets.get("api_key", "")).strip()
# Decrypt the api_key (secrets are stored encrypted; config is plaintext).
if js_api_key:
try:
js_api_key = decrypt_value(js_api_key)
except Exception:
logger.warning("could not decrypt jellyseerr api_key for %r", js_row["name"])
js_api_key = ""
js_name = js_row["name"]
target = None
if len(jellyfin_rows) == 1:
target = jellyfin_rows[0]
elif len(jellyfin_rows) > 1:
for jf in jellyfin_rows:
if not str(jf["config"].get("jellyseerr_url", "")).strip():
target = jf
break
if target:
merged_config = dict(target["config"])
merged_config["jellyseerr_url"] = js_url
merged_config["jellyseerr_api_key"] = js_api_key
self.upsert_service(
{
"id": target["id"],
"service_type": "jellyfin",
"name": target["name"],
"config": merged_config,
"enabled": target["enabled"],
},
secret_values={"api_key": str(target["secrets"].get("api_key", ""))},
)
logger.info("migrated jellyseerr service %r into jellyfin service %r", js_name, target["name"])
else:
logger.warning(
"dropped unpaired jellyseerr service %r; reconfigure manually on the Jellyfin instance",
js_name,
)
with self.connect() as conn:
conn.execute("DELETE FROM services WHERE id = ?", (js_row["id"],))
conn.commit()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
@@ -892,6 +980,7 @@ class SettingsStore:
"source": row["source"],
"target": row["target"],
"schedule_interval_seconds": row["schedule_interval_seconds"],
"service_id": row["service_id"],
"created_at": row["created_at"],
}
@@ -910,12 +999,18 @@ class SettingsStore:
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
if schedule_interval_seconds is not None:
schedule_interval_seconds = int(schedule_interval_seconds)
service_id = str(
payload.get("service_id")
if payload.get("service_id") is not None
else (current or {}).get("service_id", "") or ""
).strip()
return {
"id": job_id,
"name": name,
"source": source,
"target": target,
"schedule_interval_seconds": schedule_interval_seconds,
"service_id": service_id,
}
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
@@ -933,17 +1028,19 @@ class SettingsStore:
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at)
VALUES (?, ?, ?, ?, ?, ?)
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
name = excluded.name,
source = excluded.source,
target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds
schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
ON CONFLICT(name) DO UPDATE SET
source = excluded.source,
target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds
schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
""",
(
job["id"],
@@ -951,6 +1048,7 @@ class SettingsStore:
job["source"],
job["target"],
job["schedule_interval_seconds"],
job["service_id"],
created_at,
),
)
@@ -1566,6 +1664,113 @@ class SettingsStore:
for row in rows
]
# ------------------------------------------------------------------
# Named dashboards
# ------------------------------------------------------------------
@staticmethod
def _slugify(label: str) -> str:
import re
slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-")
return slug or "dashboard"
def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str:
self.init_schema()
base = slug
suffix = 1
with self.connect() as conn:
while True:
row = conn.execute(
"SELECT id FROM named_dashboards WHERE slug = ? AND id != ?",
(slug, exclude_id or ""),
).fetchone()
if not row:
return slug
suffix += 1
slug = f"{base}-{suffix}"
def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"label": row["label"],
"slug": row["slug"],
"sort_order": row["sort_order"],
"payload": json.loads(row["payload_json"] or "{}"),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def list_dashboards(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE"
).fetchall()
return [self._row_to_dashboard(row) for row in rows]
def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None:
if not dashboard_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone()
return self._row_to_dashboard(row) if row else None
def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None:
if not slug:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone()
return self._row_to_dashboard(row) if row else None
def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]:
self.init_schema()
current = self.get_dashboard(dashboard_id) if dashboard_id else None
dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip()
label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip()
slug = str(payload.get("slug") or "").strip() or self._slugify(label)
slug = self._unique_slug(slug, exclude_id=dash_id)
sort_order = payload.get("sort_order")
if sort_order is None:
sort_order = (current or {}).get("sort_order", 0)
sort_order = int(sort_order)
payload_data = payload.get("payload")
if payload_data is None:
payload_data = (current or {}).get("payload", {})
now = int(time.time())
with self.connect() as conn:
existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
label = excluded.label,
slug = excluded.slug,
sort_order = excluded.sort_order,
payload_json = excluded.payload_json,
updated_at = excluded.updated_at
""",
(
dash_id,
label,
slug,
sort_order,
json.dumps(payload_data),
created_at,
now,
),
)
return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug}
def delete_dashboard(self, dashboard_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,))
_store: SettingsStore | None = None
+1 -1
View File
@@ -2,7 +2,7 @@
dir: backend/tests
## role
Test suite providing unit and integration coverage for the backend's API endpoints, clients, configuration, utilities, and services.
Test suite providing unit and integration tests that validate API endpoints, configuration, external clients, utilities, and service logic across the backend.
## parent
index: backend/.pi-map.index.md
map: backend/.pi-map.md
File diff suppressed because one or more lines are too long
+1 -152
View File
@@ -14,8 +14,6 @@ from fastapi.testclient import TestClient
from media_library_viewer_api.clients.ssh import CommandResult
from media_library_viewer_api.dependencies import (
get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
get_settings_store,
get_ssh_client,
get_user_id,
@@ -70,38 +68,6 @@ def mock_jellyfin():
return client
@pytest.fixture
def mock_jellyseerr():
"""Mock Jellyseerr client."""
client = MagicMock()
client.jellyfin_users.return_value = [
{"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"},
{"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"},
]
client.users.return_value = [
{
"id": 7,
"username": "alex",
"email": "alex@example.com",
"avatar": "/avatarproxy/alex",
"userType": 3,
"permissions": 10,
"requestCount": 3,
},
{
"id": 8,
"username": "sam",
"email": "sam@example.com",
"avatar": "/avatarproxy/sam",
"userType": 2,
"permissions": 32,
"requestCount": 1,
},
]
client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}"
return client
@pytest.fixture
def mock_ssh():
"""Mock SSH client."""
@@ -132,10 +98,9 @@ def mock_ssh():
@pytest.fixture
def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path):
def test_client(mock_jellyfin, mock_ssh, tmp_path):
"""FastAPI test client with mocked dependencies."""
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin
app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr
app.dependency_overrides[get_ssh_client] = lambda: mock_ssh
app.dependency_overrides[get_user_id] = lambda: "user123"
store = SettingsStore(tmp_path / "settings.sqlite")
@@ -293,122 +258,6 @@ class TestSettingsReset:
assert len(store.list_machines()) == 0
# --- Users ---
class TestUsers:
def test_users_list_enriched(self, test_client):
response = test_client.get("/api/users")
assert response.status_code == 200
data = response.json()
assert data["total"] == 2
assert data["jellyseerr_configured"] is True
assert data["jellyseerr_available"] is True
assert data["jellyseerr_error"] == ""
alex = next(item for item in data["items"] if item["username"] == "alex")
assert alex["email"] == "alex@example.com"
assert alex["email_source"] == "jellyseerr:user"
assert alex["contactable"] is True
assert alex["avatar"].startswith("https://requests.example.com/")
assert alex["avatar_source"] == "jellyseerr:user"
assert alex["permissions"] == 10
assert alex["permissions_label"] == "admin, manage_users"
assert alex["role"] == "admin"
assert alex["user_type_label"] == "jellyfin"
assert alex["request_count"] == 3
assert "name=jellyfin" in alex["source_summary"]
assert "email=jellyseerr:user" in alex["source_summary"]
sam = next(item for item in data["items"] if item["username"] == "sam")
assert sam["role"] == "requester"
assert sam["user_type_label"] == "local"
assert sam["email"] == "sam@example.com"
def test_users_message_status(self, test_client):
mail_queue = MagicMock()
mail_queue.status.return_value = {
"state": "idle",
"worker_running": True,
"stop_requested": False,
"pending_count": 0,
"active_request_id": None,
"last_request_id": None,
"last_result": None,
"last_error": "",
"last_error_at": None,
"last_success_at": None,
"last_activity_at": None,
"sent_count": 0,
"failed_count": 0,
}
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
try:
response = test_client.get("/api/users/message/status")
finally:
app.dependency_overrides.pop(get_mail_queue, None)
assert response.status_code == 200
assert response.json()["state"] == "idle"
assert response.json()["pending_count"] == 0
def test_users_message_is_queued(self, test_client):
mail_queue = MagicMock()
mail_queue.status.return_value = {
"state": "idle",
"worker_running": True,
"stop_requested": False,
"pending_count": 0,
"active_request_id": None,
"last_request_id": None,
"last_result": None,
"last_error": "",
"last_error_at": None,
"last_success_at": None,
"last_activity_at": None,
"sent_count": 0,
"failed_count": 0,
}
mail_queue.enqueue.return_value = "mail-123456"
app.dependency_overrides[get_mail_queue] = lambda: mail_queue
settings = SimpleNamespace(
smtp_host="smtp.example.com",
smtp_port=587,
smtp_username="mailer@example.com",
smtp_password="secret",
smtp_from_address="mailer@example.com",
smtp_from_name="Manage",
smtp_use_tls=True,
smtp_use_ssl=False,
smtp_timeout=15,
)
try:
with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings):
response = test_client.post(
"/api/users/message",
data={
"recipient_ids": json.dumps(["jf1", "jf2"]),
"subject": "Hello team",
"html_body": "<p>Hi there</p>",
"text_body": "Hi there",
},
)
finally:
app.dependency_overrides.pop(get_mail_queue, None)
assert response.status_code == 202
data = response.json()
assert data["status"] == "queued"
assert data["request_id"] == "mail-123456"
assert data["recipient_count"] == 2
assert data["attachment_count"] == 0
mail_queue.enqueue.assert_called_once()
kwargs = mail_queue.enqueue.call_args.kwargs
assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"]
assert kwargs["subject"] == "Hello team"
assert kwargs["settings"] is settings
# --- Files ---
+183
View File
@@ -0,0 +1,183 @@
"""Tests for AuthentikClient and the directory endpoint."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from media_library_viewer_api.clients.authentik import AuthentikClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
from media_library_viewer_api.services.settings_store import SettingsStore
TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch: pytest.MonkeyPatch) -> None:
"""Provide a stable MANAGE_ENCRYPTION_KEY for every test."""
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
reset_encryption_key_cache()
yield
reset_encryption_key_cache()
@pytest.fixture()
def store(tmp_path: Path) -> SettingsStore:
s = SettingsStore(tmp_path / "settings.sqlite")
s.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: s
yield s
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Client unit tests
# ---------------------------------------------------------------------------
class TestAuthentikClient:
def test_base_url_normalizes_trailing_slash(self) -> None:
c = AuthentikClient(base_url="https://auth.example.com/", api_token="t")
assert c.base_url == "https://auth.example.com"
def test_base_url_strips_api_v3_suffix(self) -> None:
c = AuthentikClient(base_url="https://auth.example.com/api/v3", api_token="t")
assert c.base_url == "https://auth.example.com"
def test_bearer_header_is_set(self) -> None:
c = AuthentikClient(base_url="https://auth.example.com", api_token="tok")
assert c.session.headers["Authorization"] == "Bearer tok"
def test_empty_base_url_raises(self) -> None:
with pytest.raises(ValueError):
AuthentikClient(base_url="", api_token="t")
def test_empty_api_token_raises(self) -> None:
with pytest.raises(ValueError):
AuthentikClient(base_url="https://auth.example.com", api_token="")
@patch.object(AuthentikClient, "get")
def test_users_normalizes_pagination(self, mock_get: MagicMock) -> None:
mock_get.return_value = {
"pagination": {"count": 42, "next": 2, "previous": 0, "current": 1},
"results": [
{"pk": 1, "username": "alice", "email": "alice@example.com"},
{"pk": 2, "username": "bob", "email": "bob@example.com"},
],
}
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
result = client.users(search="ali", page=1, page_size=2)
assert result["total"] == 42
assert result["page"] == 1
assert result["page_size"] == 2
assert len(result["items"]) == 2
assert result["items"][0]["username"] == "alice"
@patch.object(AuthentikClient, "get")
def test_users_handles_empty_results(self, mock_get: MagicMock) -> None:
mock_get.return_value = {"pagination": {"count": 0}, "results": []}
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
result = client.users()
assert result["items"] == []
assert result["total"] == 0
@patch.object(AuthentikClient, "get")
def test_users_handles_non_dict_payload(self, mock_get: MagicMock) -> None:
mock_get.return_value = []
client = AuthentikClient(base_url="https://auth.example.com", api_token="t")
result = client.users()
assert result["items"] == []
assert result["total"] == 0
@patch("media_library_viewer_api.clients.authentik.requests.Session")
def test_get_sends_correct_url_and_params(self, mock_session_cls: MagicMock) -> None:
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
mock_response = MagicMock()
mock_response.json.return_value = {"results": []}
mock_response.raise_for_status.return_value = None
mock_session.get.return_value = mock_response
c = AuthentikClient(base_url="https://auth.example.com", api_token="t")
c.get("/core/users/", search="x", page=2)
call_args = mock_session.get.call_args
assert call_args.kwargs["params"] == {"search": "x", "page": 2}
assert call_args.args[0] == "https://auth.example.com/api/v3/core/users/"
# ---------------------------------------------------------------------------
# Endpoint integration tests
# ---------------------------------------------------------------------------
class TestAuthentikUsersEndpoint:
def test_not_configured_returns_empty_with_error(self, store: SettingsStore) -> None:
client = TestClient(app)
response = client.get("/api/services/authentik/nonexistent/users")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert data["total"] == 0
assert "error" in data
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
def test_success_returns_users(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
mock_client = MagicMock()
mock_client.users.return_value = {
"items": [{"pk": 1, "username": "alice"}],
"total": 1,
"page": 1,
"page_size": 50,
}
mock_client_cls.return_value = mock_client
created = store.upsert_service(
{
"service_type": "authentik",
"name": "Main",
"config": {"base_url": "https://auth.example.com"},
"enabled": True,
},
secret_values={"api_token": "secret-token"},
)
service_id = created["id"]
client = TestClient(app)
response = client.get(f"/api/services/authentik/{service_id}/users?search=ali")
assert response.status_code == 200
data = response.json()
assert len(data["items"]) == 1
assert data["items"][0]["username"] == "alice"
assert data["total"] == 1
assert "error" not in data
@patch("media_library_viewer_api.routers.authentik_users.AuthentikClient")
def test_unreachable_returns_error(self, mock_client_cls: MagicMock, store: SettingsStore) -> None:
mock_client = MagicMock()
mock_client.users.side_effect = ConnectionError("refused")
mock_client_cls.return_value = mock_client
created = store.upsert_service(
{
"service_type": "authentik",
"name": "Main",
"config": {"base_url": "https://auth.example.com"},
"enabled": True,
},
secret_values={"api_token": "secret-token"},
)
service_id = created["id"]
client = TestClient(app)
response = client.get(f"/api/services/authentik/{service_id}/users")
assert response.status_code == 200
data = response.json()
assert data["items"] == []
assert "error" in data
+97
View File
@@ -0,0 +1,97 @@
"""Tests for named-dashboards CRUD + slug uniqueness."""
from __future__ import annotations
from pathlib import Path
from fastapi.testclient import TestClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.main import app
from media_library_viewer_api.services.settings_store import SettingsStore
def _client(tmp_path: Path) -> TestClient:
store = SettingsStore(tmp_path / "settings.sqlite")
app.dependency_overrides[get_settings_store] = lambda: store
client = TestClient(app)
client.store = store # type: ignore[attr-defined]
return client
def test_create_and_list_dashboards(tmp_path: Path):
client = _client(tmp_path)
try:
resp = client.post(
"/api/dashboards",
json={"label": "Storage Overview", "payload": {"widgets": []}},
)
assert resp.status_code == 200
created = resp.json()
assert created["label"] == "Storage Overview"
assert created["slug"] == "storage-overview"
assert created["payload"] == {"widgets": []}
listed = client.get("/api/dashboards").json()
assert len(listed) == 1
assert listed[0]["id"] == created["id"]
finally:
app.dependency_overrides.clear()
def test_update_dashboard(tmp_path: Path):
client = _client(tmp_path)
try:
created = client.post("/api/dashboards", json={"label": "First"}).json()
updated = client.put(
f"/api/dashboards/{created['id']}",
json={"label": "Renamed", "payload": {"widgets": ["w1"]}},
).json()
assert updated["label"] == "Renamed"
assert updated["payload"] == {"widgets": ["w1"]}
assert updated["slug"] == "renamed"
finally:
app.dependency_overrides.clear()
def test_delete_dashboard(tmp_path: Path):
client = _client(tmp_path)
try:
created = client.post("/api/dashboards", json={"label": "Temp"}).json()
resp = client.delete(f"/api/dashboards/{created['id']}")
assert resp.status_code == 200
assert client.get("/api/dashboards").json() == []
finally:
app.dependency_overrides.clear()
def test_slug_collision_appends_suffix(tmp_path: Path):
client = _client(tmp_path)
try:
first = client.post("/api/dashboards", json={"label": "Overview"}).json()
second = client.post("/api/dashboards", json={"label": "Overview"}).json()
assert first["slug"] == "overview"
assert second["slug"] == "overview-2"
finally:
app.dependency_overrides.clear()
def test_explicit_slug_respected(tmp_path: Path):
client = _client(tmp_path)
try:
created = client.post(
"/api/dashboards",
json={"label": "My Dashboard", "slug": "custom-slug"},
).json()
assert created["slug"] == "custom-slug"
finally:
app.dependency_overrides.clear()
def test_update_nonexistent_returns_404(tmp_path: Path):
client = _client(tmp_path)
try:
resp = client.put("/api/dashboards/nope", json={"label": "X"})
assert resp.status_code == 404
finally:
app.dependency_overrides.clear()
+153 -4
View File
@@ -8,6 +8,7 @@ from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from pydantic import ValidationError
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.registry import (
@@ -56,24 +57,55 @@ def client(tmp_path):
# ---------------------------------------------------------------------------
def test_registry_contains_seven_service_types():
def test_registry_contains_eight_service_types():
assert set(SERVICE_DEFINITIONS) == {
"grafana",
"prometheus",
"alertmanager",
"jellyfin",
"jellyseerr",
"nextcloud",
"ssh_tasks",
"backups",
"authentik",
}
def test_jellyseerr_absorbed_into_jellyfin():
"""Jellyseerr is no longer its own service type (absorbed into Jellyfin)."""
assert "jellyseerr" not in SERVICE_DEFINITIONS
jellyfin_config = get_service_definition("jellyfin").config_schema["properties"]
assert "jellyseerr_url" in jellyfin_config
assert "jellyseerr_api_key" in jellyfin_config
def test_backups_service_definition():
definition = get_service_definition("backups")
assert definition is not None
assert definition.secret_fields == []
assert {wk.kind for wk in definition.widget_kinds} == {"summary"}
schema = definition.config_schema
assert "ingestion_label" in schema["properties"]
def test_authentik_service_definition():
definition = get_service_definition("authentik")
assert definition is not None
assert {sf.key for sf in definition.secret_fields} == {"api_token"}
assert definition.secret_fields[0].required is True
assert definition.widget_kinds == []
schema = definition.config_schema
assert "base_url" in schema["properties"]
assert "timeout_seconds" in schema["properties"]
def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
assert {wk.kind for wk in get_service_definition("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
assert get_service_definition("nextcloud").widget_kinds == []
assert get_service_definition("authentik").widget_kinds == []
assert {wk.kind for wk in get_service_definition("backups").widget_kinds} == {"summary"}
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
@@ -138,9 +170,10 @@ def test_list_service_types(client):
types = {item["service_type"] for item in response.json()}
assert types == {
"alertmanager",
"authentik",
"backups",
"grafana",
"jellyfin",
"jellyseerr",
"nextcloud",
"prometheus",
"ssh_tasks",
@@ -244,7 +277,8 @@ def test_invalid_config_rejected(client):
"/api/services/instances",
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
)
# Pydantic accepts empty string; force a real validation error via bad type.
assert response.status_code == 422
# Force a real validation error via bad type.
response = client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
@@ -252,6 +286,25 @@ def test_invalid_config_rejected(client):
assert response.status_code == 422
@pytest.mark.parametrize(
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"]
)
def test_service_base_url_requires_http_schema(bad_url):
"""Every service base_url must include an http:// or https:// schema."""
model = get_service_definition("grafana").config_model
with pytest.raises(ValidationError):
model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
@pytest.mark.parametrize(
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"]
)
def test_service_base_url_accepts_absolute_urls(service_type):
model = get_service_definition(service_type).config_model
instance = model.model_validate({"base_url": "https://example.com"})
assert instance.base_url == "https://example.com"
def test_unknown_secret_field_rejected(client):
response = client.post(
"/api/services/instances",
@@ -369,3 +422,99 @@ def test_record_and_list_service_task_runs(client):
assert len(runs) == 1
assert runs[0]["status"] == "success"
assert runs[0]["stdout_tail"] == "ok"
# ---------------------------------------------------------------------------
# Jellyseerr → Jellyfin migration (Slice 1.4 / 1.5)
# ---------------------------------------------------------------------------
def test_jellyseerr_migrates_into_single_jellyfin(tmp_path):
"""A standalone jellyseerr service merges into the only jellyfin instance."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
jellyfin = store.upsert_service(
{
"service_type": "jellyfin",
"name": "Main Jellyfin",
"config": {"base_url": "https://jellyfin.example.com"},
"enabled": True,
},
secret_values={"api_key": "jf-key"},
)
store.upsert_service(
{
"service_type": "jellyseerr",
"name": "Main Jellyseerr",
"config": {"base_url": "https://jellyseerr.example.com"},
"enabled": True,
},
secret_values={"api_key": "js-key"},
)
# Run migration via ensure_defaults (idempotent entry point).
store.ensure_defaults()
# Jellyseerr row is gone.
assert store.list_services("jellyseerr") == []
# Jellyfin config gained the absorbed fields.
migrated = store.get_service(jellyfin["id"])
assert migrated["config"]["jellyseerr_url"] == "https://jellyseerr.example.com"
assert migrated["config"]["jellyseerr_api_key"] == "js-key"
def test_jellyseerr_dropped_when_no_jellyfin(tmp_path):
"""An unpaired jellyseerr (no jellyfin) is dropped with a warning, no crash."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
store.upsert_service(
{
"service_type": "jellyseerr",
"name": "Orphan Jellyseerr",
"config": {"base_url": "https://jellyseerr.example.com"},
"enabled": True,
},
secret_values={"api_key": "js-key"},
)
store.ensure_defaults()
assert store.list_services("jellyseerr") == []
assert store.list_services("jellyfin") == []
def test_jellyseerr_migration_is_idempotent(tmp_path):
"""Running ensure_defaults twice does nothing the second time."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
store.upsert_service(
{
"service_type": "jellyfin",
"name": "JF",
"config": {"base_url": "https://jellyfin.example.com"},
"enabled": True,
},
secret_values={"api_key": "k"},
)
store.upsert_service(
{
"service_type": "jellyseerr",
"name": "JS",
"config": {"base_url": "https://jellyseerr.example.com"},
"enabled": True,
},
secret_values={"api_key": "k"},
)
store.ensure_defaults()
first_jellyfin = store.list_services("jellyfin")[0]
first_url = first_jellyfin["config"]["jellyseerr_url"]
store.ensure_defaults() # second run
second_jellyfin = store.list_services("jellyfin")[0]
assert second_jellyfin["config"]["jellyseerr_url"] == first_url
assert store.list_services("jellyseerr") == []
+1 -1
View File
@@ -2,7 +2,7 @@
dir: docs
## role
Documentation package containing architectural plans, requirements, and operational guides for the "Manage" media library application and its observability stack.
Documentation directory containing architecture, planning, and operational reference materials for the project.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+4 -4
View File
@@ -4,16 +4,16 @@ dir: docs
index: docs/.pi-map.index.md
## role
Documentation package containing architectural plans, requirements, and operational guides for the "Manage" media library application and its observability stack.
Documentation directory containing architecture, planning, and operational reference materials for the project.
## files
- MIGRATION_PLAN.md | This file documents the architecture, API design, and step-by-step migration plan for transitioning an application from a Streamlit monolith to a FastAPI and React SPA. | dep: FastAPI, React, Vite, TypeScript, pydantic-settings, @tanstack/react-query, ag-grid-react, recharts, tailwindcss
- REQUIREMENTS.md | Living requirements and decision log document for "Manage," a web application for browsing Jellyfin media libraries and inspecting corresponding media files on disk over SSH. | dep: React, TypeScript, shadcn/ui, Tailwind CSS, lucide-react, TanStack Table, Vitest, Jellyfin API, SQLite, Prometheus, Grafana, Alertmanager, OIDC
- REQUIREMENTS.md | This file is a living requirements and decision log detailing the product goals, architecture, and feature specifications for a web application that manages a remote Jellyfin media library and inspects server files over SSH.
- monitoring-logging-design.md | Design document detailing a self-hosted observability architecture (metrics, logs, dashboards, alerting) for integration with a platform called Manage. | dep: Prometheus, Grafana, Node Exporter, Grafana Loki, Grafana Alloy, Alertmanager, Authentik, Traefik
- observability-runbooks.md | Provides operational runbooks, configuration, and maintenance procedures for deploying and managing a standalone observability stack. | dep: Prometheus, Grafana, Loki, Alloy, Alertmanager, Node Exporter, Docker Compose, Traefik
## arch
Static markdown documentation organized as living specs, design docs, and runbooks without code structure or dependencies.
Flat collection of standalone Markdown documents covering requirements tracking, migration planning, and observability/operational runbooks.
## tags
react, design, observability, migration, plan, requirements, prometheus, grafana
design, react, observability, architecture, migration, plan, requirements, runbooks
## symbols
-
## workflows
+58
View File
@@ -271,6 +271,10 @@ Service definitions live as Pydantic modules in the backend
(`integrations/`); they declare the service config schema, secret fields, and
the widget kinds the service provides. There is no runtime plugin loading.
Every service `base_url` uses the shared `ServiceBaseUrl` type, which rejects
values missing an `http://` or `https://` schema with a clear validation error
(relative hosts break downstream HTTP clients).
### Services
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
@@ -464,3 +468,57 @@ The system receives backup execution reports from an external backup tool via HT
- Backup tool uses auto-generated Bearer API key
- Frontend uses existing OIDC/JWT auth
## Mobile Responsive Design
The frontend is fully operable in phone portrait (≥360px) at a single `md:`
(768px) breakpoint. Tablets and wider viewports use the desktop layout
unchanged.
### Breakpoint policy
- Single responsive cut: `md:` (768px). Below is "mobile"; at-or-above is
"desktop" (existing layout, unchanged).
- `useIsMobile()` hook (`frontend/src/hooks/useIsMobile.ts`) is the single
source of truth; it wraps `matchMedia("(max-width: 768px)")` and is SSR-safe.
- No `sm:` intermediate cut. No PWA, manifest, or service worker.
### Data tables (hybrid)
- The four wide tables (Media, FileBrowser, Users, Backups) render stacked
**cards per row** below `md` via `MobileCardRow`, each showing a primary
title plus 35 key fields. Narrow tables (SessionActivity) keep horizontal
scroll. The TanStack column-visibility toggle is hidden below `md`.
- At `md:` and above, all tables render as the existing `<DataTable>` unchanged.
### Edit forms (Sheet)
- Below `md`, ServicePage, Settings (machine editor), message compose, and
WidgetConfigDialog open inside a full-height `SheetForm` (side=bottom,
`h-[100dvh]`) with sticky header + sticky save bar instead of a centered
Dialog.
- At `md:` and above, the existing Dialog-based forms are unchanged.
### Touch targets
- All interactive elements below `md` have a minimum 44×44px hit area via the
`.mobile-touch-target` CSS utility (applied only below 768px). This covers
icon buttons, checkboxes, switches, and small text buttons. The class is a
no-op at `md:` and above.
### Dashboard
- Below `md`, the widget grid collapses to a single column with a section
anchor bar (Observability / Media / Backups / Custom) for quick navigation.
- At `md:` and above, the existing multi-widget grid is unchanged.
### Polling
- Widget refresh intervals and the message-queue poll interval are identical
on mobile and desktop. A follow-up to pause refetch when the tab is hidden
(`document.visibilityState`) is tracked as a future battery optimization.
### `HoverEditButton`
- Below `md`, edit affordances are always visible (not hover-gated). At `md:`
and above, the desktop hover-reveal aesthetic is preserved.
+1 -1
View File
@@ -2,7 +2,7 @@
dir: docs/superpowers
## role
Documentation directory for advanced features, capabilities, or customization guides within the project.
Documentation directory for advanced features, plugins, or capabilities (currently empty).
## parent
index: docs/.pi-map.index.md
map: docs/.pi-map.md
+2 -2
View File
@@ -4,10 +4,10 @@ dir: docs/superpowers
index: docs/superpowers/.pi-map.index.md
## role
Documentation directory for advanced features, capabilities, or customization guides within the project.
Documentation directory for advanced features, plugins, or capabilities (currently empty).
## files
## arch
Flat-file documentation structure (currently empty of content files), serving as a namespace for specialized or extended documentation topics.
Flat file structure intended for Markdown or supplementary documentation resources.
## tags
-
## symbols
+1 -2
View File
@@ -2,14 +2,13 @@
dir: docs/superpowers/specs
## role
Specification documents defining approved architectural designs for major features of the media library viewer project.
Contains design specification documents for major features and system modules.
## parent
index: docs/superpowers/.pi-map.index.md
map: docs/superpowers/.pi-map.md
## children
-
## files
- 2026-05-08-obsidian-documentation-design.md
- 2026-05-11-backup-monitoring-design.md
## links
index: docs/superpowers/specs/.pi-map.index.md
+3 -4
View File
@@ -4,14 +4,13 @@ dir: docs/superpowers/specs
index: docs/superpowers/specs/.pi-map.index.md
## role
Specification documents defining approved architectural designs for major features of the media library viewer project.
Contains design specification documents for major features and system modules.
## files
- 2026-05-08-obsidian-documentation-design.md | Defines the approved documentation structure for an Obsidian-based knowledge vault for the Manage media library viewer project, targeting developers, contributors, operators, and deployers. | dep: Obsidian, FastAPI, React, TypeScript, Vite, MUI, D3, AG Grid, Docker, Traefik, Authentik, Paramiko, PyJWT, SQLite
- 2026-05-11-backup-monitoring-design.md | Design document for a standalone backup monitoring module with HTTP API ingestion, SQLite storage, automated alerting, and React frontend for a media library viewer application. | dep: FastAPI, React, SQLite, OIDC/JWT, D3, existing MonitoringPoller
## arch
Dated Markdown design documents following a specification pattern, each capturing requirements, architecture decisions, and implementation plans for distinct system components.
Dated markdown files following a specification-driven development pattern, each documenting complete system designs including storage, APIs, alerting, and frontend integration.
## tags
design, obsidian, 2026, 05, documentation, react, sqlite, backup
design, backup, monitoring, sqlite, react, 2026, 05, 11
## symbols
-
## workflows
+1 -1
View File
@@ -2,7 +2,7 @@
dir: frontend
## role
React-based single-page application frontend for the "Manage" project, built with Vite, TypeScript, Tailwind CSS, and shadcn/ui.
Frontend SPA for the "Manage" application, built with React, Vite, and TypeScript, providing the user interface with OIDC authentication and API integration.
## parent
index: ./.pi-map.index.md
map: ./.pi-map.md
+3 -3
View File
@@ -4,11 +4,11 @@ dir: frontend
index: frontend/.pi-map.index.md
## role
React-based single-page application frontend for the "Manage" project, built with Vite, TypeScript, Tailwind CSS, and shadcn/ui.
Frontend SPA for the "Manage" application, built with React, Vite, and TypeScript, providing the user interface with OIDC authentication and API integration.
## files
- .gitignore | Specifies files and directories for Git to ignore in a Node.js/frontend project
- Dockerfile | Multi-stage Dockerfile for building and serving a Vite-based frontend application with separate production (nginx) and development (node dev server) targets | dep: node:22-alpine, nginx:1.27-alpine, npm, vite
- README.md | Documentation for the Manage Frontend React SPA, covering setup, development, build, pages, environment variables, and configuration workflows.
- README.md | Documentation for the Manage frontend SPA, describing its tech stack, setup, pages, configuration, and deployment workflow. | dep: React, TypeScript, Vite, @tanstack/react-query, @tanstack/react-table, react-router-dom, Tailwind CSS, shadcn/ui
- components.json | Configuration file for shadcn/ui component library setup with Tailwind CSS and path aliases | dep: shadcn/ui, tailwindcss, lucide-react, radix-ui
- eslint.config.js | Configures ESLint for a TypeScript React project using Vite with recommended rules for JS, TS, React Hooks, and React Refresh. | dep: @eslint/js, globals, eslint-plugin-react-hooks, eslint-plugin-react-refresh, typescript-eslint, eslint/config
- index.html | Standard HTML entry point for a React/Vite single-page application named "Manage" | dep: React (implied by root div and TSX entry), Vite (implied by module script and /src path)
@@ -23,7 +23,7 @@ React-based single-page application frontend for the "Manage" project, built wit
- vite.config.ts | Configures Vite build tool with React, Tailwind CSS, environment-based API proxying, and path aliasing for a frontend application. | dep: path, vite, @vitejs/plugin-react, @tailwindcss/vite
- vitest.config.ts | Configures Vitest test runner for a React project with jsdom environment, path aliasing, and scoped test file inclusion. | dep: path, vitest/config, @vitejs/plugin-react, vitest, jsdom
## arch
Component-driven SPA architecture using Vite for build tooling, PostCSS/Tailwind for styling, OIDC for authentication, Vitest for testing, with multi-stage Docker deployment supporting both nginx (production) and Node dev server (development) targets.
Component-based React architecture styled with Tailwind CSS/shadcn/ui, bundled via Vite, containerized through multi-stage Docker builds (Nginx for production, Node dev server for development), with Vitest for testing and ESLint for code quality.
## tags
react, @babel, vite, helper, typescript, project, css, eslint
## symbols
+1 -1
View File
@@ -2,7 +2,7 @@
dir: frontend/src
## role
Frontend application entry point and core infrastructure providing routing, OIDC authentication, global state, and styling for a React-based admin dashboard.
Frontend React application providing an admin dashboard UI with OIDC authentication, dark mode, and user activity management for a Jellyfin media server.
## parent
index: frontend/.pi-map.index.md
map: frontend/.pi-map.md
+4 -4
View File
@@ -4,21 +4,21 @@ dir: frontend/src
index: frontend/src/.pi-map.index.md
## role
Frontend application entry point and core infrastructure providing routing, OIDC authentication, global state, and styling for a React-based admin dashboard.
Frontend React application providing an admin dashboard UI with OIDC authentication, dark mode, and user activity management for a Jellyfin media server.
## files
- App.tsx | Main application component that renders a responsive admin dashboard with OIDC authentication, dark mode, collapsible sidebar navigation, and route-based page rendering. | exp: func:App() | dep: react-router-dom, @tanstack/react-query, react, react-oidc-context, ./pages/Dashboard, ./pages/Applications, ./pages/Settings, ./pages/Users, ./pages/FileBrowser, ./pages/Actions, ./components/BackupsPage, ./components/ObservabilityPage, ./pages/ServicePage, ./pages/ServicesPage, ./auth, ./api/client, ./version, ./hooks/usePersistentState, @/components/ui/button, @/components/ui/tooltip, @/components/ui/sheet, lucide-react, ./pages/*, ./components/*
- auth.ts | Manages OIDC authentication configuration and access token retrieval for a browser-based application. | exp: func:isOidcConfigured() → boolean, call:(import.meta.env.VITE_OIDC_ENABLED ?? "true").toLowerCase, call:Boolean, func:getOidcConfig(), call:window.history.replaceState, func:setAccessToken(token: string | null | undefined), func:getAccessToken() → string | null, call:getStoredAccessToken | dep: oidc-client-ts
- index.css | Defines a comprehensive Tailwind CSS v4 theme with custom design tokens, dark mode support, and base styles for a React application. | dep: tailwindcss, Google Fonts (Inter)
- main.tsx | Entry point that renders the React application into the DOM root element with StrictMode enabled. | dep: react, react-dom/client, ./App, ./index.css
- userState.d.ts | Defines TypeScript type declarations for user activity state management, including interfaces for user activity summaries and state items, plus function declarations for merging users with activity data and resolving user selections. | exp: UserActivitySummary, UserStateItem, mergeUsersWithActivity, resolveUserSelection | dep: ./types, types (NowPlayingSession, UserDirectoryItem)
- userState.js | Merges Jellyfin users with their session activity data and provides user lookup by identifier. | exp: func:mergeUsersWithActivity(users, sessions), call:users.map, call:sessions.filter, call:sessionMatchesUser, call:buildActivitySummary, func:resolveUserSelection(users, identifier), call:normalize, call:users.find, call:userKeys(user).some
- userState.js | Merges Jellyfin users with their session activity data and provides user lookup by identifier.
- users.d.ts | Defines TypeScript interfaces and a function declaration for building a user drawer model from directory data. | exp: UserDetailField, UserContactAction, UserContactState, UserDrawerModel, buildUserDrawerModel | dep: ./types, types
- users.js | Transforms raw user data into a structured UI model for a user details drawer component. | exp: func:buildUserDrawerModel(user), call:displayName, call:splitValues, call:String, call:user.role.charAt(0).toUpperCase, call:user.role.slice, call:permissions.join, call:syncStatus
- version.ts | Exports frontend version and build info constants with a formatter for semantic version labels | exp: FRONTEND_VERSION, FRONTEND_BUILD_INFO, FRONTEND_VERSION_LABEL, func:formatVersionLabel(version: string, buildInfo: string) → string, call:version.trim, call:buildInfo.trim
## arch
React SPA architecture using component-based UI with StrictMode bootstrapping, TypeScript type definitions paired with JavaScript logic modules, and Tailwind CSS v4 theming with dark mode support.
Component-based React SPA architecture using Tailwind CSS v4 theming, TypeScript type definitions, and utility modules for authentication and user state transformation.
## tags
user, activity, version, state, react, pages, users, oidc
user, version, state, react, pages, oidc, activity, drawer
## symbols
- App
- isOidcConfigured
+77 -64
View File
@@ -5,7 +5,6 @@ import {
NavLink,
useLocation,
Outlet,
Navigate,
} from "react-router-dom";
import {
QueryClient,
@@ -13,21 +12,22 @@ import {
useQuery,
} from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard";
import { Applications } from "./pages/Applications";
import { NamedDashboardPage } from "./pages/NamedDashboardPage";
import { Settings } from "./pages/Settings";
import { UsersPage } from "./pages/Users";
import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { ServicePage } from "./pages/ServicePage";
import { ServiceTypePage } from "./pages/ServiceTypePage";
import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
import { usePersistentState } from "./hooks/usePersistentState";
import { useIsMobile } from "./hooks/useIsMobile";
import { useServiceInstances } from "./hooks/useServices";
import { useDashboards } from "./hooks/useDashboards";
import { configuredNavEntries } from "./integrations/navEntries";
import { Button } from "@/components/ui/button";
import {
Tooltip,
@@ -44,12 +44,6 @@ import {
} from "@/components/ui/sheet";
import {
LayoutDashboard,
Activity,
DatabaseBackup,
Monitor,
Users,
Zap,
FolderOpen,
Settings as SettingsIcon,
Menu,
Sun,
@@ -58,10 +52,17 @@ import {
ChevronLeft,
ChevronRight,
Boxes,
LayoutTemplate,
} from "lucide-react";
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } },
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
},
},
});
function useDarkMode() {
@@ -82,18 +83,40 @@ function useDarkMode() {
return [darkMode, () => setDarkMode((prev) => !prev)] as const;
}
// Navigation items for sidebar
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity },
{ path: "/media", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen },
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
// Navigation items are data-driven (spec R1). Built from configured services + dashboards.
interface NavItem {
path: string;
label: string;
icon: LucideIcon;
}
function useNavItems() {
const { data: services = [] } = useServiceInstances();
const { data: dashboards = [] } = useDashboards();
return useMemo<NavItem[]>(() => {
const configuredTypes = new Set(
services.filter((s) => s.enabled).map((s) => s.service_type),
);
const serviceEntries = configuredNavEntries(configuredTypes).map((e) => ({
path: e.path,
label: e.label,
icon: e.icon,
}));
const dashboardEntries = dashboards.map((d) => ({
path: `/d/${d.slug}`,
label: d.label,
icon: LayoutTemplate,
}));
return [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
...dashboardEntries,
...serviceEntries,
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
];
}, [services, dashboards]);
}
function Sidebar({
collapsed,
@@ -105,6 +128,7 @@ function Sidebar({
isMobile: boolean;
}) {
const location = useLocation();
const navItems = useNavItems();
if (isMobile) return null;
@@ -189,6 +213,7 @@ function Sidebar({
function MobileDrawer() {
const [open, setOpen] = useState(false);
const location = useLocation();
const navItems = useNavItems();
return (
<Sheet open={open} onOpenChange={setOpen}>
@@ -253,6 +278,7 @@ function TopBar({
});
const backendLabel = appVersion?.backend_label || "…";
const navItems = useNavItems();
const pageTitle =
navItems.find((item) => item.path === location.pathname)?.label ||
"Dashboard";
@@ -316,16 +342,7 @@ function ShellLayout({
onToggleDarkMode: () => void;
}) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(
() => window.matchMedia("(max-width: 768px)").matches,
);
useEffect(() => {
const mql = window.matchMedia("(max-width: 768px)");
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
const isMobile = useIsMobile();
return (
<div className="min-h-screen bg-background">
@@ -427,6 +444,18 @@ function AuthenticatedApp() {
);
}
function NotFoundPage() {
return (
<div className="flex min-h-[50vh] flex-col items-center justify-center gap-4">
<h2 className="text-xl font-semibold">Not found</h2>
<p className="text-sm text-muted-foreground">This page doesn't exist.</p>
<Button asChild>
<NavLink to="/">Back to dashboard</NavLink>
</Button>
</div>
);
}
function AppInner() {
const [darkMode, toggleDarkMode] = useDarkMode();
@@ -438,26 +467,18 @@ function AppInner() {
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
@@ -474,26 +495,18 @@ function AppInner() {
}
>
<Route path="/" element={<Dashboard />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/d/:slug" element={<NamedDashboardPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
<Route path="*" element={<NotFoundPage />} />
</Route>
</Routes>
</BrowserRouter>
+65
View File
@@ -0,0 +1,65 @@
/** API client for the Authentik service (directory + messaging). */
import { get, post } from "./shared";
export interface AuthentikUser {
pk: number;
username: string;
name: string;
email: string;
is_active: boolean;
avatar: string | null;
[key: string]: unknown;
}
export interface AuthentikUsersResponse {
items: AuthentikUser[];
total: number;
page: number;
page_size: number;
error?: string;
}
export async function fetchAuthentikUsers(
serviceId: string,
params: { search?: string; page?: number; page_size?: number },
): Promise<AuthentikUsersResponse> {
return get<AuthentikUsersResponse>(
`/api/services/authentik/${serviceId}/users`,
{
search: params.search ?? "",
page: String(params.page ?? 1),
page_size: String(params.page_size ?? 50),
},
);
}
export interface AuthentikMessageInput {
recipient_emails: string[];
subject: string;
html_body: string;
}
export interface AuthentikMessageResponse {
status: string;
request_id?: string;
recipient_count?: number;
error?: string;
}
export async function sendAuthentikMessage(
serviceId: string,
input: AuthentikMessageInput,
): Promise<AuthentikMessageResponse> {
return post<AuthentikMessageResponse>(
`/api/services/authentik/${serviceId}/message`,
input,
);
}
export async function fetchAuthentikMessageStatus(
serviceId: string,
): Promise<Record<string, unknown>> {
return get<Record<string, unknown>>(
`/api/services/authentik/${serviceId}/message/status`,
);
}
+29 -25
View File
@@ -1,53 +1,57 @@
import { get, post } from "./shared";
import type {
BackupAlert,
BackupDashboardSummary,
BackupJob,
BackupRun,
BackupAlert,
BackupDashboardSummary,
BackupJob,
BackupRun,
} from "../types/backups";
export async function fetchBackupJobs(): Promise<BackupJob[]> {
return get<BackupJob[]>("/api/backups/jobs");
return get<BackupJob[]>("/api/backups/jobs");
}
export async function fetchBackupJob(
jobId: string,
jobId: string,
): Promise<{ job: BackupJob; runs: BackupRun[] }> {
return get<{ job: BackupJob; runs: BackupRun[] }>(`/api/backups/jobs/${jobId}`);
return get<{ job: BackupJob; runs: BackupRun[] }>(
`/api/backups/jobs/${jobId}`,
);
}
export async function fetchBackupRuns(
jobId?: string,
status?: string,
jobId?: string,
status?: string,
): Promise<BackupRun[]> {
return get<BackupRun[]>("/api/backups/runs", {
...(jobId ? { job_id: jobId } : {}),
...(status ? { status } : {}),
});
return get<BackupRun[]>("/api/backups/runs", {
...(jobId ? { job_id: jobId } : {}),
...(status ? { status } : {}),
});
}
export async function fetchBackupRun(runId: string): Promise<BackupRun> {
return get<BackupRun>(`/api/backups/runs/${runId}`);
return get<BackupRun>(`/api/backups/runs/${runId}`);
}
export async function fetchBackupAlerts(
jobId?: string,
acknowledged?: boolean,
severity?: string,
jobId?: string,
acknowledged?: boolean,
severity?: string,
): Promise<BackupAlert[]> {
return get<BackupAlert[]>("/api/backups/alerts", {
...(jobId ? { job_id: jobId } : {}),
...(acknowledged !== undefined ? { acknowledged: String(acknowledged) } : {}),
...(severity ? { severity } : {}),
});
return get<BackupAlert[]>("/api/backups/alerts", {
...(jobId ? { job_id: jobId } : {}),
...(acknowledged !== undefined
? { acknowledged: String(acknowledged) }
: {}),
...(severity ? { severity } : {}),
});
}
export async function acknowledgeBackupAlert(
alertId: string,
alertId: string,
): Promise<BackupAlert> {
return post<BackupAlert>(`/api/backups/alerts/${alertId}/acknowledge`);
return post<BackupAlert>(`/api/backups/alerts/${alertId}/acknowledge`);
}
export async function fetchBackupDashboard(): Promise<BackupDashboardSummary> {
return get<BackupDashboardSummary>("/api/dashboard/backups");
return get<BackupDashboardSummary>("/api/dashboard/backups");
}
+9 -1
View File
@@ -36,7 +36,15 @@ import type {
PrometheusStatus,
PrometheusTarget,
} from "../types";
import { buildHeaders, buildUrl, del, get, post, postForm, readErrorDetail } from "./shared";
import {
buildHeaders,
buildUrl,
del,
get,
post,
postForm,
readErrorDetail,
} from "./shared";
// Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
export const fetchCounts = (jellyfinServiceId?: string) =>
+50
View File
@@ -0,0 +1,50 @@
/**
* API client for the named-dashboards backend (Slice 3).
*/
import { del, get, post, put } from "./shared";
export interface NamedDashboard {
id: string;
label: string;
slug: string;
sort_order: number;
payload: Record<string, unknown>;
created_at: number;
updated_at: number;
}
export interface NamedDashboardInput {
id?: string | null;
label: string;
slug?: string;
sort_order: number;
payload: Record<string, unknown>;
}
export async function fetchDashboards(): Promise<NamedDashboard[]> {
return get<NamedDashboard[]>("/api/dashboards");
}
export async function fetchDashboardBySlug(
slug: string,
): Promise<NamedDashboard> {
return get<NamedDashboard>(
`/api/dashboards/slug/${encodeURIComponent(slug)}`,
);
}
export async function createDashboard(
input: NamedDashboardInput,
): Promise<NamedDashboard> {
return post<NamedDashboard>("/api/dashboards", input);
}
export async function updateDashboard(
input: NamedDashboardInput,
): Promise<NamedDashboard> {
return put<NamedDashboard>(`/api/dashboards`, input);
}
export async function deleteDashboard(id: string): Promise<{ status: string }> {
return del<{ status: string }>(`/api/dashboards/${id}`);
}
@@ -8,6 +8,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useIsMobile } from "../hooks/useIsMobile";
import type { BackupAlert } from "../types/backups";
interface Props {
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
return severity === "critical" ? "destructive" : "warning";
}
// Mobile card fields (spec R3.2): message is the primary identifier;
// severity/type/created give the at-a-glance info. See OpenSpec change
// `mobile-responsive-parity`, tasks slice 5.2.
const alertCardFields: MobileCardField<BackupAlert>[] = [
{ key: "message", label: "Message", render: (a) => a.message, primary: true },
{
key: "severity",
label: "Severity",
render: (a) => (
<Badge variant={severityVariant(a.severity)}>{a.severity}</Badge>
),
},
{ key: "type", label: "Type", render: (a) => a.alert_type },
{
key: "created",
label: "Created",
render: (a) => formatTimestamp(a.created_at),
},
];
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
const isMobile = useIsMobile();
if (isMobile) {
return (
<MobileCardRow
rows={alerts}
fields={alertCardFields}
getRowId={(a) => a.id}
actions={(a) =>
!a.acknowledged ? (
<Button
size="sm"
variant="outline"
className="mobile-touch-target"
onClick={() => onAcknowledge(a.id)}
>
Ack
</Button>
) : null
}
/>
);
}
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup alerts">
@@ -7,6 +7,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useIsMobile } from "../hooks/useIsMobile";
import type { BackupJob, BackupRun } from "../types/backups";
interface Props {
@@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant {
return "secondary";
}
// Mobile card fields (spec R3.2): job name is primary; source/schedule/status
// give at-a-glance context. See OpenSpec change `mobile-responsive-parity`.
interface JobCardRow {
job: BackupJob;
status: string;
run_started: number | null;
}
const jobCardFields: MobileCardField<JobCardRow>[] = [
{ key: "name", label: "Name", render: (r) => r.job.name, primary: true },
{
key: "source",
label: "Source",
render: (r) => r.job.source ?? "—",
},
{
key: "schedule",
label: "Schedule",
render: (r) => formatInterval(r.job.schedule_interval_seconds),
},
{
key: "status",
label: "Last status",
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
},
];
export default function BackupJobsTable({ jobs, latestRuns }: Props) {
const isMobile = useIsMobile();
if (isMobile) {
const cardRows: JobCardRow[] = jobs.map((job) => {
const run = latestRuns.get(job.id);
return {
job,
status: run?.status ?? "unknown",
run_started: run?.started_at ?? null,
};
});
return (
<MobileCardRow
rows={cardRows}
fields={jobCardFields}
getRowId={(r) => r.job.id}
/>
);
}
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup jobs">
+67 -27
View File
@@ -15,6 +15,11 @@ import {
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useIsMobile } from "../hooks/useIsMobile";
import type { BackupRun } from "../types/backups";
interface Props {
@@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant {
return "warning";
}
// Mobile card fields (spec R3.2): job_id is primary; status/duration/size/
// started give the at-a-glance info. See OpenSpec change `mobile-responsive-parity`.
const runCardFields: MobileCardField<BackupRun>[] = [
{ key: "job", label: "Job", render: (r) => r.job_id, primary: true },
{
key: "status",
label: "Status",
render: (r) => <Badge variant={statusVariant(r.status)}>{r.status}</Badge>,
},
{
key: "duration",
label: "Duration",
render: (r) => formatDuration(r.duration_ms),
},
{
key: "size",
label: "Size",
render: (r) => formatBytes(r.bytes_transferred),
},
{
key: "started",
label: "Started",
render: (r) => formatTimestamp(r.started_at),
},
];
export default function BackupRunsTable({ runs }: Props) {
const [statusFilter, setStatusFilter] = useState<string>("all");
const isMobile = useIsMobile();
const filteredRuns =
statusFilter === "all"
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
</SelectContent>
</Select>
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup runs">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Job</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Size</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRuns.map((run) => (
<TableRow key={run.id}>
<TableCell>{run.job_id}</TableCell>
<TableCell>
<Badge variant={statusVariant(run.status)}>
{run.status}
</Badge>
</TableCell>
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
{isMobile ? (
<MobileCardRow
rows={filteredRuns}
fields={runCardFields}
getRowId={(r) => r.id}
/>
) : (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup runs">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Job</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Size</TableHead>
<TableHead>Started</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</div>
</TableHeader>
<TableBody>
{filteredRuns.map((run) => (
<TableRow key={run.id}>
<TableCell>{run.job_id}</TableCell>
<TableCell>
<Badge variant={statusVariant(run.status)}>
{run.status}
</Badge>
</TableCell>
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
);
}
+6 -1
View File
@@ -50,7 +50,11 @@ export function DialogFooter({
}: DialogFooterProps) {
return (
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
<Button
variant="ghost"
className="mobile-touch-target"
onClick={onCancel}
>
{cancelLabel}
</Button>
{secondaryAction ? (
@@ -59,6 +63,7 @@ export function DialogFooter({
</div>
) : null}
<Button
className="mobile-touch-target"
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
disabled={confirmDisabled}
onClick={onConfirm}
+27 -5
View File
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
interface HoverEditButtonProps {
onClick: () => void;
label?: string;
/** Controls visibility below the `md:` (768px) breakpoint.
*
* - `always` (default): the button is always visible on mobile/touch.
* - `hover`: keep the legacy opacity-0-everywhere behavior.
*
* At `md:` and above the hover-reveal aesthetic is always preserved
* (`md:opacity-0 md:group-hover:opacity-100`), so desktop is not regressed.
* See OpenSpec change `mobile-responsive-parity`, spec R5. */
mobile?: "always" | "hover";
}
/**
* Hover-to-reveal edit affordance.
* Hover-to-reveal edit affordance (desktop) / always-visible (mobile).
*
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
* Keeps the `rail-edit` class plus the opacity base + transition so the
* existing hover-reveal rules in consuming pages (Actions, Settings) still
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
* MUI IconButton + EditOutlined shadcn `Button variant="ghost" size="icon-sm"`
* + lucide `Pencil`. Same exported props/display name.
*
* Mobile behavior (`mobile="always"`, the default): the button is visible by
* default below `md` because hover does not fire on touch. The hover-reveal
* aesthetic is layered back on at `md:` and above via `md:opacity-0
* md:group-hover:opacity-100`. MUI IconButton + EditOutlined → shadcn `Button
* variant="ghost" size="icon-sm"` + lucide `Pencil`. Same exported props/display
* name. See OpenSpec change `mobile-responsive-parity`, spec R5.
*/
export function HoverEditButton({
onClick,
label = "Edit",
mobile = "always",
}: HoverEditButtonProps) {
// Legacy mode: opacity-0 everywhere, revealed by group hover (the consuming
// row supplies `group`).
const hoverClasses =
mobile === "hover"
? "opacity-0 transition-opacity duration-100 ease-out group-hover:opacity-100"
: "md:opacity-0 md:transition-opacity md:duration-100 md:ease-out md:group-hover:opacity-100";
return (
<Button
variant="ghost"
size="icon-sm"
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
className={`rail-edit text-muted-foreground mobile-touch-target ${hoverClasses}`}
aria-label={label}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
@@ -1,667 +0,0 @@
import { useMemo, useState, type ElementType, type ReactNode } from "react";
import { Link } from "react-router-dom";
import {
Activity,
AlertTriangle,
Bell,
CheckCircle2,
ChevronDown,
ExternalLink,
Gauge,
Inbox,
Radio,
RefreshCw,
Server,
ServerOff,
XCircle,
} from "lucide-react";
import {
useAlertmanagerAlerts,
useAlertmanagerStatus,
useGrafanaStatus,
usePrometheusStatus,
usePrometheusTargets,
useMonitoringMachines,
} from "../hooks/useObservability";
import { useServiceInstances } from "../hooks/useServices";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type {
AlertmanagerAlert,
MonitoringMachine,
PrometheusTarget,
} from "../types";
function severityVariant(
severity: string,
): "default" | "secondary" | "destructive" | "outline" {
switch (severity.toLowerCase()) {
case "critical":
return "destructive";
case "warning":
return "default";
case "info":
return "secondary";
default:
return "outline";
}
}
function HealthCard({
title,
status,
detail,
icon: Icon,
isLoading,
}: {
title: string;
status: "ok" | "warning" | "error" | "unknown";
detail: string;
icon: ElementType;
isLoading?: boolean;
}) {
const statusIcon =
status === "ok" ? (
<CheckCircle2 className="h-5 w-5 text-green-500" />
) : status === "warning" ? (
<AlertTriangle className="h-5 w-5 text-amber-500" />
) : status === "error" ? (
<XCircle className="h-5 w-5 text-red-500" />
) : (
<Radio className="h-5 w-5 text-muted-foreground" />
);
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="flex items-center gap-2">
{isLoading ? <Skeleton className="h-5 w-5" /> : statusIcon}
<span className="text-2xl font-bold capitalize">{status}</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">{detail}</p>
</CardContent>
</Card>
);
}
function EmptyState({
icon: Icon,
title,
description,
action,
}: {
icon: ElementType;
title: string;
description: string;
action?: ReactNode;
}) {
return (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Icon className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">{title}</div>
<div className="max-w-md text-sm text-muted-foreground">
{description}
</div>
{action ? <div className="mt-2">{action}</div> : null}
</div>
);
}
function QueryError({
label,
error,
refetch,
}: {
label: string;
error: Error | null;
refetch: () => void;
}) {
if (!error) return null;
return (
<Alert variant="destructive">
<AlertTitle>{label} failed</AlertTitle>
<AlertDescription className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<span className="break-words">{error.message}</span>
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="mr-1 h-3 w-3" />
Retry
</Button>
</AlertDescription>
</Alert>
);
}
function AlertItem({ alert }: { alert: AlertmanagerAlert }) {
return (
<Collapsible>
<CollapsibleTrigger asChild>
<div className="group cursor-pointer rounded-lg border p-3 transition-colors hover:bg-muted/50">
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-sm">{alert.name}</div>
<div className="flex items-center gap-1">
<Badge variant={severityVariant(alert.severity)}>
{alert.severity}
</Badge>
<ChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{alert.summary || alert.description}
</div>
{alert.active_since && (
<div className="mt-1 text-[10px] text-muted-foreground">
Since {new Date(alert.active_since).toLocaleString()}
</div>
)}
</div>
</CollapsibleTrigger>
<CollapsibleContent className="overflow-hidden">
<div className="space-y-2 rounded-b-lg border-x border-b p-3 text-sm">
{alert.description && (
<div>
<span className="font-medium">Description:</span>{" "}
{alert.description}
</div>
)}
<div className="grid grid-cols-2 gap-2 text-xs">
{alert.job_name && (
<div>
<span className="font-medium">Job:</span> {alert.job_name}
</div>
)}
{alert.category && (
<div>
<span className="font-medium">Category:</span> {alert.category}
</div>
)}
<div>
<span className="font-medium">State:</span> {alert.state}
</div>
<div>
<span className="font-medium">Since:</span>{" "}
{alert.active_since
? new Date(alert.active_since).toLocaleString()
: "unknown"}
</div>
</div>
{alert.labels && Object.keys(alert.labels).length > 0 && (
<div className="flex flex-wrap gap-1 pt-1">
{Object.entries(alert.labels).map(([key, value]) => (
<Badge key={key} variant="secondary" className="text-[10px]">
{key}={value}
</Badge>
))}
</div>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function TargetsTable({ targets }: { targets: PrometheusTarget[] }) {
return (
<div className="space-y-3">
{targets.map((target, idx) => (
<div key={idx} className="rounded-lg border p-3">
<div className="font-mono text-sm">{target.targets.join(", ")}</div>
{target.labels && Object.keys(target.labels).length > 0 && (
<div className="mt-2 flex flex-wrap gap-1">
{Object.entries(target.labels).map(([key, value]) => (
<Badge key={key} variant="outline" className="text-[10px]">
{key}: {value}
</Badge>
))}
</div>
)}
</div>
))}
</div>
);
}
function GrafanaLinkCard({
title,
description,
href,
}: {
title: string;
description: string;
href: string;
}) {
return (
<div className="rounded-md border p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="font-medium">{title}</div>
<div className="text-sm text-muted-foreground">{description}</div>
</div>
<Button variant="outline" size="sm" asChild>
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="gap-1"
>
Open in Grafana
<ExternalLink className="h-3 w-3" />
</a>
</Button>
</div>
</div>
);
}
export function ObservabilityPage() {
const {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
refetch: refetchAlerts,
} = useAlertmanagerAlerts();
const {
data: alertmanagerStatus,
isLoading: statusLoading,
error: statusError,
refetch: refetchStatus,
} = useAlertmanagerStatus();
const {
data: grafanaStatus,
isLoading: grafanaLoading,
error: grafanaError,
refetch: refetchGrafana,
} = useGrafanaStatus();
const {
data: prometheusStatus,
isLoading: prometheusLoading,
error: prometheusError,
refetch: refetchPrometheus,
} = usePrometheusStatus();
const {
data: prometheusTargets,
isLoading: targetsLoading,
error: targetsError,
refetch: refetchTargets,
} = usePrometheusTargets();
const {
data: machines = [],
isLoading: machinesLoading,
error: machinesError,
refetch: refetchMachines,
} = useMonitoringMachines();
const { data: grafanaServices = [] } = useServiceInstances("grafana");
const [selectedMachineId, setSelectedMachineId] = useState<string>("");
const grafanaService =
grafanaServices.find((s) => s.enabled) ?? grafanaServices[0];
const GRAFANA_BASE_URL =
(grafanaService?.config?.base_url as string | undefined) ?? "";
const selectedMachine = useMemo<MonitoringMachine | null>(
() =>
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
[machines, selectedMachineId],
);
const nodeExporterDashboardUrl = useMemo(() => {
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
const instance = `${selectedMachine.host || "localhost"}:9100`;
return `${GRAFANA_BASE_URL}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(instance)}`;
}, [selectedMachine, GRAFANA_BASE_URL]);
const logsUrl = useMemo(() => {
if (!selectedMachine || !GRAFANA_BASE_URL) return "";
const container =
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
return `${GRAFANA_BASE_URL}/explore?orgId=1&left=${encodeURIComponent(
JSON.stringify({
datasource: "Loki",
queries: [{ refId: "A", expr: `{container="${container}"}` }],
range: { from: "now-1h", to: "now" },
}),
)}`;
}, [selectedMachine, GRAFANA_BASE_URL]);
const alertmanagerStatusDetail = alertmanagerStatus?.up
? alertmanagerStatus.version
? `version ${alertmanagerStatus.version}`
: "reachable"
: "unreachable";
const targetsCount = prometheusTargets?.length ?? 0;
const targetsStatus: "ok" | "warning" | "error" | "unknown" = targetsLoading
? "unknown"
: targetsError
? "error"
: targetsCount > 0
? "ok"
: "warning";
const alertStatus: "ok" | "warning" | "error" | "unknown" = alertsLoading
? "unknown"
: alertsError
? "error"
: (alertsSummary?.total ?? 0) > 0
? alertsSummary?.alerts.some((a) => a.severity === "critical")
? "error"
: "warning"
: "ok";
const machinesStatus: "ok" | "warning" | "error" | "unknown" = machinesLoading
? "unknown"
: machinesError
? "error"
: machines.length > 0
? "ok"
: "warning";
return (
<div className="space-y-6">
<div className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">Observability</h1>
<p className="text-sm text-muted-foreground">
Unified view of metrics, logs, and alerts from Prometheus, Loki, and
Alertmanager. Deep dashboards live in Grafana.
</p>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<HealthCard
title="Alertmanager"
status={
statusError
? "error"
: alertmanagerStatus?.up
? "ok"
: statusLoading
? "unknown"
: "error"
}
detail={alertmanagerStatusDetail}
icon={Bell}
isLoading={statusLoading}
/>
<HealthCard
title="Active Alerts"
status={alertStatus}
detail={`${alertsSummary?.total ?? 0} firing alert${(alertsSummary?.total ?? 0) === 1 ? "" : "s"}`}
icon={AlertTriangle}
isLoading={alertsLoading}
/>
<HealthCard
title="Prometheus Targets"
status={targetsStatus}
detail={`${targetsCount} remote Node Exporter target${targetsCount === 1 ? "" : "s"}`}
icon={Radio}
isLoading={targetsLoading}
/>
<HealthCard
title="Machines"
status={machinesStatus}
detail={`${machines.length} monitoring machine${machines.length === 1 ? "" : "s"}`}
icon={Server}
isLoading={machinesLoading}
/>
<HealthCard
title="Grafana"
status={
grafanaError
? "error"
: grafanaStatus?.up
? "ok"
: grafanaLoading
? "unknown"
: "error"
}
detail={
grafanaStatus?.up
? grafanaStatus.version
? `version ${grafanaStatus.version}`
: "reachable"
: grafanaStatus?.error === "no_service_configured"
? "not configured"
: "unreachable"
}
icon={Gauge}
isLoading={grafanaLoading}
/>
<HealthCard
title="Prometheus"
status={
prometheusError
? "error"
: prometheusStatus?.up
? "ok"
: prometheusLoading
? "unknown"
: "error"
}
detail={
prometheusStatus?.up
? prometheusStatus.version
? `version ${prometheusStatus.version}`
: "reachable"
: prometheusStatus?.error === "no_service_configured"
? "not configured"
: "unreachable"
}
icon={Radio}
isLoading={prometheusLoading}
/>
</div>
<div className="space-y-3">
{statusError && (
<QueryError
label="Alertmanager status"
error={statusError}
refetch={refetchStatus}
/>
)}
{alertsError && (
<QueryError
label="Active alerts"
error={alertsError}
refetch={refetchAlerts}
/>
)}
{targetsError && (
<QueryError
label="Prometheus targets"
error={targetsError}
refetch={refetchTargets}
/>
)}
{machinesError && (
<QueryError
label="Monitoring machines"
error={machinesError}
refetch={refetchMachines}
/>
)}
{grafanaError && (
<QueryError
label="Grafana status"
error={grafanaError}
refetch={refetchGrafana}
/>
)}
{prometheusError && (
<QueryError
label="Prometheus status"
error={prometheusError}
refetch={refetchPrometheus}
/>
)}
</div>
{alertsSummary?.error && (
<Alert variant="destructive">
<AlertTitle>Alertmanager unreachable</AlertTitle>
<AlertDescription>
The UI cannot reach Alertmanager right now. Alerts shown here may be
stale.
</AlertDescription>
</Alert>
)}
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-4 w-4" />
Recent Alerts
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{alertsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !alertsSummary || alertsSummary.total === 0 ? (
<EmptyState
icon={Inbox}
title="No active alerts"
description="Everything looks quiet. Alertmanager will list firing alerts here when they occur."
/>
) : (
<>
{alertsSummary.alerts.map((alert, idx) => (
<AlertItem key={`${alert.name}-${idx}`} alert={alert} />
))}
{alertsSummary.total > alertsSummary.alerts.length && (
<div className="text-center text-xs text-muted-foreground">
{alertsSummary.total - alertsSummary.alerts.length} more
alert
{alertsSummary.total - alertsSummary.alerts.length === 1
? ""
: "s"}{" "}
in Alertmanager
</div>
)}
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Prometheus Targets
</CardTitle>
</CardHeader>
<CardContent>
{targetsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !prometheusTargets || prometheusTargets.length === 0 ? (
<EmptyState
icon={Radio}
title="No Node Exporter targets"
description="Enable Node Exporter on an SSH machine in Settings to populate Prometheus scrape targets."
action={
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
}
/>
) : (
<TargetsTable targets={prometheusTargets} />
)}
</CardContent>
</Card>
</div>
<Card>
<CardHeader className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex items-center gap-2">
<Activity className="h-4 w-4" />
Machine Dashboard
</CardTitle>
<Select
value={selectedMachine?.id ?? ""}
onValueChange={setSelectedMachineId}
disabled={machines.length === 0}
>
<SelectTrigger className="w-full sm:w-[240px]">
<SelectValue placeholder="Select machine" />
</SelectTrigger>
<SelectContent>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</CardHeader>
<CardContent className="space-y-4">
{selectedMachine ? (
GRAFANA_BASE_URL ? (
<>
<GrafanaLinkCard
title={`${selectedMachine.name} metrics`}
description="Open the Node Exporter overview dashboard for this machine in Grafana."
href={nodeExporterDashboardUrl}
/>
<GrafanaLinkCard
title={`${selectedMachine.name} logs`}
description="Explore Loki logs for this machine in Grafana."
href={logsUrl}
/>
</>
) : (
<EmptyState
icon={Gauge}
title="No Grafana service configured"
description="Add a Grafana service instance to enable deep-links to dashboards and logs."
action={
<Button variant="outline" size="sm" asChild>
<Link to="/services">Open Services</Link>
</Button>
}
/>
)
) : (
<EmptyState
icon={ServerOff}
title="No machine selected"
description="Add monitoring machines in Settings to see Grafana drill-down links."
action={
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
}
/>
)}
</CardContent>
</Card>
</div>
</div>
);
}
@@ -0,0 +1,57 @@
import { useNavigate } from "react-router-dom";
import { Boxes, ChevronRight, type LucideIcon } from "lucide-react";
import { cn } from "@/lib/utils";
/**
* Pinned service link rendered on named dashboards. A card-shaped shortcut
* that navigates to a service page (or a specific tab via query param).
*
* The `target` is a route path like `/services/jellyfin/svc-1` or
* `/services/ssh_tasks/svc-2?tab=Files`.
*/
export interface PinnedServiceLinkProps {
label: string;
target: string;
icon?: LucideIcon;
className?: string;
}
export function PinnedServiceLink({
label,
target,
icon: Icon = Boxes,
className,
}: PinnedServiceLinkProps) {
const navigate = useNavigate();
return (
<button
type="button"
onClick={() => navigate(target)}
className={cn(
"mobile-touch-target group flex min-h-16 w-full items-center justify-between rounded-lg border border-border bg-card p-4 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<div className="flex items-center gap-3">
<Icon className="size-5 shrink-0 text-muted-foreground" />
<span className="text-sm font-medium text-foreground">{label}</span>
</div>
<ChevronRight className="size-4 text-muted-foreground transition-transform group-hover:translate-x-0.5" />
</button>
);
}
/**
* Static helper: build a target path for a pinned service link.
* Returns `/services/:type/:id` or with a `?tab=` suffix when provided.
*/
// eslint-disable-next-line react-refresh/only-export-components
export function serviceLinkTarget(
serviceType: string,
serviceId: string,
tab?: string,
): string {
const base = `/services/${serviceType}/${serviceId}`;
return tab ? `${base}?tab=${tab}` : base;
}
@@ -168,6 +168,7 @@ export function SessionActivityPanel({
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
+206 -185
View File
@@ -26,6 +26,8 @@ import {
} from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import type { WidgetInstance, WidgetInstanceInput } from "../types";
import {
BUILTIN_WIDGETS,
@@ -277,201 +279,220 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
]?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind]
: undefined;
const isMobile = useIsMobile();
const isTaskOutput =
draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type ===
"ssh_tasks";
// The draft body (Title/SortOrder/Enabled/config editor) is shared between
// the Dialog (desktop) and SheetForm (mobile). On mobile the inline
// Back/Save buttons are omitted because the SheetForm footer provides them.
const draftBody = draft ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Title" htmlFor="widget-title">
<Input
id="widget-title"
value={draft.title}
onChange={(e) => setDraft({ ...draft, title: e.target.value })}
/>
</Field>
<Field label="Sort order" htmlFor="widget-sort-order">
<Input
id="widget-sort-order"
type="number"
value={String(draft.sortOrder)}
onChange={(e) =>
setDraft({
...draft,
sortOrder: e.target.value === "" ? 0 : Number(e.target.value),
})
}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Switch
id="widget-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigEditor
binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
tasks={tasks}
/>
{!isMobile ? (
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={reset} className="mobile-touch-target">
Back
</Button>
<Button onClick={saveDraft} disabled={saveWidget.isPending} className="mobile-touch-target">
Save widget
</Button>
</div>
) : null}
</div>
) : (
<div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? (
<Alert>
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{sortedInstances.map((instance, index) => {
const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
return (
<div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
<Badge variant="outline">
{bindingLabel(instance.service_id, instance.widget_kind)}
</Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
disabled={index === 0}
onClick={() => moveInstance(index, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
disabled={index === sortedInstances.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Switch
className="mobile-touch-target"
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
{Object.values(BUILTIN_WIDGETS).map((b) => (
<Button
key={b.kind}
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => startAddBuiltIn(b.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{b.name}
</Button>
))}
{services
.filter((s) => s.enabled)
.flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map((w) => (
<Button
key={`${s.id}:${w.kind}`}
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => startAddService(s.id, w.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{w.name} · {s.name}
</Button>
)),
)}
</div>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more widgets.
</p>
</div>
</div>
);
const dialogTitle = draft
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets";
if (isMobile) {
return (
<SheetForm
open={open}
onOpenChange={(next) => {
if (!next) handleClose(next);
}}
title={dialogTitle}
onSave={draft ? saveDraft : () => handleClose(false)}
onCancel={draft ? reset : () => handleClose(false)}
saveLabel={draft ? "Save widget" : "Done"}
isPending={draft ? saveWidget.isPending : false}
isDirty={draft !== null}
>
<div className="flex flex-col gap-4">{draftBody}</div>
</SheetForm>
);
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{draft
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
<DialogTitle>{dialogTitle}</DialogTitle>
</DialogHeader>
{draft ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Title" htmlFor="widget-title">
<Input
id="widget-title"
value={draft.title}
onChange={(e) =>
setDraft({ ...draft, title: e.target.value })
}
/>
</Field>
<Field label="Sort order" htmlFor="widget-sort-order">
<Input
id="widget-sort-order"
type="number"
value={String(draft.sortOrder)}
onChange={(e) =>
setDraft({
...draft,
sortOrder:
e.target.value === "" ? 0 : Number(e.target.value),
})
}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Switch
id="widget-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigEditor
binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
tasks={tasks}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={reset}>
Back
</Button>
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
Save widget
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? (
<Alert>
<AlertDescription>
No widgets yet. Add one below.
</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{sortedInstances.map((instance, index) => {
const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
return (
<div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
<Badge variant="outline">
{bindingLabel(
instance.service_id,
instance.widget_kind,
)}
</Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === 0}
onClick={() => moveInstance(index, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === sortedInstances.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Switch
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
{Object.values(BUILTIN_WIDGETS).map((b) => (
<Button
key={b.kind}
variant="outline"
size="sm"
onClick={() => startAddBuiltIn(b.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{b.name}
</Button>
))}
{services
.filter((s) => s.enabled)
.flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
(w) => (
<Button
key={`${s.id}:${w.kind}`}
variant="outline"
size="sm"
onClick={() => startAddService(s.id, w.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{w.name} · {s.name}
</Button>
),
),
)}
</div>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more
widgets.
</p>
</div>
</div>
)}
{draftBody}
</DialogContent>
</Dialog>
);
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import BackupAlertsTable from "../BackupAlertsTable";
@@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => {
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
});
});
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => setMatchMedia(false));
describe("BackupAlertsTable (mobile card layout — slice 5)", () => {
it("renders cards with message as primary below md", () => {
setMatchMedia(true);
render(
<BackupAlertsTable
alerts={[alert({ id: "m1", message: "Disk full" })]}
onAcknowledge={vi.fn()}
/>,
);
expect(screen.getByText("Disk full")).toBeInTheDocument();
expect(screen.getAllByText("Severity")).toHaveLength(1);
});
it("renders acknowledge action on card below md", async () => {
setMatchMedia(true);
const onAck = vi.fn();
render(
<BackupAlertsTable
alerts={[alert({ id: "a1", acknowledged: false })]}
onAcknowledge={onAck}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Ack" }));
expect(onAck).toHaveBeenCalledWith("a1");
});
});
@@ -0,0 +1,79 @@
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import BackupJobsTable from "../BackupJobsTable";
import type { BackupJob, BackupRun } from "../../types/backups";
function job(overrides: Partial<BackupJob> = {}): BackupJob {
return {
id: "j1",
name: "nightly",
source: "/data",
target: "s3://bucket",
schedule_interval_seconds: 86400,
created_at: 1_700_000_000,
...overrides,
};
}
function run(overrides: Partial<BackupRun> = {}): BackupRun {
return {
id: "r1",
job_id: "j1",
started_at: 1_700_000_000,
ended_at: null,
status: "success",
bytes_transferred: 2048,
duration_ms: 1500,
error_message: null,
details_json: null,
created_at: 1_700_000_000,
...overrides,
};
}
// jsdom lacks matchMedia; default to desktop so the table renders.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => setMatchMedia(false));
describe("BackupJobsTable (desktop)", () => {
it("renders job name and schedule interval", () => {
render(
<BackupJobsTable
jobs={[job({ name: "nightly", schedule_interval_seconds: 86400 })]}
latestRuns={new Map()}
/>,
);
expect(screen.getByText("nightly")).toBeInTheDocument();
expect(screen.getByText("1d")).toBeInTheDocument();
});
});
describe("BackupJobsTable (mobile card layout — slice 5)", () => {
it("renders cards with job name as primary below md", () => {
setMatchMedia(true);
render(
<BackupJobsTable
jobs={[job({ id: "j1", name: "nightly", source: "/data" })]}
latestRuns={
new Map([["j1", run({ status: "success" })]]) as Map<string, BackupRun>
}
/>,
);
expect(screen.getByText("nightly")).toBeInTheDocument();
expect(screen.getAllByText("Source")).toHaveLength(1);
expect(screen.getAllByText("Schedule")).toHaveLength(1);
expect(screen.getAllByText("Last status")).toHaveLength(1);
});
});
@@ -1,4 +1,4 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import BackupRunsTable from "../BackupRunsTable";
import type { BackupRun } from "../../types/backups";
@@ -57,3 +57,29 @@ describe("BackupRunsTable", () => {
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
});
});
// jsdom lacks matchMedia; default to desktop so existing tests are unaffected.
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
beforeEach(() => setMatchMedia(false));
describe("BackupRunsTable (mobile card layout — slice 5)", () => {
it("renders cards with job_id as primary below md", () => {
setMatchMedia(true);
render(<BackupRunsTable runs={[run({ id: "r1", job_id: "nightly" })]} />);
expect(screen.getByText("nightly")).toBeInTheDocument();
expect(screen.getAllByText("Status")).toHaveLength(1);
expect(screen.getAllByText("Duration")).toHaveLength(1);
});
});
@@ -18,4 +18,23 @@ describe("HoverEditButton", () => {
screen.getByRole("button", { name: "Rename machine" }),
).toBeInTheDocument();
});
it('defaults to always-visible below md (mobile="always")', () => {
render(<HoverEditButton onClick={() => {}} />);
const button = screen.getByRole("button", { name: "Edit" });
const tokens = button.className.split(/\s+/);
// The default mobile mode layers hover-reveal only at md+ via
// md:opacity-0/md:group-hover:opacity-100, so the button is visible by
// default below md (no base opacity-0 token).
expect(tokens).toContain("md:opacity-0");
expect(tokens).toContain("md:group-hover:opacity-100");
expect(tokens).not.toContain("opacity-0");
});
it('preserves the legacy opacity-0 behavior when mobile="hover"', () => {
render(<HoverEditButton onClick={() => {}} mobile="hover" />);
const button = screen.getByRole("button", { name: "Edit" });
expect(button.className).toContain("opacity-0");
expect(button.className).toContain("group-hover:opacity-100");
});
});
@@ -0,0 +1,41 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Routes, Route } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { PinnedServiceLink } from "../PinnedServiceLink";
function renderLink() {
return render(
<MemoryRouter initialEntries={["/"]}>
<Routes>
<Route
path="/"
element={
<PinnedServiceLink
label="My Jellyfin"
target="/services/jellyfin/svc-1"
/>
}
/>
<Route
path="/services/jellyfin/svc-1"
element={<div>target page</div>}
/>
</Routes>
</MemoryRouter>,
);
}
describe("PinnedServiceLink", () => {
it("renders the label", () => {
renderLink();
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
});
it("navigates to the target on click", async () => {
const user = userEvent.setup();
renderLink();
await user.click(screen.getByText("My Jellyfin"));
expect(screen.getByText("target page")).toBeInTheDocument();
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { WidgetConfigDialog } from "../WidgetConfigDialog";
// jsdom has no window.matchMedia; default to desktop (matches: false).
function setMatchMedia(matches: boolean) {
window.matchMedia = ((query: string) => ({
matches: query.includes("768") ? matches : false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useSettings", () => ({
useTasks: () => ({ data: [] }),
}));
beforeEach(() => setMatchMedia(false));
describe("WidgetConfigDialog (desktop)", () => {
it("renders a Dialog with the dashboard widgets title at md+", () => {
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
expect(
screen.getByRole("heading", { name: "Dashboard widgets" }),
).toBeInTheDocument();
});
});
describe("WidgetConfigDialog (mobile SheetForm — slice 8)", () => {
beforeEach(() => setMatchMedia(true));
it("renders a SheetForm with the dashboard widgets title below md", () => {
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
expect(screen.getByText("Dashboard widgets")).toBeInTheDocument();
// List mode footer: "Done" button closes.
expect(screen.getByRole("button", { name: "Done" })).toBeInTheDocument();
});
it("prompts before discarding a widget draft (R4.5)", async () => {
const { userEvent } = await import("@testing-library/user-event");
render(<WidgetConfigDialog open={true} onClose={() => {}} />);
// Enter draft mode by clicking an "Add widget" button.
await userEvent.click(screen.getByRole("button", { name: /Backups/i }));
// Now in draft mode — Cancel should prompt before resetting.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MobileCardRow, type MobileCardField } from "../mobile-card";
interface Row {
id: string;
title: string;
size: string;
year: number;
}
const rows: Row[] = [
{ id: "a", title: "Movie A", size: "4.2GB", year: 2026 },
{ id: "b", title: "Movie B", size: "2.1GB", year: 2025 },
];
const fields: MobileCardField<Row>[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size },
{ key: "year", label: "Year", render: (r) => r.year },
];
describe("MobileCardRow", () => {
it("renders the primary field as a title and the rest as key/value pairs", () => {
render(<MobileCardRow rows={rows} fields={fields} />);
// Primary title
expect(screen.getByText("Movie A")).toBeInTheDocument();
expect(screen.getByText("Movie B")).toBeInTheDocument();
// Field labels and values (appear once per row)
expect(screen.getAllByText("Size")).toHaveLength(2);
expect(screen.getAllByText("4.2GB")).toHaveLength(1);
expect(screen.getAllByText("Year")).toHaveLength(2);
expect(screen.getAllByText("2026")).toHaveLength(1);
});
it("fires onRowClick when the card is tapped", async () => {
const onRowClick = vi.fn();
render(
<MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} />,
);
await userEvent.click(screen.getByText("Movie A"));
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(rows[0]);
});
it("renders the actions slot per row", () => {
render(
<MobileCardRow
rows={rows}
fields={fields}
actions={(r) => (
<button type="button" onClick={() => undefined}>
edit-{r.id}
</button>
)}
/>,
);
expect(screen.getByText("edit-a")).toBeInTheDocument();
expect(screen.getByText("edit-b")).toBeInTheDocument();
});
it("renders a non-interactive card when onRowClick is absent", () => {
render(<MobileCardRow rows={rows} fields={fields} />);
// No buttons wrapping the cards.
expect(screen.queryAllByRole("button")).toHaveLength(0);
expect(screen.getByText("Movie A")).toBeInTheDocument();
});
it("renders nothing when rows is empty", () => {
const { container } = render(<MobileCardRow rows={[]} fields={fields} />);
const cards = container.querySelector(".flex.flex-col.gap-2");
expect(cards?.children).toHaveLength(0);
expect(screen.queryByText("Size")).not.toBeInTheDocument();
});
it("renders a card without a title when no primary field is set", () => {
const noPrimary: MobileCardField<Row>[] = fields.filter(
(f) => f.key !== "title",
);
render(<MobileCardRow rows={rows} fields={noPrimary} />);
// No title text rendered, but the key/value stack still is.
expect(screen.queryByText("Movie A")).not.toBeInTheDocument();
expect(screen.getAllByText("Size")).toHaveLength(2);
});
it("uses getRowId for stable keys and emits no duplicate-key warning", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(<MobileCardRow rows={rows} fields={fields} getRowId={(r) => r.id} />);
// No React duplicate-key warning should fire.
const duplicateKeyCalls = errorSpy.mock.calls.filter((args) =>
String(args[0] ?? "").includes("same key"),
);
expect(duplicateKeyCalls).toHaveLength(0);
errorSpy.mockRestore();
});
});
@@ -0,0 +1,168 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SheetForm } from "../sheet-form";
describe("SheetForm", () => {
it("renders the title and children", () => {
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit service"
onSave={() => {}}
onCancel={() => {}}
>
<input aria-label="Name" />
</SheetForm>,
);
expect(screen.getByText("Edit service")).toBeInTheDocument();
expect(screen.getByLabelText("Name")).toBeInTheDocument();
});
it("calls onSave when Save is clicked", async () => {
const onSave = vi.fn();
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={onSave}
onCancel={() => {}}
>
<div />
</SheetForm>,
);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onSave).toHaveBeenCalledTimes(1);
});
it("calls onCancel when Cancel is clicked", async () => {
const onCancel = vi.fn();
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={() => {}}
onCancel={onCancel}
>
<div />
</SheetForm>,
);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("disables Save and shows a pending label when isPending", () => {
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={() => {}}
onCancel={() => {}}
isPending
>
<div />
</SheetForm>,
);
const saveButton = screen.getByRole("button", { name: /Saving/i });
expect(saveButton).toBeDisabled();
expect(screen.getByText("Saving…")).toBeInTheDocument();
});
it("calls onCancel when the close (X) button is clicked", async () => {
const onCancel = vi.fn();
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={() => {}}
onCancel={onCancel}
>
<div />
</SheetForm>,
);
await userEvent.click(screen.getByRole("button", { name: "Close" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
describe("dirty-state confirm (R4.5)", () => {
it("prompts before discarding via Cancel when isDirty", async () => {
const onCancel = vi.fn();
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={() => {}}
onCancel={onCancel}
isDirty
>
<div />
</SheetForm>,
);
// Cancel does not immediately close; a confirm opens.
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).not.toHaveBeenCalled();
expect(
screen.getByRole("heading", { name: "Discard changes?" }),
).toBeInTheDocument();
// Confirm discard -> actually closes.
await userEvent.click(screen.getByRole("button", { name: "Discard" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("closing the confirm without discarding keeps the form open", async () => {
const onCancel = vi.fn();
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={() => {}}
onCancel={onCancel}
isDirty
>
<div />
</SheetForm>,
);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
// Two Cancel buttons now exist: the SheetForm footer and the confirm dialog.
const cancelButtons = screen.getAllByRole("button", { name: "Cancel" });
await userEvent.click(cancelButtons[cancelButtons.length - 1]);
expect(onCancel).not.toHaveBeenCalled();
});
it("closes immediately when not dirty", async () => {
const onCancel = vi.fn();
render(
<SheetForm
open
onOpenChange={() => {}}
title="Edit"
onSave={() => {}}
onCancel={onCancel}
>
<div />
</SheetForm>,
);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(
screen.queryByRole("heading", { name: "Discard changes?" }),
).not.toBeInTheDocument();
});
});
});
+9 -87
View File
@@ -6,7 +6,6 @@ import {
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type Table as TableInstance,
type VisibilityState,
flexRender,
getCoreRowModel,
@@ -18,6 +17,7 @@ import { Columns3 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { TablePagination } from "@/components/ui/table-pagination";
import {
Table,
TableBody,
@@ -34,13 +34,6 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[];
@@ -253,90 +246,19 @@ export function DataTable<TData, TValue = unknown>({
</div>
{enablePagination && (
<DataTablePagination
table={table}
<TablePagination
pageIndex={table.getState().pagination.pageIndex}
pageSize={table.getState().pagination.pageSize}
pageSizeOptions={pageSizeOptions}
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
pageCount={pageCount}
manual={manualPagination}
rowCount={rowCount}
onPaginationChange={table.setPagination}
/>
)}
</div>
);
}
interface PaginationProps<TData> {
table: TableInstance<TData>;
pageSizeOptions: number[];
pageCount: number;
manual: boolean;
rowCount?: number;
}
function DataTablePagination<TData>({
table,
pageSizeOptions,
pageCount,
manual,
rowCount,
}: PaginationProps<TData>) {
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
const visibleRows = table.getRowModel().rows.length;
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
return (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="text-muted-foreground">
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<SelectTrigger
size="sm"
className="w-[70px]"
aria-label="Rows per page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-muted-foreground">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Previous page"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
</div>
);
}
// DataTablePagination was extracted into the shared TablePagination component
// (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
// and the Media mobile card list consume it.
+123
View File
@@ -0,0 +1,123 @@
import * as React from "react";
import { cn } from "@/lib/utils";
/**
* Field descriptor for a {@link MobileCardRow}.
*
* The consuming page decides which fields to show and in what order; this
* primitive does not pick them. Exactly one field should set `primary: true`
* it renders as the card title (bold, larger). The rest render as a key/value
* stack below the title.
*/
export interface MobileCardField<T> {
key: string;
label: string;
render: (row: T) => React.ReactNode;
/** When true, render as the card title (bold, larger). One per card. */
primary?: boolean;
}
export interface MobileCardRowProps<T> {
rows: T[];
fields: MobileCardField<T>[];
/** Stable per-row identity; falls back to the row index when omitted. */
getRowId?: (row: T) => string;
/** When set, the whole card becomes a button (44px min height). */
onRowClick?: (row: T) => void;
/** Optional right-aligned action slot (edit/delete icon buttons). */
actions?: (row: T) => React.ReactNode;
/** Optional className for the outer list container. */
className?: string;
}
/**
* Stacked card list for wide tables below the `md:` breakpoint.
*
* Each row renders as a card: the `primary` field as the title and the
* remaining fields as a key/value stack. When `onRowClick` is provided the
* whole card is a button with a 44px minimum touch target (spec R6.1). An
* optional `actions` slot renders right-aligned controls.
*
* This is the mobile counterpart to {@link DataTable}; pages branch on
* `useIsMobile()`. See OpenSpec change `mobile-responsive-parity`, design
* §`MobileCardRow`.
*/
export function MobileCardRow<T>({
rows,
fields,
getRowId,
onRowClick,
actions,
className,
}: MobileCardRowProps<T>) {
const primary = fields.find((f) => f.primary);
const rest = fields.filter((f) => !f.primary);
return (
<div className={cn("flex flex-col gap-2", className)}>
{rows.map((row, index) => {
const rowKey = getRowId?.(row) ?? String(index);
const body = (
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-1">
{primary ? (
<div className="truncate text-sm font-medium text-foreground">
{primary.render(row)}
</div>
) : null}
{rest.length > 0 ? (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs text-muted-foreground">
{rest.map((field) => (
<React.Fragment key={field.key}>
<dt className="font-medium text-muted-foreground">
{field.label}
</dt>
<dd className="truncate text-foreground">
{field.render(row)}
</dd>
</React.Fragment>
))}
</dl>
) : null}
</div>
{actions ? (
<div className="flex shrink-0 items-center gap-1">
{actions(row)}
</div>
) : null}
</div>
);
if (onRowClick) {
return (
<div
key={rowKey}
role="button"
tabIndex={0}
onClick={() => onRowClick(row)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onRowClick(row);
}
}}
className="mobile-touch-target min-h-11 w-full cursor-pointer rounded-lg border border-border bg-card p-3 text-left transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{body}
</div>
);
}
return (
<div
key={rowKey}
className="min-h-11 rounded-lg border border-border bg-card p-3"
>
{body}
</div>
);
})}
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import * as React from "react";
import { Loader2, XIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
import { ConfirmDialog } from "@/components/ConfirmDialog";
export interface SheetFormProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
onSave: () => void;
onCancel: () => void;
/** Disable Save and show a pending spinner. */
isPending?: boolean;
/** Override the Save button label (default "Save"). */
saveLabel?: string;
/** Disable the Save button (e.g. when required fields are empty). */
saveDisabled?: boolean;
/**
* When true, any close attempt (Cancel button, header X, overlay click,
* Escape) prompts a discard-confirmation instead of immediately closing.
* Spec R4.5.
*/
isDirty?: boolean;
children: React.ReactNode;
/** Optional className applied to the scrolling body. */
bodyClassName?: string;
}
/**
* Full-height form host for the mobile (`< md`) breakpoint.
*
* Wraps the shadcn `Sheet` primitive with a fixed header (title + close) and a
* fixed footer (Cancel + Save). The body scrolls between them. Laid out as a
* flex column (NOT `position: sticky`) because Radix `Sheet` uses transforms,
* which break sticky positioning see OpenSpec change
* `mobile-responsive-parity`, design §`SheetForm` / risks.
*
* Uses `h-[100dvh]` (not `h-screen`) to avoid the iOS Safari URL-bar resize
* jump. Consumers choose this host vs the desktop `Dialog` via `useIsMobile()`.
*/
export function SheetForm({
open,
onOpenChange,
title,
onSave,
onCancel,
isPending = false,
saveDisabled = false,
saveLabel = "Save",
isDirty = false,
children,
bodyClassName,
}: SheetFormProps) {
const [confirmDiscardOpen, setConfirmDiscardOpen] = React.useState(false);
// Route every close path (Cancel, header X, Radix overlay/Escape) through one
// guard so the dirty-confirm is applied uniformly (spec R4.5).
const attemptClose = React.useCallback(() => {
if (isDirty) {
setConfirmDiscardOpen(true);
} else {
onCancel();
}
}, [isDirty, onCancel]);
const handleOpenChange = React.useCallback(
(next: boolean) => {
if (!next) {
attemptClose();
} else {
onOpenChange(next);
}
},
[attemptClose, onOpenChange],
);
return (
<Sheet open={open} onOpenChange={handleOpenChange}>
<SheetContent
side="bottom"
showCloseButton={false}
className="flex h-[100dvh] w-full flex-col gap-0 p-0 sm:max-w-full"
onEscapeKeyDown={(e) => {
// Prevent Radix's default Escape close so our guard runs instead.
if (isDirty) {
e.preventDefault();
attemptClose();
}
}}
onPointerDownOutside={(e) => {
// Prevent overlay-click close so our guard runs instead.
if (isDirty) {
e.preventDefault();
attemptClose();
}
}}
>
{/* Header — fixed at top */}
<div className="flex h-14 shrink-0 items-center justify-between border-b border-border px-4">
<SheetTitle className="font-heading text-base font-medium">
{title}
</SheetTitle>
<Button
variant="ghost"
size="icon-sm"
aria-label="Close"
onClick={attemptClose}
>
<XIcon />
</Button>
</div>
{/* Body — scrolls */}
<div className={cn("flex-1 overflow-y-auto p-4", bodyClassName)}>
{children}
</div>
{/* Footer — fixed at bottom */}
<div className="flex shrink-0 items-center justify-end gap-2 border-t border-border bg-muted/50 p-4">
<Button variant="outline" onClick={attemptClose} disabled={isPending}>
Cancel
</Button>
<Button onClick={onSave} disabled={isPending || saveDisabled}>
{isPending ? (
<>
<Loader2 className="animate-spin" />
Saving
</>
) : (
saveLabel
)}
</Button>
</div>
</SheetContent>
<ConfirmDialog
open={confirmDiscardOpen}
title="Discard changes?"
message="You have unsaved changes. Discard them and close?"
confirmLabel="Discard"
onCancel={() => setConfirmDiscardOpen(false)}
onConfirm={() => {
setConfirmDiscardOpen(false);
onCancel();
}}
/>
</Sheet>
);
}
@@ -0,0 +1,122 @@
import type { OnChangeFn } from "@tanstack/react-table";
import type { PaginationState } from "@tanstack/react-table";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
/**
* Shared pagination footer for table-style views.
*
* Renders the rows count, page-size select, page indicator, and prev/next
* buttons. Works off the raw {@link PaginationState} primitives so it can back
* both a TanStack `Table` instance (via a thin adapter) and standalone card
* layouts that drive pagination directly (e.g. MediaMobilePagination).
*
* The Desktop DataTable and the Media mobile card list both consume this to
* avoid the duplication flagged in
* `openspec/changes/mobile-responsive-parity/verify-report.md` residual risk #5.
*/
export interface TablePaginationProps {
pageIndex: number;
pageSize: number;
pageSizeOptions: number[];
totalRows: number;
pageCount: number;
onPaginationChange: OnChangeFn<PaginationState>;
/** Optional extra className on the outer container (e.g. "p-4"). */
className?: string;
}
export function TablePagination({
pageIndex,
pageSize,
pageSizeOptions,
totalRows,
pageCount,
onPaginationChange,
className,
}: TablePaginationProps) {
return (
<div
className={cn(
"flex flex-wrap items-center justify-between gap-3 text-sm",
className,
)}
>
<div className="text-muted-foreground">
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) =>
onPaginationChange(() => ({
pageIndex: 0,
pageSize: Number(value),
}))
}
>
<SelectTrigger
size="sm"
className="w-[70px]"
aria-label="Rows per page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-muted-foreground">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() =>
onPaginationChange((prev) => ({
...prev,
pageIndex: Math.max(0, prev.pageIndex - 1),
}))
}
disabled={pageIndex <= 0}
aria-label="Previous page"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() =>
onPaginationChange((prev) => ({
...prev,
pageIndex: prev.pageIndex + 1,
}))
}
disabled={pageIndex >= pageCount - 1}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
</div>
);
}
+43
View File
@@ -0,0 +1,43 @@
/** Hooks for the Authentik directory + messaging tabs. */
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
fetchAuthentikMessageStatus,
fetchAuthentikUsers,
sendAuthentikMessage,
} from "../api/authentik";
export function useAuthentikUsers(
serviceId: string,
params: { search?: string; page?: number; page_size?: number },
) {
return useQuery({
queryKey: ["authentik", "users", serviceId, params],
queryFn: () => fetchAuthentikUsers(serviceId, params),
staleTime: 10_000,
});
}
export function useSendAuthentikMessage(serviceId: string) {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: {
recipient_emails: string[];
subject: string;
html_body: string;
}) => sendAuthentikMessage(serviceId, input),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["authentik", "message-status", serviceId],
});
},
});
}
export function useAuthentikMessageStatus(serviceId: string) {
return useQuery({
queryKey: ["authentik", "message-status", serviceId],
queryFn: () => fetchAuthentikMessageStatus(serviceId),
refetchInterval: 5_000,
staleTime: 0,
});
}
+47
View File
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createDashboard,
deleteDashboard,
fetchDashboardBySlug,
fetchDashboards,
updateDashboard,
type NamedDashboardInput,
} from "../api/dashboards";
export function useDashboards() {
return useQuery({
queryKey: ["dashboards"],
queryFn: fetchDashboards,
staleTime: 30 * 1000,
});
}
export function useDashboardBySlug(slug: string | undefined) {
return useQuery({
queryKey: ["dashboards", "slug", slug],
queryFn: () => fetchDashboardBySlug(slug!),
enabled: !!slug,
staleTime: 30 * 1000,
});
}
export function useSaveDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: NamedDashboardInput) =>
input.id ? updateDashboard(input) : createDashboard(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
},
});
}
export function useDeleteDashboard() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => deleteDashboard(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["dashboards"] });
},
});
}
+38
View File
@@ -0,0 +1,38 @@
import { useEffect, useState } from "react";
/** Mobile breakpoint (must match Tailwind `md:` and the OpenSpec spec R1.2). */
const MOBILE_QUERY = "(max-width: 768px)";
/**
* Single source of truth for the mobile/desktop responsive cut.
*
* Returns `true` when the viewport matches `max-width: 768px` (phone portrait),
* `false` at `md:` and above. SSR-safe: returns `false` when `window` is
* undefined so server-rendered markup stays on the desktop path.
*
* Replaces the ad-hoc `window.matchMedia("(max-width: 768px)")` reads scattered
* across pages (App.tsx, Media.tsx) see OpenSpec change
* `mobile-responsive-parity`, design §`useIsMobile`.
*/
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(
() =>
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia(MOBILE_QUERY).matches,
);
useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
)
return;
const mql = window.matchMedia(MOBILE_QUERY);
const handler = (e: MediaQueryListEvent) => setIsMobile(e.matches);
mql.addEventListener("change", handler);
return () => mql.removeEventListener("change", handler);
}, []);
return isMobile;
}
+4 -1
View File
@@ -14,7 +14,10 @@ export function useMediaStatus(jellyfinServiceId?: string) {
staleTime: 5_000,
refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false,
refetchIntervalInBackground: true,
// Inherit the default refetchIntervalInBackground: false — pause the
// 1s build-progress poll when the tab is hidden. The build keeps
// running server-side; the poll resumes and catches up on return.
// Battery-friendly (D8 follow-up).
});
}
-11
View File
@@ -1,11 +0,0 @@
import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types";
export function useUsers(jellyfinServiceId?: string) {
return useQuery<UserDirectoryResponse>({
queryKey: ["users", jellyfinServiceId ?? "default"],
queryFn: () => fetchUsers(jellyfinServiceId),
staleTime: 30_000,
});
}
+16
View File
@@ -100,3 +100,19 @@ body,
* {
box-sizing: border-box;
}
/*
* Mobile touch-target utility (spec R6.1).
*
* Applies a 44x44px minimum hit area to interactive elements ONLY below the
* `md:` (768px) breakpoint, satisfying WCAG 2.5.5 / Apple HIG on touch devices.
* At md+ the class is inert so desktop sizing is not regressed. Pages sprinkle
* this on icon buttons, checkboxes, switches, and row taps. See OpenSpec
* change `mobile-responsive-parity`, design §`mobile-touch-target`.
*/
@media (max-width: 767px) {
.mobile-touch-target {
min-height: 44px;
min-width: 44px;
}
}
@@ -0,0 +1,55 @@
import { describe, it, expect } from "vitest";
import { configuredNavEntries, SERVICE_TYPE_NAV_ENTRIES } from "../navEntries";
describe("navEntries", () => {
it("returns no entries when no types are configured", () => {
expect(configuredNavEntries(new Set())).toEqual([]);
});
it("returns Media when jellyfin is configured", () => {
const entries = configuredNavEntries(new Set(["jellyfin"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("Media");
expect(entries[0].path).toBe("/services/jellyfin");
});
it("returns Files + Actions when ssh_tasks is configured", () => {
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
expect(entries).toHaveLength(2);
expect(entries.map((e) => e.label)).toEqual(["Files", "Actions"]);
});
it("returns all observability entries", () => {
const entries = configuredNavEntries(
new Set(["alertmanager", "grafana", "prometheus"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Alerts",
"Grafana",
"Prometheus",
]);
});
it("returns Backups + Users when configured", () => {
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
expect(entries.map((e) => e.label)).toEqual(["Backups", "Users"]);
});
it("nextcloud has no nav entries in the static map", () => {
expect(
SERVICE_TYPE_NAV_ENTRIES.filter((e) => e.serviceType === "nextcloud"),
).toEqual([]);
});
it("preserves declaration order across mixed types", () => {
const entries = configuredNavEntries(
new Set(["authentik", "ssh_tasks", "jellyfin"]),
);
expect(entries.map((e) => e.label)).toEqual([
"Media",
"Files",
"Actions",
"Users",
]);
});
});
+91
View File
@@ -0,0 +1,91 @@
/**
* Service-type conditional nav-entry map.
*
* Each configured service type contributes one or more top-level nav entries
* that appear only when at least one enabled instance of that type exists.
* See OpenSpec change `services-as-hub-ia`, spec R1.2.
*/
import {
Activity,
DatabaseBackup,
FolderOpen,
GanttChartSquare,
Link2,
Monitor,
Users,
Zap,
type LucideIcon,
} from "lucide-react";
export interface NavEntry {
serviceType: string;
label: string;
icon: LucideIcon;
/** Route path for this entry. */
path: string;
}
/**
* Static mapping from service type to its conditional nav entries.
* `nextcloud` has no entries (no operational content).
*/
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
{
serviceType: "jellyfin",
label: "Media",
icon: Monitor,
path: "/services/jellyfin",
},
{
serviceType: "ssh_tasks",
label: "Files",
icon: FolderOpen,
path: "/services/ssh_tasks",
},
{
serviceType: "ssh_tasks",
label: "Actions",
icon: Zap,
path: "/services/ssh_tasks",
},
{
serviceType: "alertmanager",
label: "Alerts",
icon: Activity,
path: "/services/alertmanager",
},
{
serviceType: "grafana",
label: "Grafana",
icon: Link2,
path: "/services/grafana",
},
{
serviceType: "prometheus",
label: "Prometheus",
icon: GanttChartSquare,
path: "/services/prometheus",
},
{
serviceType: "backups",
label: "Backups",
icon: DatabaseBackup,
path: "/services/backups",
},
{
serviceType: "authentik",
label: "Users",
icon: Users,
path: "/services/authentik",
},
];
/**
* Filter the static entries to those whose service type is configured (present
* in the `configuredTypes` set). Returns a flat list in declaration order.
*/
export function configuredNavEntries(configuredTypes: Set<string>): NavEntry[] {
return SERVICE_TYPE_NAV_ENTRIES.filter((e) =>
configuredTypes.has(e.serviceType),
);
}
+1 -1
View File
@@ -2,7 +2,7 @@
dir: frontend/src/pages
## role
Top-level page components for the application's main navigation routes, each encapsulating a full-feature UI for managing services, media, files, users, settings, and system actions.
Top-level page components constituting the primary UI screens/routes of the frontend application, covering dashboards, media management, file browsing, services, settings, users, and task automation.
## parent
index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.md
+5 -5
View File
@@ -4,7 +4,7 @@ dir: frontend/src/pages
index: frontend/src/pages/.pi-map.index.md
## role
Top-level page components for the application's main navigation routes, each encapsulating a full-feature UI for managing services, media, files, users, settings, and system actions.
Top-level page components constituting the primary UI screens/routes of the frontend application, covering dashboards, media management, file browsing, services, settings, users, and task automation.
## files
- Actions.tsx | Provides a React component for managing reusable server tasks (shell/python actions) with CRUD operations, service selection, and execution history display. | exp: func:Actions(), call:useServiceInstances, call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:setRunServiceId, call:saveTask.mutateAsync, call:setTab, call:String, call:tasks.map, call:openEdit, call:initialFromTask, call:runTask.mutateAsync, call:sshServices.map, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../types, ../hooks/useSettings, ../hooks/useServices, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea
- Applications.tsx | Renders a tabbed Applications dashboard with Jellyfin library statistics and media management, plus a placeholder for future Nextcloud support. | exp: func:Applications(), call:useState | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/tabs, ./Media, ../hooks/useDashboard, ../hooks/useServices, ../components/SectionCard, ../components/TabbedCard
@@ -12,15 +12,15 @@ Top-level page components for the application's main navigation routes, each enc
- FileBrowser.impl.tsx | A React component that implements a file browser with directory listing, file selection, ffprobe media metadata inspection, and job execution capabilities. | exp: func:FileBrowser(), call:useSearchParams, call:useState, call:useMonitoringSettings, call:useMemo, call:(machines ?? []).filter, call:machine.services.includes, call:searchParams.get, call:usePersistentState, call:isVideoFile, call:requestedPath.includes, call:requestedPath.replace, call:selectedPath.replace, call:defaultFileBrowserState, call:useNavigate, call:setBrowserState, call:useDirectoryListing, call:useFfprobe, call:useJobTemplates, call:useRunJob, call:updateBrowserState, call:setSearchParams, call:next.set, call:next.delete, call:navigate, call:currentDir.replace, call:rows.push, call:entry.name.split(".").pop, call:formatSize, call:formatTime, call:updater, call:Object.keys(next).filter, call:rows.find, call:templates?.find, call:fileMachines.map, call:refetch, call:String, call:templates.map, call:runJob.mutate, call:navigateToSettings | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/tabs, ../hooks/useFiles, ../hooks/usePersistentState, ../hooks/useSettings, ../components/SectionCard, ../components/TabbedCard
- FileBrowser.tsx | Re-exports the FileBrowser component from its implementation file | dep: ./FileBrowser.impl
- Media.tsx | A React component for managing and browsing Jellyfin media libraries with server-driven pagination, index building controls, and responsive data table display. | exp: func:Media(), call:useNavigate, call:useSearchParams, call:usePrefersSmallScreen, call:useServiceInstances, call:searchParams.get, call:jellyfinServices.find, call:useCounts, call:useLibraries, call:useMediaStatus, call:useBuildIndex, call:useStopBuildIndex, call:useForceStopBuildIndex, call:usePersistentState, call:defaultMediaTabState, call:setMediaState, call:useState, call:useEffect, call:setSearchParams, call:next.set, call:useMediaDataQuery, call:Math.floor, call:updater, call:useMemo, call:navigate, call:encodeURIComponent, call:Math.max, call:Math.ceil, call:formatDuration, call:jellyfinServices.map, call:status.item_count.toLocaleString, call:counts.movies.toLocaleString, call:counts.series.toLocaleString, call:counts.episodes.toLocaleString, call:(libraries?.length ?? 0).toLocaleString, call:buildIndex.mutate, call:stopBuildIndex.mutate, call:forceStopBuildIndex.mutate, call:Math.round, call:status?.build_items_processed?.toLocaleString, call:status?.build_items_total?.toLocaleString, call:status?.build_library_items_processed?.toLocaleString, call:status?.build_library_items_total?.toLocaleString, call:updateMediaState, call:total.toLocaleString | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/alert, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/progress, @/components/ui/select, ../hooks/useMedia, ../hooks/usePersistentState, ../types, ../hooks/useServices, ../hooks/useDashboard, @/components/ui (data-table, alert, button, card, input, label, progress, select)
- ServicePage.tsx | Provides a UI for viewing and editing a service instance's configuration, secrets, and widget bindings, with save and delete functionality. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useSaveServiceInstance, call:useDeleteServiceInstance, call:useMemo, call:services.find, call:getServiceBinding, call:useState, call:setName, call:setEnabled, call:setHydrated, call:saveService.mutateAsync, call:buildInput, call:setDeleteOpen, call:binding.widgets.map, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../integrations/registry, @/components/ui/*
- ServicePage.tsx | Provides a settings form to view, edit (config and secrets), save, and delete a specific service instance, and lists its available widgets. | exp: func:ServicePage(), call:useParams, call:useServiceInstances, call:useServiceTypes, call:useSaveServiceInstance, call:useDeleteServiceInstance, call:useMemo, call:services.find, call:getServiceBinding, call:types.find, call:useState, call:setName, call:setEnabled, call:setDraftConfig, call:setHydrated, call:saveService.mutateAsync, call:buildInput, call:setDeleteOpen, call:binding.widgets.map, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch)
- ServicesPage.tsx | A React page component for managing external service instances, allowing users to view, create, and delete service configurations with dynamic form fields based on service type schemas. | exp: func:ServicesPage(), call:useNavigate, call:useServiceInstances, call:useServiceTypes, call:useDeleteServiceInstance, call:useState, call:useMemo, call:map.get, call:list.push, call:map.set, call:[...map.entries()].sort, call:map.entries, call:a[0].localeCompare, call:types.find, call:getServiceBinding, call:setCreateOpen, call:grouped.map, call:typeName, call:instances.map, call:Object.entries(s.secrets_set).some, call:navigate, call:setDeleteId, call:Boolean, call:deleteService.mutate | dep: react, react-router-dom, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/switch, @/components/ui/dialog, lucide-react, ../hooks/useServices, ../types, ../components/SectionCard, ../components/ConfirmDialog, ../components/DialogFooter, ../integrations/registry, @/components/ui (alert, badge, button, input, label, switch, dialog)
- Settings.tsx | This file provides the UI components and form logic for managing application settings, including configuring monitoring machines via SSH and managing SSH keys. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea
- Settings.tsx | Provides a React settings UI for managing monitoring machines, SSH keys, and local database operations with form editing and validation. | exp: func:Settings(), call:useMonitoringSettings, call:useSSHKeys, call:useSaveMonitoringMachine, call:useDeleteMonitoringMachine, call:useTestMonitoringMachineSSH, call:useState, call:emptyMachine, call:useMemo, call:orderedMachines.find, call:setSSHValidationMessage, call:setSSHValidationError, call:setSSHValidationStatus, call:clearSSHValidation, call:setMachineDraft, call:setEditingMachine, call:setMachineDialogOpen, call:saveMachine.mutateAsync, call:testMachineSSH.mutateAsync, call:String, call:message.toLowerCase, call:lowered.includes, call:setTab, call:orderedMachines.map, call:setSelectedMachineId, call:cn, call:openEditMachine, call:setDeleteMachineId, call:closeMachineDialog, call:saveMachineDraft, call:machineDraft.host.trim, call:Boolean, call:deleteMachine.mutate | dep: react, ../types, ../hooks/useSettings, ../components/DialogFooter, ../components/HoverEditButton, ../components/SectionCard, ../components/SelectionRailCard, ../components/TabbedCard, ../components/ConfirmDialog, @/lib/utils, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/checkbox, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/switch, @/components/ui/tabs, @/components/ui/textarea, @/components/ui/*
- Users.tsx | Re-exports the UsersPage component from its implementation file to provide a cleaner import interface. | dep: ./UsersPage.impl
- UsersPage.impl.tsx | A React page component for managing and messaging Jellyfin users with Jellyseerr enrichment, featuring a searchable directory table, user selection, email composition dialog, and queue status monitoring. | exp: func:UsersPage(), call:useUsers, call:useActivity, call:useUserMessageQueueStatus, call:useSendUserMessage, call:useIsMobile, call:useState, call:useSearchParams, call:useRef, call:useMemo, call:mergeUsersWithActivity, call:search.trim().toLowerCase, call:rows.filter, call:[ row.username, row.display_name, row.email, row.email_source, row.avatar_source, row.name_source, row.access_source, row.user_type_label, row.role, row.permissions_label, row.jellyseerr_username, row.activity_label, row.activity_summary, row.activity.primary_session?.title || "", String(row.jellyseerr_user_id ?? ""), ].some, call:String, call:value.toLowerCase().includes, call:queueStatus.active_request_id.slice, call:selectedIdSet.has, call:selectedRows.filter, call:filteredRows.filter, call:setSelectedUserIds, call:current.includes, call:current.filter, call:filteredRows.forEach, call:next.add, call:next.delete, call:Array.from, call:searchParams.get, call:resolveUserSelection, call:buildUserDrawerModel, call:sendUserMessage.reset, call:subject.trim, call:setSubject, call:htmlBody.trim, call:setHtmlBody, call:setComposeOpen, call:htmlBody.slice, call:requestAnimationFrame, call:textarea.focus, call:textarea.setSelectionRange, call:window.prompt, call:insertMarkup, call:setAttachments, call:formData.append, call:JSON.stringify, call:allSelectedRows.map, call:attachments.forEach, call:sendUserMessage.mutateAsync, call:setSearch, call:cn, call:toggleVisibleSelection, call:filteredRows.map, call:setSearchParams, call:event.stopPropagation, call:toggleUserSelected, call:userLabel(row).charAt(0).toUpperCase, call:activityBadgeVariant, call:Boolean, call:drawerModel.title.charAt(0).toUpperCase, call:drawerModel.identity.map, call:drawerModel.contactActions.map, call:drawerModel.contactActions .map((action) => action.hint) .join, call:drawerModel.permissions.map, call:closeCompose, call:sendUserMessage.data.request_id.slice, call:selectedDeliverableRows.map, call:attachments.map, call:removeAttachment | dep: react, react-router-dom, lucide-react, @/components/ui/dialog, @/components/ui/input, @/components/ui/textarea, @/components/ui/separator, @/components/ui/label, @/components/ui/avatar, @/components/ui/badge, @/components/ui/button, @/components/ui/checkbox, @/components/ui/alert, @/components/ui/progress, @/components/ui/tooltip, @/components/ui/sheet, @/components/ui/table, @/lib/utils, ../components/MetricCard, ../components/SessionActivityPanel, ../hooks/useUsers, ../hooks/useDashboard, ../hooks/useSendUserMessage, ../hooks/useUserMessageQueueStatus, ../types, ../users, ../userState, @/components/ui (dialog, input, textarea, separator, label, avatar, badge, button, checkbox, alert, progress, tooltip, sheet, table)
## arch
React functional component pattern with lazy-loaded implementations (`.impl.tsx`) behind clean re-export facades (e.g., `FileBrowser.tsx`, `Users.tsx`), using dynamic service-type schemas and tabbed dashboard composition.
React functional component pattern with implementation/re-export splitting; each page encapsulates its own state management, CRUD operations, and UI rendering, typically integrating backend APIs, dynamic forms, and tabular/dialog-based interfaces.
## tags
call:use, components, ui, call:set, state, service, machine, react
call:use, components, ui, call:set, state, service, react, machine
## symbols
- Actions
- Applications
-133
View File
@@ -1,133 +0,0 @@
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useServiceInstances } from "../hooks/useServices";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
function JellyfinLibraryStats() {
const [searchParams] = useSearchParams();
const { data: jellyfinServices = [] } = useServiceInstances("jellyfin");
const selectedServiceId =
searchParams.get("jellyfin_service_id") ||
jellyfinServices.find((s) => s.enabled)?.id ||
"";
const { data: counts } = useCounts(selectedServiceId || undefined);
const { data: libraries } = useLibraries(selectedServiceId || undefined);
return (
<SectionCard
title="Library stats"
description="Compact Jellyfin summary for the selected machine."
action={
<Badge variant="outline">
{selectedServiceId ? "Selected service" : "Default service"}
</Badge>
}
>
<div className="flex flex-col gap-2">
{counts ? (
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Total</span>
<div className="text-base leading-tight font-extrabold">
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Movies</span>
<div className="text-base leading-tight font-extrabold">
{counts.movies.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Series</span>
<div className="text-base leading-tight font-extrabold">
{counts.series.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Episodes</span>
<div className="text-base leading-tight font-extrabold">
{counts.episodes.toLocaleString()}
</div>
</div>
</div>
) : null}
{libraries?.length ? (
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{libraries.map((library) => (
<div
key={library.library}
className="rounded-lg border bg-card px-3 py-2"
>
<div className="flex flex-col gap-1">
<span className="truncate text-sm font-semibold">
{library.library}
</span>
<span className="text-sm text-muted-foreground">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</span>
</div>
</div>
))}
</div>
) : null}
</div>
</SectionCard>
);
}
export function Applications() {
const [tab, setTab] = useState("jellyfin");
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">Applications</h1>
<p className="text-sm text-muted-foreground">
Browse application-specific tools from a compact tabbed workspace.
</p>
</div>
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<TabsTrigger key="jellyfin" value="jellyfin">
Jellyfin
</TabsTrigger>,
<TabsTrigger key="nextcloud" value="nextcloud">
Nextcloud
</TabsTrigger>,
]}
>
{tab === "jellyfin" ? (
<div className="flex flex-col gap-4">
<JellyfinLibraryStats />
<Media />
</div>
) : (
<div className="rounded-lg border bg-card p-3">
<Alert>
<AlertDescription>
Nextcloud support will be added in a future update.
</AlertDescription>
</Alert>
</div>
)}
</TabbedCard>
</div>
);
}
+150 -4
View File
@@ -1,5 +1,11 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import {
Activity,
DatabaseBackup,
LayoutDashboard,
Monitor,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -26,13 +32,121 @@ import {
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import type {
DashboardShortcut,
DashboardShortcutInput,
ServiceInstance,
WidgetInstance,
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
// --- Mobile section grouping (mobile-parity) ---
const SECTION_ORDER = ["observability", "media", "backups", "custom"] as const;
type SectionId = (typeof SECTION_ORDER)[number];
const SECTION_META: Record<
SectionId,
{ label: string; icon: typeof Activity }
> = {
observability: { label: "Observability", icon: Activity },
media: { label: "Media", icon: Monitor },
backups: { label: "Backups", icon: DatabaseBackup },
custom: { label: "Custom", icon: LayoutDashboard },
};
const OBSERVABILITY_TYPES = new Set(["alertmanager", "prometheus", "grafana"]);
function widgetSection(
widget: WidgetInstance,
services: ServiceInstance[],
): SectionId {
if (!widget.service_id) {
return widget.widget_kind === "backups" ? "backups" : "custom";
}
const service = services.find((s) => s.id === widget.service_id);
const serviceType = service?.service_type ?? "";
if (OBSERVABILITY_TYPES.has(serviceType)) return "observability";
if (serviceType === "jellyfin") return "media";
return "custom";
}
function groupWidgetsBySection(
widgets: WidgetInstance[],
services: ServiceInstance[],
): { id: SectionId; widgets: WidgetInstance[] }[] {
const groups: Record<SectionId, WidgetInstance[]> = {
observability: [],
media: [],
backups: [],
custom: [],
};
for (const w of widgets) {
groups[widgetSection(w, services)].push(w);
}
return SECTION_ORDER.map((id) => ({ id, widgets: groups[id] })).filter(
(s) => s.widgets.length > 0,
);
}
function MobileWidgetSections({
sections,
}: {
sections: { id: SectionId; widgets: WidgetInstance[] }[];
}) {
return (
<>
<div className="-mx-1 flex gap-2 overflow-x-auto px-1 pb-1">
{sections.map((section) => {
const meta = SECTION_META[section.id];
const Icon = meta.icon;
return (
<button
key={section.id}
type="button"
className="mobile-touch-target inline-flex shrink-0 items-center gap-1.5 rounded-full border border-border bg-card px-3 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
onClick={() =>
document
.getElementById(`dashboard-section-${section.id}`)
?.scrollIntoView({
behavior: "smooth",
block: "start",
})
}
>
<Icon className="size-3.5" />
{meta.label}
</button>
);
})}
</div>
<div className="grid grid-cols-1 gap-4">
{sections.map((section) => (
<section
key={section.id}
id={`dashboard-section-${section.id}`}
className="scroll-mt-16 flex flex-col gap-2"
>
<h3 className="text-sm font-semibold text-muted-foreground">
{SECTION_META[section.id].label}
</h3>
{section.widgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
</section>
))}
</div>
</>
);
}
function emptyShortcut(): DashboardShortcutInput {
return {
id: null,
@@ -336,6 +450,8 @@ export function Dashboard() {
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile();
const visibleWidgets = useMemo(
() =>
@@ -345,6 +461,11 @@ export function Dashboard() {
[widgetInstances],
);
const mobileSections = useMemo(
() => groupWidgetsBySection(visibleWidgets, services),
[visibleWidgets, services],
);
const openCreateShortcut = () => {
setShortcutDraft(emptyShortcut());
setShortcutDialogOpen(true);
@@ -374,6 +495,27 @@ export function Dashboard() {
return (
<div className="flex flex-col gap-4">
{services.length === 0 ? (
<SectionCard
title="Welcome to Manage"
description="Add a service to get started."
>
<div className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
No services configured yet. Add a Jellyfin, SSH target, Authentik,
or observability service to populate the navigation and
dashboards.
</p>
<Button
variant="outline"
onClick={() => navigate("/services")}
className="w-fit"
>
Add a service
</Button>
</div>
</SectionCard>
) : null}
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
@@ -417,9 +559,13 @@ export function Dashboard() {
)}
</SectionCard>
{visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))}
{isMobile && mobileSections.length > 0 ? (
<MobileWidgetSections sections={mobileSections} />
) : (
visibleWidgets.map((widget) => (
<WidgetInstanceCard key={widget.id} widget={widget} />
))
)}
<ShortcutDialog
open={shortcutDialogOpen}
-1
View File
@@ -1 +0,0 @@
export { FileBrowser } from "./FileBrowser.impl";
+91
View File
@@ -0,0 +1,91 @@
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { Boxes } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import { useDashboardBySlug } from "../hooks/useDashboards";
import { PinnedServiceLink } from "../components/PinnedServiceLink";
/**
* Payload model for named dashboards (design choice: inline items, not widget
* instance ids). The payload stores an ordered list of items:
*
* ```
* { items: DashboardItem[] }
* ```
*
* Where `DashboardItem` is either a pinned service link (this slice) or a
* future widget reference (follow-up). Widget composition on named dashboards
* is deferred the main Dashboard already has the rich widget config dialog.
*/
interface LinkItem {
type: "link";
label: string;
target: string;
}
type DashboardItem = LinkItem;
function parseItems(payload: Record<string, unknown>): DashboardItem[] {
const items = payload.items;
if (!Array.isArray(items)) return [];
return items.filter(
(item): item is LinkItem =>
typeof item === "object" &&
item !== null &&
item.type === "link" &&
typeof item.label === "string" &&
typeof item.target === "string",
);
}
export function NamedDashboardPage() {
const { slug = "" } = useParams<{ slug: string }>();
const { data: dashboard, isLoading, isError } = useDashboardBySlug(slug);
const items = useMemo(
() => parseItems(dashboard?.payload ?? {}),
[dashboard?.payload],
);
if (isLoading) {
return <Skeleton className="h-32 w-full" />;
}
if (isError || !dashboard) {
return (
<Alert>
<AlertDescription>
Dashboard not found. It may have been deleted or the link is invalid.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<div>
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
</div>
{items.length === 0 ? (
<Alert>
<AlertDescription>
This dashboard has no shortcuts yet. Add pinned service links from
the dashboard management panel on the Services page.
</AlertDescription>
</Alert>
) : (
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{items.map((item, index) => (
<PinnedServiceLink
key={`${item.target}-${index}`}
label={item.label}
target={item.target}
icon={Boxes}
/>
))}
</div>
)}
</div>
);
}
+322 -101
View File
@@ -1,20 +1,40 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { useParams, useNavigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
} from "../hooks/useServices";
import type { ServiceInstance, ServiceInstanceInput } from "../types";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { getServiceBinding } from "../integrations/registry";
import {
OVERVIEW_TAB,
serviceContentTabs,
type ContentTab,
} from "./service-tabs";
function Field({
label,
@@ -44,6 +64,8 @@ export function ServicePage() {
serviceId: string;
}>();
const { data: services = [] } = useServiceInstances(serviceType || undefined);
const { data: types = [] } = useServiceTypes();
const navigate = useNavigate();
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
@@ -52,16 +74,39 @@ export function ServicePage() {
[services, serviceId],
);
const binding = getServiceBinding(serviceType);
const typeInfo = useMemo(
() => types.find((t) => t.service_type === serviceType),
[types, serviceType],
);
const contentTabs = useMemo(
() => serviceContentTabs(serviceType),
[serviceType],
);
const siblings = useMemo(
() => services.filter((s) => s.service_type === serviceType),
[services, serviceType],
);
// R3.1: switcher trigger keys off ENABLED siblings (not total).
const enabledSiblings = useMemo(
() => siblings.filter((s) => s.enabled),
[siblings],
);
const showSwitcher = enabledSiblings.length > 1;
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const [hydrated, setHydrated] = useState(false);
const isMobile = useIsMobile();
const [sheetOpen, setSheetOpen] = useState(true);
// Hydrate local form state once the instance loads.
if (instance && !hydrated) {
setName(instance.name);
setEnabled(instance.enabled);
setDraftConfig({ ...instance.config });
setDraftSecrets({});
setHydrated(true);
}
@@ -82,86 +127,191 @@ export function ServicePage() {
}
function buildInput(): ServiceInstanceInput {
// R2.3/R10.1: collect typed secret drafts. Empty values mean "keep the
// existing value" so they are filtered out before sending.
const onlyChangedSecrets = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
return {
id: instance!.id,
service_type: instance!.service_type,
name,
config: instance!.config,
secrets: {}, // secrets are managed via the dedicated inputs below
config: draftConfig,
secrets: onlyChangedSecrets,
enabled,
};
}
async function save() {
await saveService.mutateAsync(buildInput());
// Clear secret drafts after a successful save so the inputs reset to
// "leave blank to keep" state.
setDraftSecrets({});
}
const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
// The config + widgets body, shared between desktop tabs and mobile SheetForm.
const widgetsContent =
binding.widgets.length > 0 ? (
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
) : (
<p className="text-sm text-muted-foreground">
No widget kinds for this service type.
</p>
);
const configBody = (
<ConfigBody
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
draftSecrets={draftSecrets}
onSecretsChange={setDraftSecrets}
name={name}
enabled={enabled}
onNameChange={setName}
onEnabledChange={setEnabled}
onSave={save}
savePending={saveService.isPending}
onDelete={() => setDeleteOpen(true)}
/>
);
// Mobile: render inside a SheetForm (open on mount; cancel navigates back).
if (isMobile) {
return (
<div className="flex flex-col gap-4">
<SheetForm
open={sheetOpen}
onOpenChange={setSheetOpen}
title={name || instance.name}
onSave={save}
onCancel={() => {
setSheetOpen(false);
navigate("/services");
}}
isPending={saveService.isPending}
isDirty={
name !== instance.name ||
enabled !== instance.enabled ||
JSON.stringify(draftConfig) !== JSON.stringify(instance.config)
}
>
<div className="flex flex-col gap-6">
{allTabs.map((tab) => {
const TabComponent = tab.Component;
return (
<div key={tab.label}>
<h3 className="mb-2 text-sm font-semibold text-muted-foreground">
{tab.label}
</h3>
<TabComponent instance={instance} />
</div>
);
})}
{widgetsContent}
{configBody}
</div>
</SheetForm>
<ConfirmDialog
open={deleteOpen}
title="Delete service?"
message="This removes the service and any widgets that reference it. This cannot be undone."
confirmLabel="Delete"
onCancel={() => setDeleteOpen(false)}
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div>
{/* Header + instance switcher */}
<div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-xl font-semibold">{instance.name}</h2>
<p className="text-sm text-muted-foreground">{binding.description}</p>
</div>
<Badge variant="outline">{binding.name}</Badge>
<div className="flex items-center gap-2">
{showSwitcher ? (
<Select
value={instance.id}
onValueChange={(id) => navigate(`/services/${serviceType}/${id}`)}
>
<SelectTrigger className="w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{siblings.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.name}
</SelectItem>
))}
</SelectContent>
</Select>
) : null}
<Badge variant="outline">{binding.name}</Badge>
</div>
</div>
<SectionCard title="General">
<div className="flex flex-col gap-3">
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
<div className="flex justify-between">
<Button onClick={save} disabled={saveService.isPending}>
Save
</Button>
<Button variant="destructive" onClick={() => setDeleteOpen(true)}>
Delete
</Button>
</div>
</div>
</SectionCard>
{/* Tab skeleton */}
<Tabs defaultValue="Overview">
<TabsList>
<TabsTrigger value="Overview">Overview</TabsTrigger>
{contentTabs.map((tab) => (
<TabsTrigger key={tab.label} value={tab.label}>
{tab.label}
</TabsTrigger>
))}
<TabsTrigger value="Widgets">Widgets</TabsTrigger>
<TabsTrigger value="Config">Config</TabsTrigger>
</TabsList>
<ServiceSecretsCard instance={instance} />
{allTabs.map((tab) => {
const TabComponent = tab.Component;
return (
<TabsContent key={tab.label} value={tab.label}>
<TabComponent instance={instance} />
</TabsContent>
);
})}
{binding.widgets.length > 0 ? (
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
<div className="flex flex-col gap-2">
{binding.widgets.map((w) => (
<div
key={w.kind}
className="flex items-center justify-between rounded border p-2"
>
<div>
<div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
{w.description}
</div>
</div>
<Badge variant="outline">{w.kind}</Badge>
</div>
))}
<p className="text-xs text-muted-foreground">
Add these to the dashboard from the dashboard's edit dialog.
</p>
</div>
</SectionCard>
) : null}
<TabsContent value="Widgets">
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
{widgetsContent}
</SectionCard>
</TabsContent>
<TabsContent value="Config">{configBody}</TabsContent>
</Tabs>
<ConfirmDialog
open={deleteOpen}
@@ -172,39 +322,119 @@ export function ServicePage() {
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
const saveService = useSaveServiceInstance();
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
function ConfigBody({
instance,
typeInfo,
draftConfig,
onConfigChange,
draftSecrets,
onSecretsChange,
name,
enabled,
onNameChange,
onEnabledChange,
onSave,
savePending,
onDelete,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
draftConfig: Record<string, unknown>;
onConfigChange: (config: Record<string, unknown>) => void;
draftSecrets: Record<string, string>;
onSecretsChange: (secrets: Record<string, string>) => void;
name: string;
enabled: boolean;
onNameChange: (name: string) => void;
onEnabledChange: (enabled: boolean) => void;
onSave: () => void;
savePending: boolean;
onDelete: () => void;
}) {
const properties =
(
(typeInfo?.config_schema ?? {}) as {
properties?: Record<
string,
{ type?: string; description?: string; default?: unknown }
>;
}
).properties ?? {};
const configEntries: Array<
[string, { type?: string; description?: string }]
> =
Object.keys(properties).length > 0
? Object.entries(properties).map(([key, schema]) => [
key,
{ type: schema?.type, description: schema?.description },
])
: Object.entries(instance.config).map(([key, value]) => [
key,
{ type: typeof value === "number" ? "integer" : "string" },
]);
return (
<SectionCard
title="Connection"
description="Non-secret config is read-only here for now; edit secret values below."
>
<SectionCard title="Config">
<div className="flex flex-col gap-3">
{Object.entries(instance.config).length === 0 ? (
<Field label="Name" htmlFor="service-name">
<Input
id="service-name"
value={name}
onChange={(e) => onNameChange(e.target.value)}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="service-enabled"
checked={enabled}
onCheckedChange={onEnabledChange}
/>
<Label htmlFor="service-enabled">Enabled</Label>
</div>
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<dl className="grid grid-cols-1 gap-2 text-sm sm:grid-cols-2">
{Object.entries(instance.config).map(([key, value]) => (
<div key={key} className="flex flex-col">
<dt className="text-xs text-muted-foreground">{key}</dt>
<dd className="truncate font-mono text-xs">{String(value)}</dd>
</div>
))}
</dl>
<div className="flex flex-col gap-3">
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
onConfigChange({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
)}
{Object.keys(instance.secrets_set).length === 0 ? (
<p className="text-sm text-muted-foreground">No secret fields.</p>
) : (
{Object.keys(instance.secrets_set).length === 0 ? null : (
<div className="flex flex-col gap-3">
{Object.entries(instance.secrets_set).map(([key, isSet]) => (
<div key={key} className="flex flex-col gap-1.5">
@@ -219,7 +449,7 @@ function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
onSecretsChange({
...draftSecrets,
[key]: e.target.value,
})
@@ -229,26 +459,17 @@ function ServiceSecretsCard({ instance }: { instance: ServiceInstance }) {
{isSet ? <Badge variant="secondary">set</Badge> : null}
</div>
))}
<Button
onClick={() => {
const onlyChanged = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
saveService.mutate({
id: instance.id,
service_type: instance.service_type,
name: instance.name,
config: instance.config,
secrets: onlyChanged,
enabled: instance.enabled,
});
setDraftSecrets({});
}}
>
Update secrets
</Button>
</div>
)}
<div className="flex justify-between">
<Button onClick={onSave} disabled={savePending}>
Save
</Button>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</div>
</SectionCard>
);
+46
View File
@@ -0,0 +1,46 @@
/**
* Handles `/services/:type` (no instance id). Resolves the first enabled
* instance and redirects. Shows an empty state if none are configured.
*/
import { useMemo } from "react";
import { Link, useParams, Navigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { useServiceInstances } from "../hooks/useServices";
export function ServiceTypePage() {
const { serviceType = "" } = useParams<{ serviceType: string }>();
const { data: instances = [], isLoading } = useServiceInstances(
serviceType || undefined,
);
const firstEnabled = useMemo(
() => instances.find((s) => s.enabled) ?? instances[0],
[instances],
);
if (isLoading) {
return (
<div className="flex h-32 items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (firstEnabled) {
return (
<Navigate to={`/services/${serviceType}/${firstEnabled.id}`} replace />
);
}
return (
<Alert>
<AlertDescription className="flex flex-col gap-3">
<span>No {serviceType} service configured.</span>
<Button asChild className="w-fit">
<Link to="/services">Add a service</Link>
</Button>
</AlertDescription>
</Alert>
);
}
+256 -1
View File
@@ -12,13 +12,31 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ExternalLink, Plus, Trash2 } from "lucide-react";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
ChevronDown,
ChevronUp,
ExternalLink,
Plus,
Trash2,
} from "lucide-react";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
} from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices";
import {
useDashboards,
useDeleteDashboard,
useSaveDashboard,
} from "../hooks/useDashboards";
import type {
SecretFieldInfo,
ServiceInstance,
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import { getServiceBinding } from "../integrations/registry";
import { serviceLinkTarget } from "../components/PinnedServiceLink";
import type { NamedDashboardInput } from "../api/dashboards";
interface CreateDraft {
serviceType: string;
@@ -257,6 +277,239 @@ function CreateServiceDialog({
);
}
// --- Named dashboards management (Slice 10.3) ---
function DashboardManagementCard() {
const { data: dashboards = [] } = useDashboards();
const saveDashboard = useSaveDashboard();
const deleteDashboard = useDeleteDashboard();
const { data: services = [] } = useServiceInstances();
const [createOpen, setCreateOpen] = useState(false);
const [newLabel, setNewLabel] = useState("");
const [deleteId, setDeleteId] = useState<string | null>(null);
const [linkDashId, setLinkDashId] = useState<string | null>(null);
const [linkLabel, setLinkLabel] = useState("");
const [linkTarget, setLinkTarget] = useState("");
const enabledServices = useMemo(
() => services.filter((s) => s.enabled),
[services],
);
function createDashboard() {
if (!newLabel.trim()) return;
const input: NamedDashboardInput = {
label: newLabel.trim(),
sort_order: dashboards.length,
payload: { items: [] },
};
saveDashboard.mutate(input);
setNewLabel("");
setCreateOpen(false);
}
function reorder(dashId: string, direction: -1 | 1) {
const sorted = [...dashboards].sort((a, b) => a.sort_order - b.sort_order);
const idx = sorted.findIndex((d) => d.id === dashId);
const swapIdx = idx + direction;
if (swapIdx < 0 || swapIdx >= sorted.length) return;
const a = sorted[idx];
const b = sorted[swapIdx];
saveDashboard.mutate({
...a,
sort_order: b.sort_order,
payload: a.payload,
});
saveDashboard.mutate({
...b,
sort_order: a.sort_order,
payload: b.payload,
});
}
function addPinnedLink() {
if (!linkDashId || !linkLabel.trim() || !linkTarget.trim()) return;
const dash = dashboards.find((d) => d.id === linkDashId);
if (!dash) return;
const items = Array.isArray(dash.payload.items)
? (dash.payload.items as unknown[])
: [];
items.push({ type: "link", label: linkLabel.trim(), target: linkTarget });
saveDashboard.mutate({
id: dash.id,
label: dash.label,
sort_order: dash.sort_order,
payload: { items },
});
setLinkLabel("");
setLinkTarget("");
}
return (
<SectionCard
title="Dashboards"
description="Named dashboards appear in the top nav. Compose them from pinned service links."
action={
<Button variant="outline" onClick={() => setCreateOpen(true)}>
<Plus className="mr-1 h-3 w-3" />
New dashboard
</Button>
}
>
{dashboards.length === 0 ? (
<p className="text-sm text-muted-foreground">
No named dashboards yet. Create one to add pinned service links.
</p>
) : (
<div className="flex flex-col gap-3">
{[...dashboards]
.sort((a, b) => a.sort_order - b.sort_order)
.map((d, idx, arr) => (
<div key={d.id} className="rounded border p-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-medium">{d.label}</span>
<Badge variant="outline">/{d.slug}</Badge>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={idx === 0}
onClick={() => reorder(d.id, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
disabled={idx === arr.length - 1}
onClick={() => reorder(d.id, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-destructive"
onClick={() => setDeleteId(d.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="mt-2 flex flex-wrap items-center gap-2">
{Array.isArray(d.payload.items) &&
(d.payload.items as unknown[]).length > 0 ? (
<span className="text-xs text-muted-foreground">
{(d.payload.items as unknown[]).length} pinned link(s)
</span>
) : (
<span className="text-xs text-muted-foreground">
No links yet
</span>
)}
</div>
<div className="mt-2 flex flex-wrap items-end gap-2">
<Field label="Link label" htmlFor={`link-label-${d.id}`}>
<Input
id={`link-label-${d.id}`}
className="w-40"
placeholder="My Jellyfin"
value={linkDashId === d.id ? linkLabel : ""}
onChange={(e) => {
setLinkDashId(d.id);
setLinkLabel(e.target.value);
}}
/>
</Field>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`link-target-${d.id}`}>Service</Label>
<Select
value={linkDashId === d.id ? linkTarget : ""}
onValueChange={(v) => {
setLinkDashId(d.id);
setLinkTarget(v);
}}
>
<SelectTrigger
id={`link-target-${d.id}`}
className="w-56"
>
<SelectValue placeholder="Pick a service" />
</SelectTrigger>
<SelectContent>
{enabledServices.map((s) => (
<SelectItem
key={s.id}
value={serviceLinkTarget(s.service_type, s.id)}
>
{s.name} ({s.service_type})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
size="sm"
disabled={
linkDashId !== d.id ||
!linkLabel.trim() ||
!linkTarget.trim()
}
onClick={addPinnedLink}
>
Add link
</Button>
</div>
</div>
))}
</div>
)}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>New dashboard</DialogTitle>
</DialogHeader>
<Field label="Label" htmlFor="dash-label">
<Input
id="dash-label"
placeholder="Storage overview"
value={newLabel}
onChange={(e) => setNewLabel(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") createDashboard();
}}
/>
</Field>
<DialogFooter
onCancel={() => setCreateOpen(false)}
onConfirm={createDashboard}
confirmLabel="Create"
confirmDisabled={!newLabel.trim() || saveDashboard.isPending}
/>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(deleteId)}
title="Delete dashboard?"
message="This removes the named dashboard and its pinned links."
confirmLabel="Delete"
onCancel={() => setDeleteId(null)}
onConfirm={() => {
if (deleteId) deleteDashboard.mutate(deleteId);
setDeleteId(null);
}}
/>
</SectionCard>
);
}
export function ServicesPage() {
const navigate = useNavigate();
const { data: services = [] } = useServiceInstances();
@@ -352,6 +605,8 @@ export function ServicesPage() {
)}
</SectionCard>
<DashboardManagementCard />
<CreateServiceDialog
open={createOpen}
onClose={() => setCreateOpen(false)}
+144 -45
View File
@@ -17,6 +17,8 @@ import {
useSaveSSHKey,
useTestMonitoringMachineSSH,
} from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
@@ -126,6 +128,30 @@ function emptyMachine(
};
}
/**
* Dirty check for the machine editor SheetForm guard (spec R4.5).
* Pragmatic field-by-field comparison of the user-editable fields. In create
* mode (editingMachine is null) the form is always dirty.
*/
function isMachineDraftDirty(
draft: MonitoringMachineInput,
editingMachine: MonitoringMachine | null,
): boolean {
if (!editingMachine) return true;
return (
draft.name !== editingMachine.name ||
draft.host !== editingMachine.host ||
draft.mode !== editingMachine.mode ||
draft.port !== editingMachine.port ||
draft.username !== editingMachine.username ||
draft.ssh_key_id !== editingMachine.ssh_key_id ||
draft.enabled !== editingMachine.enabled ||
draft.notes !== editingMachine.notes ||
JSON.stringify([...draft.services].sort()) !==
JSON.stringify([...editingMachine.services].sort())
);
}
function MachineEditor({
title,
hint,
@@ -230,6 +256,7 @@ function MachineEditor({
<div className="flex items-center gap-2">
<Switch
id="machine-enabled"
className="mobile-touch-target"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked }))
@@ -439,6 +466,7 @@ function MachineEditor({
</Alert>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
className="mobile-touch-target"
variant="outline"
onClick={onValidateSSH}
disabled={
@@ -532,7 +560,7 @@ function SSHKeyManager({
<Button
variant="outline"
size="sm"
className="w-full"
className="mobile-touch-target w-full"
onClick={() => {
clear();
}}
@@ -651,6 +679,7 @@ function SSHKeyManager({
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
className="mobile-touch-target"
disabled={saveKey.isPending}
onClick={async () => {
await saveKey.mutateAsync(draft);
@@ -660,6 +689,7 @@ function SSHKeyManager({
{editing ? "Update key" : "Save key"}
</Button>
<Button
className="mobile-touch-target"
variant="outline"
disabled={generateKey.isPending}
onClick={async () => {
@@ -683,11 +713,16 @@ function SSHKeyManager({
>
{generateKey.isPending ? "Generating..." : "Generate key"}
</Button>
<Button variant="outline" onClick={clear}>
<Button
variant="outline"
onClick={clear}
className="mobile-touch-target"
>
Clear
</Button>
{selectedKey && (
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => deleteKey.mutate(selectedKey.id)}
>
@@ -753,7 +788,11 @@ function ResetLocalDatabaseCard() {
Reset the local SQLite settings/media index databases after
acknowledging the data loss.
</p>
<Button variant="destructive" onClick={() => setOpen(true)}>
<Button
variant="destructive"
onClick={() => setOpen(true)}
className="mobile-touch-target"
>
Reset local database
</Button>
{resetDatabase.error && (
@@ -780,6 +819,7 @@ function ResetLocalDatabaseCard() {
<div className="flex flex-col gap-3">
<label className="flex items-center gap-2 text-sm">
<Checkbox
className="mobile-touch-target"
checked={ackSettings}
onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
/>
@@ -787,6 +827,7 @@ function ResetLocalDatabaseCard() {
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
className="mobile-touch-target"
checked={ackIndex}
onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
/>
@@ -794,6 +835,7 @@ function ResetLocalDatabaseCard() {
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox
className="mobile-touch-target"
checked={ackIrreversible}
onCheckedChange={(checked) =>
setAckIrreversible(Boolean(checked))
@@ -850,6 +892,7 @@ export function Settings() {
const [editingMachine, setEditingMachine] =
useState<MonitoringMachine | null>(null);
const [selectedMachineId, setSelectedMachineId] = useState("");
const isMobile = useIsMobile();
const orderedMachines = useMemo(() => machines ?? [], [machines]);
const selectedMachine = useMemo(
() =>
@@ -970,7 +1013,7 @@ export function Settings() {
<Button
variant="outline"
size="sm"
className="w-full"
className="mobile-touch-target w-full"
onClick={() => {
clearSSHValidation();
setMachineDraft(emptyMachine("local"));
@@ -1086,6 +1129,7 @@ export function Settings() {
</div>
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
className="mobile-touch-target"
variant="outline"
onClick={() =>
openEditMachine(
@@ -1113,6 +1157,7 @@ export function Settings() {
Edit
</Button>
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteMachineId(selectedMachine.id)}
>
@@ -1135,21 +1180,25 @@ export function Settings() {
)}
{tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard>
<Dialog
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{machineDraft.id ? "Edit machine" : "Create machine"}
</DialogTitle>
<DialogDescription>
{machineDraft.mode === "local" ? "Local API host" : "SSH target"}
</DialogDescription>
</DialogHeader>
{isMobile ? (
<SheetForm
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
title={machineDraft.id ? "Edit machine" : "Create machine"}
onSave={() => {
void saveMachineDraft(machineDraft);
}}
onCancel={closeMachineDialog}
isPending={saveMachine.isPending}
saveDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
@@ -1170,32 +1219,82 @@ export function Settings() {
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
<DialogFooter
onCancel={closeMachineDialog}
cancelLabel="Cancel"
onConfirm={() => {
void saveMachineDraft(machineDraft);
}}
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
confirmDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
secondaryAction={
machineDraft.id ? (
<Button
variant="destructive"
onClick={() => {
setDeleteMachineId(machineDraft.id as string);
}}
>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
</Dialog>
{machineDraft.id ? (
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => setDeleteMachineId(machineDraft.id as string)}
>
Delete machine
</Button>
) : null}
</SheetForm>
) : (
<Dialog
open={machineDialogOpen}
onOpenChange={(open) => {
if (!open) closeMachineDialog();
}}
>
<DialogContent className="sm:max-w-4xl">
<DialogHeader>
<DialogTitle>
{machineDraft.id ? "Edit machine" : "Create machine"}
</DialogTitle>
<DialogDescription>
{machineDraft.mode === "local"
? "Local API host"
: "SSH target"}
</DialogDescription>
</DialogHeader>
<MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={
machineDraft.id
? machineDraft.name || "Edit machine"
: "New machine"
}
hint={
machineDraft.mode === "local" ? "Local API host" : "SSH target"
}
machine={machineDraft}
sshKeys={sshKeys}
editingMachine={editingMachine}
onChange={updateMachineDraft}
onValidateSSH={validateMachineSSH}
isValidatingSSH={testMachineSSH.isPending}
sshValidationMessage={sshValidationMessage}
sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus}
/>
<DialogFooter
onCancel={closeMachineDialog}
cancelLabel="Cancel"
onConfirm={() => {
void saveMachineDraft(machineDraft);
}}
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"}
confirmDisabled={
!machineDraft.name ||
(machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
secondaryAction={
machineDraft.id ? (
<Button
className="mobile-touch-target"
variant="destructive"
onClick={() => {
setDeleteMachineId(machineDraft.id as string);
}}
>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
</Dialog>
)}
<ConfirmDialog
open={Boolean(deleteMachineId)}
title="Delete machine?"
-1
View File
@@ -1 +0,0 @@
export { UsersPage } from "./UsersPage.impl";
-983
View File
@@ -1,983 +0,0 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ChangeEvent } from "react";
// Slice 6b: compose dialog (shadcn Dialog family) + lucide icons. The file is
// now fully @mui-free (6a migrated the directory surface, drawer, and the
// compose content's shared leaf components).
import {
X,
Paperclip,
Bold,
Italic,
Link,
List,
Mail,
Send,
Trash2,
} from "lucide-react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Separator } from "@/components/ui/separator";
import { Label } from "@/components/ui/label";
// Slice 6a directory surface + drawer primitives.
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Button as UiButton } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Alert as UIAlert, AlertDescription } from "@/components/ui/alert";
import { Progress } from "@/components/ui/progress";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { Sheet, SheetContent } from "@/components/ui/sheet";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { cn } from "@/lib/utils";
import { MetricCard } from "../components/MetricCard";
import { SessionActivityPanel } from "../components/SessionActivityPanel";
import { useUsers } from "../hooks/useUsers";
import { useActivity } from "../hooks/useDashboard";
import { useSendUserMessage } from "../hooks/useSendUserMessage";
import { useUserMessageQueueStatus } from "../hooks/useUserMessageQueueStatus";
import type { UserDirectoryItem } from "../types";
import { buildUserDrawerModel } from "../users";
import {
mergeUsersWithActivity,
resolveUserSelection,
type UserStateItem,
} from "../userState";
// Replaces MUI `useMediaQuery` (a 6b-owned component) with a dependency-free
// matchMedia hook for the compose dialog's mobile fullScreen behavior.
function useIsMobile(query = "(max-width: 900px)") {
const [mobile, setMobile] = useState(() =>
typeof window !== "undefined" && typeof window.matchMedia === "function"
? window.matchMedia(query).matches
: false,
);
useEffect(() => {
if (
typeof window === "undefined" ||
typeof window.matchMedia !== "function"
) {
return;
}
const mql = window.matchMedia(query);
const onChange = (event: MediaQueryListEvent) => setMobile(event.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [query]);
return mobile;
}
function userLabel(user: UserDirectoryItem) {
return user.display_name || user.username || user.jellyfin_id;
}
// Activity → Badge status variant (design §2.3: healthy/active = success chart-2,
// paused = warning chart-3, neutral = secondary).
function activityBadgeVariant(
label: string,
): "success" | "warning" | "secondary" {
if (label === "Playing") return "success";
if (label === "Paused") return "warning";
return "secondary";
}
const DEFAULT_HTML_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
export function UsersPage() {
const { data, isError, error } = useUsers();
const { data: activity } = useActivity();
const queueStatusQuery = useUserMessageQueueStatus();
const sendUserMessage = useSendUserMessage();
const isMobile = useIsMobile();
const [search, setSearch] = useState("");
const [searchParams, setSearchParams] = useSearchParams();
const [selectedUserIds, setSelectedUserIds] = useState<string[]>([]);
const [composeOpen, setComposeOpen] = useState(false);
const [subject, setSubject] = useState("");
const [htmlBody, setHtmlBody] = useState(DEFAULT_HTML_BODY);
const [attachments, setAttachments] = useState<File[]>([]);
const htmlBodyRef = useRef<HTMLTextAreaElement | null>(null);
const baseRows = data?.items ?? [];
const rows = useMemo(
() => mergeUsersWithActivity(baseRows, activity ?? []),
[baseRows, activity],
);
const filteredRows = useMemo(() => {
const term = search.trim().toLowerCase();
if (!term) {
return rows;
}
return rows.filter((row) => {
return [
row.username,
row.display_name,
row.email,
row.email_source,
row.avatar_source,
row.name_source,
row.access_source,
row.user_type_label,
row.role,
row.permissions_label,
row.jellyseerr_username,
row.activity_label,
row.activity_summary,
row.activity.primary_session?.title || "",
String(row.jellyseerr_user_id ?? ""),
].some((value) => value.toLowerCase().includes(term));
});
}, [rows, search]);
const metrics = useMemo(() => {
const total = baseRows.length;
const contactable = rows.filter((row) => row.contactable).length;
const enriched = rows.filter(
(row) => row.jellyseerr_user_id !== null,
).length;
const admins = rows.filter((row) => row.role === "admin").length;
return { total, contactable, enriched, admins };
}, [baseRows]);
const queueStatus = queueStatusQuery.data;
const queueBanner = useMemo(() => {
if (!queueStatus) {
return null;
}
const activeCount = queueStatus.active_request_id ? 1 : 0;
const totalCount = queueStatus.pending_count + activeCount;
const countLabel =
totalCount > 0
? `${totalCount} item${totalCount === 1 ? "" : "s"} in queue (${queueStatus.pending_count} waiting${activeCount ? ", 1 processing" : ""})`
: "0 items in queue";
if (!queueStatus.worker_running) {
return {
severity: "warning" as const,
message:
queueStatus.last_error ||
"Email queue worker is not running. New messages cannot be delivered until it restarts.",
countLabel,
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
};
}
if (queueStatus.state === "error") {
return {
severity: "error" as const,
message: queueStatus.last_error || "The last email delivery failed.",
countLabel,
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
};
}
if (queueStatus.state === "busy") {
const active = queueStatus.active_request_id
? `processing ${queueStatus.active_request_id.slice(0, 8)}`
: "processing a message";
const waiting = queueStatus.pending_count
? `${queueStatus.pending_count} waiting`
: "no backlog";
return {
severity: "info" as const,
message: `Email queue is busy: ${active}, ${waiting}.`,
countLabel,
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
};
}
return {
severity: "success" as const,
message: "Email queue is idle and empty.",
countLabel,
subtext: `Sent ${queueStatus.sent_count}, failed ${queueStatus.failed_count}.`,
};
}, [queueStatus]);
const selectedIdSet = useMemo(
() => new Set(selectedUserIds),
[selectedUserIds],
);
const selectedRows = useMemo(
() => rows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
[rows, selectedIdSet],
);
const selectedDeliverableRows = useMemo(
() => selectedRows.filter((row) => row.contactable && row.email),
[selectedRows],
);
const skippedRows = useMemo(
() => selectedRows.filter((row) => !row.contactable || !row.email),
[selectedRows],
);
const visibleSelectedRows = useMemo(
() => filteredRows.filter((row) => selectedIdSet.has(row.jellyfin_id)),
[filteredRows, selectedIdSet],
);
const allVisibleSelected =
filteredRows.length > 0 &&
visibleSelectedRows.length === filteredRows.length;
const toggleUserSelected = (userId: string) => {
setSelectedUserIds((current) =>
current.includes(userId)
? current.filter((id) => id !== userId)
: [...current, userId],
);
};
const toggleVisibleSelection = (checked: boolean) => {
setSelectedUserIds((current) => {
const next = new Set(current);
filteredRows.forEach((row) => {
if (checked) {
next.add(row.jellyfin_id);
} else {
next.delete(row.jellyfin_id);
}
});
return Array.from(next);
});
};
const selectedUserParam = searchParams.get("user") || "";
const selectedUser = useMemo(
() =>
selectedUserParam
? (resolveUserSelection(
rows,
selectedUserParam,
) as UserStateItem | null)
: null,
[rows, selectedUserParam],
);
const drawerModel = selectedUser ? buildUserDrawerModel(selectedUser) : null;
const openCompose = () => {
if (!selectedRows.length) {
return;
}
sendUserMessage.reset();
if (!subject.trim()) {
setSubject(
`Manage update for ${selectedDeliverableRows.length} user${selectedDeliverableRows.length === 1 ? "" : "s"}`,
);
}
if (!htmlBody.trim()) {
setHtmlBody(DEFAULT_HTML_BODY);
}
setComposeOpen(true);
};
const closeCompose = () => {
setComposeOpen(false);
sendUserMessage.reset();
};
const insertMarkup = (before: string, after = before) => {
const textarea = htmlBodyRef.current;
if (!textarea) {
return;
}
const start = textarea.selectionStart ?? htmlBody.length;
const end = textarea.selectionEnd ?? htmlBody.length;
const selected = htmlBody.slice(start, end) || "text";
const next =
htmlBody.slice(0, start) +
before +
selected +
after +
htmlBody.slice(end);
setHtmlBody(next);
requestAnimationFrame(() => {
textarea.focus();
const cursorStart = start + before.length;
const cursorEnd = cursorStart + selected.length;
textarea.setSelectionRange(cursorStart, cursorEnd);
});
};
const addLink = () => {
const url = window.prompt("Link URL", "https://");
if (!url) {
return;
}
insertMarkup(`<a href="${url}">`, "</a>");
};
const handleAttachments = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
if (files.length) {
setAttachments((current) => [...current, ...files]);
}
event.target.value = "";
};
const removeAttachment = (index: number) => {
setAttachments((current) => current.filter((_, idx) => idx !== index));
};
const handleSend = async () => {
const allSelectedRows = selectedRows;
if (!allSelectedRows.length) {
return;
}
const formData = new FormData();
formData.append(
"recipient_ids",
JSON.stringify(allSelectedRows.map((row) => row.jellyfin_id)),
);
formData.append("subject", subject);
formData.append("html_body", htmlBody);
attachments.forEach((file) => {
formData.append("attachments", file, file.name);
});
try {
await sendUserMessage.mutateAsync(formData);
setComposeOpen(false);
setAttachments([]);
setSubject("");
setHtmlBody(DEFAULT_HTML_BODY);
} catch {
// Mutation state is shown inline.
}
};
// Sticky table-header base (opaque so rows don't bleed through on scroll).
const thBase = "font-semibold sticky top-0 z-10 bg-card";
return (
<div className="flex flex-col gap-6">
<div>
<h1 className="text-2xl font-semibold">Users</h1>
<p className="text-sm text-muted-foreground">
Read-only Jellyfin users with optional Jellyseerr enrichment.
</p>
</div>
{isError ? (
<UIAlert variant="destructive">
<AlertDescription>
Unable to load users: {(error as Error)?.message || "Unknown error"}
</AlertDescription>
</UIAlert>
) : null}
{data && !data.jellyseerr_configured ? (
<UIAlert>
<AlertDescription>
Jellyseerr is not configured in the backend yet. Check
JELLYSEERR_URL and JELLYSEERR_API_KEY, then restart the API.
</AlertDescription>
</UIAlert>
) : null}
{data?.jellyseerr_error ? (
<UIAlert>
<AlertDescription>
Jellyseerr enrichment is unavailable: {data.jellyseerr_error}
</AlertDescription>
</UIAlert>
) : null}
{data?.jellyseerr_configured &&
!data.jellyseerr_error &&
data.enriched_count === 0 ? (
<UIAlert>
<AlertDescription>
Jellyseerr is connected, but no Jellyfin users were matched yet. The
backend found {data.jellyseerr_jellyfin_user_count} Jellyfin-linked
entries and {data.jellyseerr_user_count} Jellyseerr users.
</AlertDescription>
</UIAlert>
) : null}
{queueStatusQuery.isError ? (
<UIAlert>
<AlertDescription>
Unable to load email queue status:{" "}
{String(
(queueStatusQuery.error as Error)?.message || "Unknown error",
)}
</AlertDescription>
</UIAlert>
) : queueBanner ? (
<UIAlert
variant={queueBanner.severity === "error" ? "destructive" : undefined}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">{queueBanner.message}</span>
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
</div>
<AlertDescription>{queueBanner.subtext}</AlertDescription>
</UIAlert>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-4">
<MetricCard label="Total users" value={String(metrics.total)} />
<MetricCard label="Contactable" value={String(metrics.contactable)} />
<MetricCard label="Enriched" value={String(metrics.enriched)} />
<MetricCard label="Admins" value={String(metrics.admins)} />
</div>
<div className="rounded-lg border bg-card p-4">
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-base font-semibold">User list</h2>
<p className="text-sm text-muted-foreground">
{filteredRows.length} visible of {rows.length} total
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<Badge variant="outline">{selectedRows.length} selected</Badge>
<Badge
variant={selectedDeliverableRows.length ? "success" : "outline"}
>
{selectedDeliverableRows.length} deliverable
</Badge>
<UiButton
variant="default"
disabled={!selectedDeliverableRows.length}
onClick={openCompose}
>
<Mail />
Message selected
</UiButton>
<UiButton
variant="ghost"
disabled={!selectedRows.length}
onClick={() => setSelectedUserIds([])}
>
Clear selection
</UiButton>
<Input
aria-label="Search"
placeholder="Name, email, role, permission..."
value={search}
onChange={(event) => setSearch(event.target.value)}
className="w-full sm:w-80"
/>
</div>
</div>
<div className="max-h-[660px] overflow-auto rounded-lg border">
<Table aria-label="Users table">
<TableHeader>
<TableRow>
<TableHead className={cn(thBase, "w-14 p-2")}>
<Checkbox
checked={allVisibleSelected}
aria-label="Select all visible users"
onCheckedChange={(checked) =>
toggleVisibleSelection(checked === true)
}
/>
</TableHead>
<TableHead className={thBase}>User</TableHead>
<TableHead className={thBase}>Email</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Activity
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[140px] text-center md:table-cell",
)}
>
Type
</TableHead>
<TableHead className={cn(thBase, "w-[132px] text-center")}>
Jellyseerr
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Role
</TableHead>
<TableHead className={thBase}>Permissions</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-24 text-center md:table-cell",
)}
>
Reqs
</TableHead>
<TableHead
className={cn(
thBase,
"hidden w-[120px] text-center md:table-cell",
)}
>
Contact
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRows.map((row) => {
const linked =
row.jellyseerr_user_id !== null &&
row.jellyseerr_user_id !== undefined;
const checked = selectedIdSet.has(row.jellyfin_id);
return (
<TableRow
key={row.jellyfin_id}
data-state={
checked || selectedUser?.jellyfin_id === row.jellyfin_id
? "selected"
: undefined
}
className="cursor-pointer"
onClick={() => setSearchParams({ user: row.jellyfin_id })}
>
<TableCell className="w-14 p-2">
<Checkbox
checked={checked}
aria-label={`Select ${userLabel(row)}`}
onClick={(event) => event.stopPropagation()}
onCheckedChange={() =>
toggleUserSelected(row.jellyfin_id)
}
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-3 min-w-0">
<Avatar className="size-9">
<AvatarImage
src={row.avatar || undefined}
alt={userLabel(row)}
/>
<AvatarFallback>
{userLabel(row).charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<div className="truncate font-semibold leading-tight">
{userLabel(row)}
</div>
<div className="truncate text-xs text-muted-foreground">
{row.username && row.username !== row.display_name
? row.username
: row.jellyfin_id}
</div>
</div>
</div>
</TableCell>
<TableCell>
<div className="truncate font-medium">
{row.email || "—"}
</div>
</TableCell>
<TableCell className="text-center">
<Badge
variant={activityBadgeVariant(row.activity_label)}
>
{row.activity_label}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.user_type_label}</Badge>
</TableCell>
<TableCell className="text-center">
<Badge variant={linked ? "success" : "secondary"}>
{linked
? `Linked #${row.jellyseerr_user_id}`
: "Base only"}
</Badge>
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge variant="outline">{row.role}</Badge>
</TableCell>
<TableCell className="whitespace-normal">
{row.permissions_label}
</TableCell>
<TableCell className="hidden text-center font-semibold md:table-cell">
{row.request_count ?? "—"}
</TableCell>
<TableCell className="hidden text-center md:table-cell">
<Badge
variant={row.contactable ? "success" : "secondary"}
>
{row.contactable ? "Yes" : "No"}
</Badge>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</div>
</div>
<Sheet
open={Boolean(drawerModel)}
onOpenChange={(open) => {
if (!open) {
setSearchParams({});
}
}}
>
<SheetContent
side="right"
showCloseButton={false}
className="w-full gap-6 overflow-y-auto p-6 sm:max-w-[440px]"
>
{selectedUser && drawerModel ? (
<div className="flex flex-col gap-6">
<div className="flex items-start gap-4">
<Avatar className="size-14">
<AvatarImage
src={selectedUser.avatar || undefined}
alt={drawerModel.title}
/>
<AvatarFallback>
{drawerModel.title.charAt(0).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0 flex-1">
<h2 className="truncate text-lg font-bold">
{drawerModel.title}
</h2>
<p className="truncate text-sm text-muted-foreground">
{drawerModel.subtitle}
</p>
</div>
<Badge variant="secondary">
{drawerModel.contactState.label}
</Badge>
<UiButton
variant="ghost"
aria-label="Close user details"
onClick={() => setSearchParams({})}
>
<X />
Close
</UiButton>
</div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{selectedUser.user_type_label}</Badge>
<Badge variant="default">{selectedUser.role}</Badge>
<Badge variant="secondary">{drawerModel.syncStatus}</Badge>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Identity</h3>
<div className="flex flex-col gap-1">
{drawerModel.identity.map((field) => (
<div key={field.label} className="flex gap-4">
<span className="min-w-[120px] text-xs uppercase text-muted-foreground">
{field.label}
</span>
<span className="break-words text-sm">{field.value}</span>
</div>
))}
</div>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Activity</h3>
<SessionActivityPanel
sessions={selectedUser.activity.sessions}
selectedUserLabel={
selectedUser.display_name ||
selectedUser.username ||
selectedUser.jellyfin_id
}
emptyMessage="No live sessions matched to this user."
/>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Contact actions</h3>
<p className="mb-2 text-sm text-muted-foreground">
{drawerModel.contactState.description}
</p>
<div className="flex flex-wrap gap-2">
{drawerModel.contactActions.map((action) => (
<UiButton
key={action.label}
variant="outline"
disabled={!action.enabled}
>
{action.label}
</UiButton>
))}
</div>
<p className="mt-2 text-xs text-muted-foreground">
{drawerModel.contactActions
.map((action) => action.hint)
.join(" ")}
</p>
</div>
<div className="rounded-lg border bg-card p-4">
<h3 className="mb-2 text-sm font-semibold">Permissions</h3>
<div className="flex flex-wrap gap-1">
{drawerModel.permissions.map((permission) => (
<Badge key={permission} variant="secondary">
{permission}
</Badge>
))}
</div>
</div>
<Separator />
<p className="text-xs text-muted-foreground">
This panel is read-only for now. Communication actions will be
added later without redesigning the list.
</p>
</div>
) : null}
</SheetContent>
</Sheet>
<Dialog
open={composeOpen}
onOpenChange={(open) => {
if (!open) {
closeCompose();
}
}}
>
<DialogContent
className={cn(
"flex max-h-[90dvh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl",
isMobile &&
"inset-0 max-h-none max-w-none translate-x-0 translate-y-0 rounded-none",
)}
>
<DialogHeader className="gap-1 px-4 pt-4">
<DialogTitle className="pr-8">Message selected users</DialogTitle>
<DialogDescription className="sr-only">
Compose a message to the selected deliverable users.
</DialogDescription>
</DialogHeader>
{sendUserMessage.isPending ? (
<Progress value={100} className="animate-pulse" />
) : null}
<div className="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
{sendUserMessage.isError ? (
<UIAlert variant="destructive">
<AlertDescription>
Unable to send message:{" "}
{(sendUserMessage.error as Error)?.message || "Unknown error"}
</AlertDescription>
</UIAlert>
) : null}
{sendUserMessage.isSuccess ? (
<UIAlert>
<AlertDescription>
Queued for {sendUserMessage.data.recipient_count} recipients
{sendUserMessage.data.attachment_count
? ` with ${sendUserMessage.data.attachment_count} attachment${sendUserMessage.data.attachment_count === 1 ? "" : "s"}`
: ""}
{sendUserMessage.data.request_id
? ` (request ${sendUserMessage.data.request_id.slice(0, 8)})`
: ""}
.
</AlertDescription>
</UIAlert>
) : null}
{queueBanner ? (
<UIAlert
variant={
queueBanner.severity === "error" ? "destructive" : undefined
}
>
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold">
{queueBanner.message}
</span>
<Badge variant="secondary">{queueBanner.countLabel}</Badge>
</div>
</UIAlert>
) : null}
<UIAlert>
<AlertDescription>
{selectedRows.length} selected, {selectedDeliverableRows.length}{" "}
deliverable.
{skippedRows.length
? ` ${skippedRows.length} will be skipped because they do not have a deliverable email address.`
: ""}
</AlertDescription>
</UIAlert>
<div className="flex flex-wrap gap-1">
{selectedDeliverableRows.map((row) => (
<Badge key={row.jellyfin_id} variant="secondary">
{`${userLabel(row)} <${row.email}>`}
</Badge>
))}
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="compose-subject">Subject</Label>
<Input
id="compose-subject"
value={subject}
onChange={(event) => setSubject(event.target.value)}
/>
</div>
<div className="flex flex-wrap gap-1">
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
onClick={() => insertMarkup("<strong>", "</strong>")}
aria-label="Bold"
>
<Bold />
</UiButton>
</TooltipTrigger>
<TooltipContent>Bold</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
onClick={() => insertMarkup("<em>", "</em>")}
aria-label="Italic"
>
<Italic />
</UiButton>
</TooltipTrigger>
<TooltipContent>Italic</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
onClick={addLink}
aria-label="Link"
>
<Link />
</UiButton>
</TooltipTrigger>
<TooltipContent>Link</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<UiButton
variant="ghost"
size="icon"
onClick={() => insertMarkup("<ul><li>", "</li></ul>")}
aria-label="Bullet list"
>
<List />
</UiButton>
</TooltipTrigger>
<TooltipContent>Bullet list</TooltipContent>
</Tooltip>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor="compose-body">HTML message body</Label>
<Textarea
id="compose-body"
ref={htmlBodyRef}
value={htmlBody}
onChange={(event) => setHtmlBody(event.target.value)}
className="min-h-[260px] font-mono"
/>
<p className="text-xs text-muted-foreground">
Formatting is sent as HTML; a plain-text fallback is generated
automatically.
</p>
</div>
<div className="rounded-lg border bg-muted/40 p-4">
<p className="mb-2 text-sm font-semibold">Preview</p>
<div className="overflow-hidden rounded-md border bg-card">
<iframe
title="Email preview"
sandbox=""
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>body{font-family:Roboto,Arial,sans-serif;padding:16px;margin:0;background:#fff;color:#111;line-height:1.5}</style></head><body>${htmlBody || "<p>(Empty)</p>"}</body></html>`}
style={{ width: "100%", minHeight: 220, border: 0 }}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-1">
<UiButton asChild variant="outline">
<label className="cursor-pointer">
<Paperclip />
Add attachment
<input
hidden
type="file"
multiple
onChange={handleAttachments}
/>
</label>
</UiButton>
{attachments.map((file, index) => (
<Badge
key={`${file.name}-${index}`}
variant="secondary"
className="gap-1 pr-1"
>
{file.name}
<button
type="button"
aria-label={`Remove ${file.name}`}
onClick={() => removeAttachment(index)}
className="inline-flex items-center text-current [&>svg]:size-3"
>
<Trash2 />
</button>
</Badge>
))}
</div>
</div>
<DialogFooter className="m-0 border-t p-4">
<UiButton variant="ghost" onClick={closeCompose}>
Cancel
</UiButton>
<UiButton
variant="default"
disabled={
sendUserMessage.isPending ||
!selectedDeliverableRows.length ||
!subject.trim()
}
onClick={handleSend}
>
<Send />
Send message
</UiButton>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1,125 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Actions } from "../Actions";
import type { SavedTask, ServiceInstance } from "../../types";
const saveTaskMutate = vi.fn().mockResolvedValue({
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "",
enabled: true,
default_service_id: "",
notes: "",
});
const deleteTaskMutate = vi.fn();
const runTaskMutate = vi.fn().mockResolvedValue({});
let sshServices: ServiceInstance[] = [];
let tasks: SavedTask[] = [];
vi.mock("../../hooks/useSettings", () => ({
useTasks: () => ({ data: tasks }),
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
useTaskRuns: () => ({ data: { items: [] } }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: sshServices }),
}));
function sshService(overrides: Partial<ServiceInstance> = {}): ServiceInstance {
return {
id: "s1",
service_type: "ssh_tasks",
name: "Box",
config: { host: "box", username: "u" },
secrets_set: {},
enabled: true,
created_at: 0,
updated_at: 0,
...overrides,
} as ServiceInstance;
}
function task(overrides: Partial<SavedTask> = {}): SavedTask {
return {
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "systemctl restart foo",
enabled: true,
default_service_id: "",
notes: "",
created_at: 0,
updated_at: 0,
...overrides,
} as SavedTask;
}
beforeEach(() => {
saveTaskMutate.mockClear();
deleteTaskMutate.mockClear();
runTaskMutate.mockClear();
sshServices = [];
tasks = [];
});
describe("Actions", () => {
it("shows the empty state and creates a task via the editor dialog", async () => {
render(<Actions />);
expect(screen.getByText("No action selected")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
// Editor dialog opened (Name field is unique to the editor).
expect(screen.getByLabelText("Name")).toBeInTheDocument();
// Controlled input parity: name + default shell type flow through.
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
const saved = saveTaskMutate.mock.calls[0][0];
expect(saved.name).toBe("Restart svc");
expect(saved.task_type).toBe("shell");
expect(saved.default_service_id).toBe("");
});
it("disables the Run button until a run service is selected", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
// Selecting a saved task tab exposes the detail + Run control.
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
const runButton = screen.getByRole("button", { name: "Run action" });
expect(runButton).toBeDisabled();
});
it("runs a task on the selected SSH task service", async () => {
sshServices = [sshService()];
tasks = [task()];
render(<Actions />);
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
await userEvent.click(
screen.getByRole("combobox", { name: "Run on SSH task service" }),
);
await userEvent.click(screen.getByRole("option", { name: "Box" }));
await userEvent.click(screen.getByRole("button", { name: "Run action" }));
expect(runTaskMutate).toHaveBeenCalledTimes(1);
expect(runTaskMutate).toHaveBeenCalledWith({
taskId: "t1",
serviceId: "s1",
});
});
});
@@ -1,75 +0,0 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Applications } from "../Applications";
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
// so this slice-4 test stays focused on the migrated Applications shell and
// does not pull the still-MUI DataGrid into the jsdom render.
vi.mock("../Media", () => ({
Media: () => <div data-testid="media-child">Media</div>,
}));
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({
data: [
{
id: "m1",
name: "Main",
enabled: true,
services: ["jellyfin"],
},
],
}),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
}),
useLibraries: () => ({
data: [
{ library: "Movies", total: 10, movies: 10, series: 0 },
{ library: "Shows", total: 5, movies: 0, series: 5 },
],
}),
}));
describe("Applications", () => {
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
render(<Applications />);
// Library stats header.
expect(screen.getByText("Library stats")).toBeInTheDocument();
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
expect(screen.getByText("115")).toBeInTheDocument();
expect(screen.getByText("Episodes")).toBeInTheDocument();
// Library rows render their per-library totals (unique strings).
expect(
screen.getByText(/Total 10 · Movies 10 · Series 0/),
).toBeInTheDocument();
expect(
screen.getByText(/Total 5 · Movies 0 · Series 5/),
).toBeInTheDocument();
// Tabs present.
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
expect(screen.getByTestId("media-child")).toBeInTheDocument();
});
});
@@ -24,6 +24,9 @@ vi.mock("../../hooks/useSettings", () => ({
vi.mock("../../hooks/useWidgets", () => ({
useWidgetInstances: () => ({ data: [] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
const deleteShortcutMutate = vi.fn();
@@ -1,120 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileBrowser } from "../FileBrowser.impl";
import type { DirectoryListing, MonitoringMachine } from "../../types";
// usePersistentState (browserState) reads/writes localStorage; clear between tests
// so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
});
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["files", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
notes: "",
...overrides,
};
}
function listingFixture(
entries: {
name: string;
type: string;
size: number;
mtime: number;
}[],
): DirectoryListing {
return { path: "/", entries, count: entries.length };
}
let listing: DirectoryListing;
let machines: MonitoringMachine[];
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));
vi.mock("../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: listing,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
}));
beforeEach(() => {
machines = [machineFixture()];
listing = listingFixture([
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
]);
});
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
// The leading selection column header is empty (checkbox); the 5 data
// columns are Type, Name, Ext, Size, Modified in that order.
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
expect(headers.filter((h) => h === "Type").length).toBe(1);
expect(headers.filter((h) => h === "Modified").length).toBe(1);
});
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
render(<FileBrowser />);
// The selected-file path surfaces in the Browser status caption once chosen.
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
await userEvent.click(screen.getByText("video.mkv"));
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
// A recognized video file enters the ffprobe branch; with empty ffprobe
// data it shows the "No ffprobe data available." status (proving the
// selected file routed into the Media info preview flow).
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
});
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
render(<FileBrowser />);
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd and
// NO "Selected:" segment (directories are opened, not selected for preview).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
});
-263
View File
@@ -1,263 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Media } from "../Media";
import type {
MediaIndexStatus,
MediaItem,
MediaQueryResponse,
MonitoringMachine,
} from "../../types";
// Shared navigate mock so the row-click test can assert the call. The vi.mock
// factory is hoisted above this const, but it only closes over `navigate`
// lazily (the arrow runs at render time, well after init) — no TDZ access.
const navigate = vi.fn();
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["jellyfin", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
notes: "",
...overrides,
};
}
function statusFixture(
overrides: Partial<MediaIndexStatus> = {},
): MediaIndexStatus {
return {
exists: true,
item_count: 2,
updated_at: 1,
updated_at_label: "now",
build_duration_seconds: null,
build_running: false,
build_stage: "",
build_message: "",
build_progress: null,
build_items_processed: 0,
build_items_total: 0,
build_current_library: "",
build_library_index: 0,
build_libraries_total: 0,
build_library_progress: null,
build_library_items_processed: 0,
build_library_items_total: 0,
build_elapsed_seconds: null,
build_eta_seconds: null,
build_library_elapsed_seconds: null,
build_library_eta_seconds: null,
build_cancel_requested: false,
build_pid: null,
build_error: "",
...overrides,
};
}
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "1",
title: "Inception",
series: "",
season: "",
episode: null,
type: "Movie",
year: 2010,
runtime_min: 148,
size: "12.4 GB",
bitrate: "35.0 Mbps",
hdr: "HDR10",
video: "HEVC",
resolution: "4K",
date_added: "2024-01-01",
library: "Movies",
path: "/media/movies/Inception.mkv",
...overrides,
};
}
let status: MediaIndexStatus;
let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
useSearchParams: () => [
new URLSearchParams("jellyfin_service_id=jfs1"),
vi.fn(),
],
}));
vi.mock("../../hooks/useMedia", () => ({
useMediaStatus: () => ({ data: status }),
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }),
}));
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: [
{ id: "jfs1", service_type: "jellyfin", name: "Main", enabled: true },
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }),
}));
// usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
navigate.mockClear();
status = statusFixture();
queryResult = {
items: [
mediaItem({
id: "1",
title: "Inception",
path: "/media/movies/Inception.mkv",
}),
mediaItem({
id: "2",
title: "Matrix",
path: "/media/movies/Matrix.mkv",
}),
],
total: 2,
limit: 100,
offset: 0,
};
});
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => {
render(<Media />);
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
const toggleable = screen
.getAllByRole("menuitemcheckbox")
.map((item) => (item.textContent ?? "").trim());
expect([...toggleable].sort()).toEqual(
[
"title",
"series",
"season",
"episode",
"type",
"year",
"runtime_min",
"size",
"bitrate",
"hdr",
"video",
"resolution",
"date_added",
"library",
"path",
].sort(),
);
// The leading selection column is never toggleable (enableHiding=false).
expect(toggleable).toHaveLength(15);
expect(toggleable).not.toContain("__select__");
});
it("renders the 15 data column headers", () => {
render(<Media />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => (h.textContent ?? "").trim());
for (const expected of [
"Title",
"Series",
"Season",
"Episode",
"Type",
"Year",
"Runtime",
"Size",
"Bitrate",
"HDR",
"Video codec",
"Resolution",
"Date added",
"Library",
"Path",
]) {
expect(headers).toContain(expected);
}
});
it("navigates to the file browser at the item path on row click", async () => {
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("does NOT navigate when toggling a row selection checkbox", async () => {
render(<Media />);
const firstCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(firstCheckbox);
expect(firstCheckbox).toBeChecked();
expect(navigate).not.toHaveBeenCalled();
});
it("renders the server-driven pagination total + page controls", () => {
render(<Media />);
// DataTable manual-pagination footer surfaces the server total + pager.
// ("Page 1 of 1" also appears in the page caption, so match all and assert
// the pager footer text is present alongside the unique total.)
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
});
it("disables Build index while a build is running", () => {
status = statusFixture({ build_running: true });
render(<Media />);
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
// Stop + Force stop surface only while running.
expect(
screen.getByRole("button", { name: "Stop build" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Force stop" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,93 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { NamedDashboardPage } from "../NamedDashboardPage";
vi.mock("../../hooks/useDashboards", () => ({
useDashboardBySlug: vi.fn(() => ({ data: undefined, isLoading: true })),
}));
import { useDashboardBySlug } from "../../hooks/useDashboards";
function renderPage(slug: string) {
return render(
<MemoryRouter initialEntries={[`/d/${slug}`]}>
<Routes>
<Route path="/d/:slug" element={<NamedDashboardPage />} />
</Routes>
</MemoryRouter>,
);
}
describe("NamedDashboardPage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders loading state", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
} as never);
renderPage("storage");
// Skeleton renders during load.
expect(document.querySelector(".h-32")).toBeInTheDocument();
});
it("renders 404 when dashboard not found", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
} as never);
renderPage("nonexistent");
expect(screen.getByText(/Dashboard not found/i)).toBeInTheDocument();
});
it("renders pinned links for a known dashboard", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d1",
label: "Storage",
slug: "storage",
sort_order: 0,
payload: {
items: [
{
type: "link",
label: "My Jellyfin",
target: "/services/jellyfin/svc-1",
},
],
},
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("storage");
expect(screen.getByText("Storage")).toBeInTheDocument();
expect(screen.getByText("My Jellyfin")).toBeInTheDocument();
});
it("renders empty state when dashboard has no items", () => {
vi.mocked(useDashboardBySlug).mockReturnValue({
data: {
id: "d2",
label: "Empty",
slug: "empty",
sort_order: 0,
payload: {},
created_at: 1,
updated_at: 1,
},
isLoading: false,
isError: false,
} as never);
renderPage("empty");
expect(screen.getByText("Empty")).toBeInTheDocument();
expect(screen.getByText(/no shortcuts yet/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,135 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { ServicePage } from "../ServicePage";
import type { ServiceInstance, ServiceTypeInfo } from "../../types";
const instance: ServiceInstance = {
id: "svc-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: { api_key: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
const typeInfo: ServiceTypeInfo = {
service_type: "jellyfin",
name: "Jellyfin",
description: "Media server",
config_schema: {
type: "object",
properties: { base_url: { type: "string" } },
},
secret_fields: [{ key: "api_key", label: "API key", required: false }],
widget_kinds: [],
};
const secondInstance: ServiceInstance = {
...instance,
id: "svc-2",
name: "Backup Jellyfin",
};
const saveMutateAsync = vi.fn();
vi.mock("../../hooks/useServices", () => ({
useServiceInstances: () => ({
data: (window as unknown as { __svcInstances?: ServiceInstance[] })
?.__svcInstances ?? [instance],
}),
useServiceTypes: () => ({ data: [typeInfo] }),
useSaveServiceInstance: () => ({
mutateAsync: saveMutateAsync,
mutate: vi.fn(),
isPending: false,
}),
useDeleteServiceInstance: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../../integrations/registry", () => ({
getServiceBinding: () => ({
name: "Jellyfin",
description: "Media server",
widgets: [],
}),
}));
function renderServicePage(path: string) {
return render(
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Routes>
</MemoryRouter>,
);
}
describe("ServicePage tab skeleton", () => {
it("renders Overview + Media + Requests + Widgets + Config for jellyfin", () => {
renderServicePage("/services/jellyfin/svc-1");
expect(screen.getByRole("tab", { name: "Overview" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Media" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Requests" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Widgets" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Config" })).toBeInTheDocument();
});
it("does NOT render Media/Requests for non-jellyfin types", () => {
const sshInstance = { ...instance, service_type: "ssh_tasks", id: "ssh-1" };
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [sshInstance];
renderServicePage("/services/ssh_tasks/ssh-1");
expect(screen.getByRole("tab", { name: "Files" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Actions" })).toBeInTheDocument();
expect(
screen.queryByRole("tab", { name: "Media" }),
).not.toBeInTheDocument();
});
it("shows instance switcher when >1 sibling of same type", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance, secondInstance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// The switcher renders as a Select trigger (combobox).
expect(container.querySelector("[role='combobox']")).toBeInTheDocument();
});
it("hides instance switcher when only one instance", () => {
(
window as unknown as { __svcInstances: ServiceInstance[] }
).__svcInstances = [instance];
const { container } = renderServicePage("/services/jellyfin/svc-1");
// No select trigger rendered (only one instance).
expect(
container.querySelector("[role='combobox']"),
).not.toBeInTheDocument();
});
it("includes typed secret drafts in the save payload (B1 regression guard)", async () => {
const { userEvent } = await import("@testing-library/user-event");
const user = userEvent.setup();
saveMutateAsync.mockReset();
renderServicePage("/services/jellyfin/svc-1");
// Open the Config tab and type a new api_key.
await user.click(screen.getByRole("tab", { name: "Config" }));
const secretInput = screen.getByLabelText("api_key");
await user.type(secretInput, "new-secret-value");
// Save and assert the typed secret is in the payload (not secrets: {}).
await user.click(screen.getByRole("button", { name: "Save" }));
expect(saveMutateAsync).toHaveBeenCalledTimes(1);
const input = saveMutateAsync.mock.calls[0][0] as {
secrets: Record<string, string>;
};
expect(input.secrets).toEqual({ api_key: "new-secret-value" });
});
});

Some files were not shown because too many files have changed in this diff Show More