Compare commits

...

17 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
102 changed files with 7873 additions and 4321 deletions
@@ -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()
@@ -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,
),
],
)
@@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config."""
"""Non-secret Jellyfin connection config.
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,33 +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,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: ServiceBaseUrl
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=[],
)
@@ -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 -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()
+132 -4
View File
@@ -57,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"}
@@ -139,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",
@@ -265,7 +297,7 @@ def test_service_base_url_requires_http_schema(bad_url):
@pytest.mark.parametrize(
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"]
"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
@@ -390,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") == []
+54
View File
@@ -468,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.
+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`,
);
}
+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),
);
}
-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>
);
}
+245 -96
View File
@@ -1,17 +1,27 @@
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 { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import type {
ServiceInstance,
ServiceInstanceInput,
@@ -20,6 +30,11 @@ import type {
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,
@@ -50,6 +65,7 @@ export function ServicePage() {
}>();
const { data: services = [] } = useServiceInstances(serviceType || undefined);
const { data: types = [] } = useServiceTypes();
const navigate = useNavigate();
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
@@ -62,18 +78,35 @@ export function ServicePage() {
() => 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);
}
@@ -94,91 +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: draftConfig,
secrets: {}, // secrets are managed via the dedicated inputs below
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>
<ServiceConnectionCard
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
/>
{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}
@@ -189,27 +322,42 @@ export function ServicePage() {
onConfirm={() => {
deleteService.mutate(instance.id);
setDeleteOpen(false);
navigate("/services");
}}
/>
</div>
);
}
function ServiceConnectionCard({
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 saveService = useSaveServiceInstance();
// Empty-on-edit: local state starts blank; a blank field means "keep existing".
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const properties =
(
(typeInfo?.config_schema ?? {}) as {
@@ -233,11 +381,24 @@ function ServiceConnectionCard({
]);
return (
<SectionCard
title="Connection"
description="Edit non-secret connection config and secret values."
>
<SectionCard title="Config">
<div className="flex flex-col gap-3">
<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>
) : (
@@ -273,9 +434,7 @@ function ServiceConnectionCard({
</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">
@@ -290,7 +449,7 @@ function ServiceConnectionCard({
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
onSecretsChange({
...draftSecrets,
[key]: e.target.value,
})
@@ -303,24 +462,14 @@ function ServiceConnectionCard({
</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: draftConfig,
secrets: onlyChanged,
enabled: instance.enabled,
});
setDraftSecrets({});
}}
>
Update connection
</Button>
<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" });
});
});
@@ -1,285 +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 { UsersPage } from "../UsersPage.impl";
import { TooltipProvider } from "../../components/ui/tooltip";
import type {
NowPlayingSession,
UserDirectoryItem,
UserDirectoryResponse,
} from "../../types";
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
beforeEach(() => {
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
// The compose formatting actions defer a focus/selection restore via
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
// synchronously, so make it synchronous so the slice-6b compose test can
// observe the html-body value update.
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof window.requestAnimationFrame;
});
// Keep the drawer's nested session panel out of the DOM under test.
vi.mock("../../components/SessionActivityPanel", () => ({
SessionActivityPanel: ({
selectedUserLabel,
}: {
selectedUserLabel: string;
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
}));
let users: UserDirectoryItem[] = [];
let activity: NowPlayingSession[] = [];
function directoryResponse(): UserDirectoryResponse {
return {
items: users,
total: users.length,
jellyseerr_configured: true,
jellyseerr_available: true,
jellyseerr_error: "",
jellyseerr_jellyfin_user_count: 0,
jellyseerr_user_count: 0,
enriched_count: 0,
};
}
vi.mock("../../hooks/useUsers", () => ({
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
}));
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: activity }),
}));
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
}));
vi.mock("../../hooks/useSendUserMessage", () => ({
useSendUserMessage: () => ({
isPending: false,
isError: false,
isSuccess: false,
reset: vi.fn(),
mutateAsync: vi.fn(),
}),
}));
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
let currentParams: Record<string, string> = {};
const setSearchParams = vi.fn((next: Record<string, string>) => {
currentParams = { ...next };
});
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
}));
function userFixture(
overrides: Partial<UserDirectoryItem> = {},
): UserDirectoryItem {
return {
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
email: "alice@example.com",
email_source: "jellyfin",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "",
name_source: "jellyfin",
access_source: "jellyfin",
jellyseerr_user_id: null,
jellyseerr_username: "",
user_type: 1,
user_type_label: "User",
role: "admin",
permissions: 1,
permissions_label: "Administrator",
request_count: 0,
...overrides,
};
}
beforeEach(() => {
users = [];
activity = [];
currentParams = {};
setSearchParams.mockClear();
});
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
it("renders the directory table and metric counts", () => {
users = [userFixture()];
render(<UsersPage />);
expect(screen.getByText("Total users")).toBeInTheDocument();
expect(screen.getByText("User list")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("toggles row selection and reflects the selected-count badge", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
expect(screen.getByText("0 selected")).toBeInTheDocument();
// Selection-across-pagination: toggling a row updates the selected-id set.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("1 selected")).toBeInTheDocument();
// Toggling again removes it (the set survives, membership flips).
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("selects all visible rows via the header select-all checkbox", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
await userEvent.click(
screen.getByRole("checkbox", { name: "Select all visible users" }),
);
expect(screen.getByText("2 selected")).toBeInTheDocument();
});
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
users = [userFixture({ jellyfin_id: "u1" })];
render(<UsersPage />);
// Clicking the row body (not the checkbox) opens the detail drawer.
await userEvent.click(screen.getByText("Alice"));
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
});
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
users = [
userFixture({
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
}),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
activity = [
{
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
},
{
user: "bob",
title: "Show",
type: "Episode",
state: "paused",
transcoding: "no",
transcoding_type: "",
device: "TV",
session_id: "s2",
},
];
render(<UsersPage />);
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
"success",
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
});
it("renders the user detail drawer (Sheet) when a user is selected", () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
currentParams = { user: "u1" };
render(<UsersPage />);
// buildUserDrawerModel title = display name; rendered as the drawer heading.
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
// Drawer sections (identity / contact actions) + the activity panel render.
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Contact actions")).toBeInTheDocument();
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
});
});
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
it("opens compose and inserts bold markup into the html body", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select a deliverable user so the "Message selected" button enables.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Compose dialog opens (shadcn Dialog family).
expect(
screen.getByRole("heading", { name: "Message selected users" }),
).toBeInTheDocument();
// Bold action wraps the cursor selection in <strong></strong> via the
// preserved insertMarkup helper (markup insertion actions parity).
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
const body = screen.getByRole("textbox", {
name: "HTML message body",
}) as HTMLTextAreaElement;
expect(body.value).toContain("<strong>");
});
});
@@ -1,18 +1,25 @@
/**
* ActionsTab operational content for the ssh_tasks service page.
*
* Lifted from the old top-level `pages/Actions.tsx`. The `instance` prop
* provides the active ssh_tasks service id, which is used as the default run
* service. The page-level header is removed (the service page provides it).
* The task editor dialog, saved-task rail, and run history are preserved.
*/
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../types";
import type { SavedTask, SavedTaskInput, ServiceInstance } from "../../types";
import {
useDeleteTask,
useRunTask,
useSaveTask,
useTaskRuns,
useTasks,
} from "../hooks/useSettings";
import { useServiceInstances } from "../hooks/useServices";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard";
} from "../../hooks/useSettings";
import { DialogFooter } from "../../components/DialogFooter";
import { HoverEditButton } from "../../components/HoverEditButton";
import { SectionCard } from "../../components/SectionCard";
import { SelectionRailCard } from "../../components/SelectionRailCard";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -37,13 +44,8 @@ import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
// Radix Select disallows empty-string item values; the "None" option maps to
// this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type ActionTab = "new" | string;
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
label,
htmlFor,
@@ -106,16 +108,11 @@ function initialFromTask(task: SavedTask): SavedTaskInput {
function TaskEditor({
task,
services,
onChange,
}: {
task: SavedTaskInput;
services: ServiceInstance[];
onChange: (task: SavedTaskInput) => void;
}) {
const selectedService = services.find(
(service) => service.id === task.default_service_id,
);
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
@@ -124,11 +121,7 @@ function TaskEditor({
</p>
<Badge variant="outline">{task.task_type}</Badge>
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
{selectedService && (
<Badge variant="outline">{`default: ${selectedService.name}`}</Badge>
)}
</div>
<div className="flex flex-col gap-2">
<FormField label="Name" htmlFor="task-name">
<Input
@@ -159,31 +152,6 @@ function TaskEditor({
</Select>
</FormField>
</div>
<div className="min-w-[220px] flex-1">
<FormField label="Default SSH task service">
<Select
value={task.default_service_id || NONE}
onValueChange={(value) =>
onChange({
...task,
default_service_id: value === NONE ? "" : value,
})
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{services.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</div>
<FormField label="Notes">
<Input
@@ -216,7 +184,6 @@ function TaskDialog({
open,
task,
baseline,
services,
onClose,
onChange,
onSave,
@@ -225,7 +192,6 @@ function TaskDialog({
open: boolean;
task: SavedTaskInput;
baseline: SavedTaskInput;
services: ServiceInstance[];
onClose: () => void;
onChange: (task: SavedTaskInput) => void;
onSave: () => void;
@@ -235,12 +201,10 @@ function TaskDialog({
if (
!sameTask(task, baseline) &&
!window.confirm("Discard unsaved changes?")
) {
)
return;
}
onClose();
};
return (
<Dialog
open={open}
@@ -254,10 +218,10 @@ function TaskDialog({
<DialogDescription>
Save a reusable server task. Shell commands run via{" "}
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
Runs execute on the selected SSH task service instance.
Runs execute on this SSH task service instance.
</DialogDescription>
</DialogHeader>
<TaskEditor task={task} services={services} onChange={onChange} />
<TaskEditor task={task} onChange={onChange} />
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
@@ -277,8 +241,7 @@ function TaskDialog({
);
}
export function Actions() {
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
export function ActionsTab({ instance }: { instance: ServiceInstance }) {
const { data: tasks = [] } = useTasks();
const saveTask = useSaveTask();
const deleteTask = useDeleteTask();
@@ -288,9 +251,11 @@ export function Actions() {
const [draftBaseline, setDraftBaseline] = useState<SavedTaskInput>(
emptyTask(),
);
const [runServiceId, setRunServiceId] = useState("");
const [editOpen, setEditOpen] = useState(false);
// Default to this instance's service id for task runs.
const runServiceId = instance.id;
const selectedTask = useMemo(
() => tasks.find((task) => task.id === tab) ?? null,
[tasks, tab],
@@ -303,14 +268,6 @@ export function Actions() {
setEditOpen(true);
};
const createNew = () => {
const initial = emptyTask();
setDraft(initial);
setDraftBaseline(initial);
setRunServiceId(sshServices[0]?.id || "");
setEditOpen(true);
};
const saveDraft = async () => {
const saved = await saveTask.mutateAsync(draft);
setTab(saved.id);
@@ -328,20 +285,8 @@ export function Actions() {
setDraftBaseline(nextDraft);
};
const editingTask = selectedTask;
return (
<div className="flex flex-col gap-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Actions</h1>
<p className="text-xs text-muted-foreground">
Save reusable server tasks and switch between them with tabs.
</p>
</div>
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
</div>
<div className="flex flex-col gap-4">
{saveTask.error && (
<Alert variant="destructive">
<AlertDescription>{String(saveTask.error)}</AlertDescription>
@@ -368,7 +313,7 @@ export function Actions() {
variant="outline"
size="sm"
className="w-full"
onClick={createNew}
onClick={() => openEdit(emptyTask())}
>
Add action
</Button>
@@ -406,23 +351,23 @@ export function Actions() {
</SelectionRailCard>
<div className="flex flex-col gap-4">
{editingTask ? (
{selectedTask ? (
<SectionCard
title={editingTask.name}
title={selectedTask.name}
description="Open the editor popup to modify this action."
action={
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() => openEdit(initialFromTask(editingTask))}
onClick={() => openEdit(initialFromTask(selectedTask))}
>
Edit
</Button>
<Button
disabled={runTask.isPending || !runServiceId}
disabled={runTask.isPending}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
taskId: selectedTask.id,
serviceId: runServiceId,
});
}}
@@ -432,35 +377,7 @@ export function Actions() {
</div>
}
>
<div className="flex flex-wrap items-center gap-2">
<FormField
label="Run on SSH task service"
htmlFor="run-service-id"
>
<Select
value={runServiceId}
onValueChange={(value) => setRunServiceId(value)}
>
<SelectTrigger
id="run-service-id"
className="min-w-[240px]"
size="sm"
>
<SelectValue placeholder="Select service" />
</SelectTrigger>
<SelectContent>
{sshServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
<Separator />
<p className="text-sm font-semibold">Recent runs</p>
{selectedRuns.data?.items?.length ? (
<div className="flex flex-col gap-2">
@@ -509,25 +426,16 @@ export function Actions() {
)}
</SectionCard>
) : (
<div className="flex flex-col gap-4">
<SectionCard
title="No action selected"
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
>
{tasks[0] && (
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action
</Button>
)}
</SectionCard>
<SectionCard title="What this panel shows">
<p className="text-xs text-muted-foreground">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</p>
</SectionCard>
</div>
<SectionCard
title="No action selected"
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup."
>
{tasks[0] && (
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action
</Button>
)}
</SectionCard>
)}
</div>
</div>
@@ -536,7 +444,6 @@ export function Actions() {
open={editOpen}
task={draft}
baseline={draftBaseline}
services={sshServices}
onClose={() => setEditOpen(false)}
onChange={setDraft}
onSave={saveDraft}
@@ -0,0 +1,188 @@
/**
* Alertmanager Alerts tab (spec R2.4, R8.2).
*
* Lifts the Alertmanager alerts content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Renders the active-alert
* summary (total + by severity) and the expandable alert list.
*
* The hooks (useAlertmanagerAlerts, useAlertmanagerStatus) are global /
* first-configured for now — they don't accept a service_id yet. Wiring
* `instance.id` into them is a documented follow-up once the hooks gain the
* parameter. The `instance` prop is accepted for future scoping.
*/
import { AlertTriangle, Bell, ChevronDown, Inbox } from "lucide-react";
import {
useAlertmanagerAlerts,
useAlertmanagerStatus,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import type { AlertmanagerAlert, ServiceInstance } 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 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>
);
}
export function AlertsTab({ instance }: { instance: ServiceInstance }) {
// Global / first-configured hooks for now; instance.id scoping is a
// follow-up (see file docstring).
void instance;
const {
data: alertsSummary,
isLoading: alertsLoading,
error: alertsError,
} = useAlertmanagerAlerts();
const { data: status, isLoading: statusLoading } = useAlertmanagerStatus();
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: statusLoading
? "checking…"
: "unreachable";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Bell className="h-4 w-4" />
Alertmanager {statusDetail}
</div>
{alertsError && (
<Alert variant="destructive">
<AlertTitle>Failed to load alerts</AlertTitle>
<AlertDescription>{alertsError.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Active Alerts ({alertsSummary?.total ?? 0})
</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 ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Inbox className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No active alerts</div>
<div className="max-w-md text-sm text-muted-foreground">
Everything looks quiet. Firing alerts will appear here.
</div>
</div>
) : (
<>
{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>
</div>
);
}
@@ -1,9 +1,23 @@
import { useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
/**
* FilesTab operational content for the ssh_tasks service page.
*
* Lifted from the old top-level `pages/FileBrowser.impl.tsx`. The machine
* selector and `useMonitoringSettings` are removed; the active ssh_tasks
* instance id (from the `instance` prop) replaces the machine_id. The initial
* path is read from `?path=` search param for deep-link support (resolves the
* MediaTab row-click navigation from slice 5). Everything else directory
* listing, path bar, ffprobe preview, job execution is preserved.
*/
import { useState } from "react";
import { useSearchParams } from "react-router-dom";
import type { ColumnDef, RowSelectionState } from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import { Alert, AlertAction, AlertDescription } from "@/components/ui/alert";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -16,17 +30,27 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { TabsTrigger } from "@/components/ui/tabs";
import {
useDirectoryListing,
useFfprobe,
useJobTemplates,
useRunJob,
} from "../hooks/useFiles";
import { usePersistentState } from "../hooks/usePersistentState";
import { useMonitoringSettings } from "../hooks/useSettings";
import { SectionCard } from "../components/SectionCard";
import { TabbedCard } from "../components/TabbedCard";
} from "../../hooks/useFiles";
import { usePersistentState } from "../../hooks/usePersistentState";
import { SectionCard } from "../../components/SectionCard";
import type { ServiceInstance } from "../../types";
import { useIsMobile } from "../../hooks/useIsMobile";
// Mobile card fields (mobile-parity pattern).
const fileCardFields: MobileCardField<DisplayRow>[] = [
{ key: "name", label: "Name", render: (r) => r.name, primary: true },
{ key: "type", label: "Type", render: (r) => r.type },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "modified", label: "Modified", render: (r) => r.modified || "-" },
];
// --- Types (lifted verbatim) ---
interface DisplayRow {
id: string;
@@ -78,6 +102,8 @@ interface FfprobeData {
streams?: FfprobeStream[];
}
// --- Helpers (lifted verbatim) ---
function formatSize(bytes: number): string {
if (bytes === 0) return "-";
const units = ["B", "KB", "MB", "GB", "TB"];
@@ -149,9 +175,8 @@ function isVideoFile(name: string): boolean {
return exts.some((ext) => name.toLowerCase().endsWith(ext));
}
// Design §3.2: referentially-stable column defs (a new array each render would
// destabilize the TanStack table instance and drop controlled selection).
// Visibility-only: no sorting, no sizing/resizing (design §3.3).
// --- Column defs (lifted verbatim) ---
const fileColumns: ColumnDef<DisplayRow>[] = [
{
accessorKey: "type",
@@ -182,7 +207,9 @@ const fileColumns: ColumnDef<DisplayRow>[] = [
},
];
const FILE_BROWSER_STATE_KEY = "manage.files.browserState";
// --- State + helpers (lifted) ---
const FILE_TAB_STATE_KEY = "manage.files.tabState";
type FileBrowserState = {
currentDir: string;
@@ -200,6 +227,8 @@ function defaultFileBrowserState(): FileBrowserState {
};
}
// --- Ffprobe rendering (lifted verbatim) ---
function FfprobeChip({
children,
variant = "outline",
@@ -217,15 +246,9 @@ function StreamBlock({ children }: { children: React.ReactNode }) {
function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
const format = data.format ?? {};
const streams = data.streams ?? [];
const videoStreams = streams.filter(
(stream) => stream.codec_type === "video",
);
const audioStreams = streams.filter(
(stream) => stream.codec_type === "audio",
);
const subtitleStreams = streams.filter(
(stream) => stream.codec_type === "subtitle",
);
const videoStreams = streams.filter((s) => s.codec_type === "video");
const audioStreams = streams.filter((s) => s.codec_type === "audio");
const subtitleStreams = streams.filter((s) => s.codec_type === "subtitle");
return (
<div className="flex flex-col gap-4">
@@ -233,7 +256,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
<div className="text-base font-semibold">ffprobe details</div>
<div className="text-xs text-muted-foreground">{path}</div>
</div>
<Card>
<CardContent className="flex flex-col gap-3">
<div className="text-sm font-semibold">Container / format</div>
@@ -269,11 +291,9 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex flex-col gap-3">
<div className="text-sm font-semibold">Streams</div>
{videoStreams.length > 0 && (
<div>
<div className="text-xs text-muted-foreground">Video streams</div>
@@ -323,9 +343,7 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</FfprobeChip>
)}
{stream.width && stream.height && (
<FfprobeChip variant="outline">
{`${stream.width}×${stream.height}`}
</FfprobeChip>
<FfprobeChip variant="outline">{`${stream.width}×${stream.height}`}</FfprobeChip>
)}
{stream.pix_fmt && (
<FfprobeChip variant="outline">
@@ -333,14 +351,10 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</FfprobeChip>
)}
{stream.display_aspect_ratio && (
<FfprobeChip variant="outline">
{`DAR ${stream.display_aspect_ratio}`}
</FfprobeChip>
<FfprobeChip variant="outline">{`DAR ${stream.display_aspect_ratio}`}</FfprobeChip>
)}
{stream.sample_aspect_ratio && (
<FfprobeChip variant="outline">
{`SAR ${stream.sample_aspect_ratio}`}
</FfprobeChip>
<FfprobeChip variant="outline">{`SAR ${stream.sample_aspect_ratio}`}</FfprobeChip>
)}
{stream.level !== undefined &&
stream.level !== null && (
@@ -382,7 +396,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</div>
)}
{audioStreams.length > 0 && (
<div>
<div className="text-xs text-muted-foreground">Audio streams</div>
@@ -431,7 +444,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</div>
)}
{subtitleStreams.length > 0 && (
<div>
<div className="text-xs text-muted-foreground">
@@ -464,7 +476,6 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
</div>
</div>
)}
{streams.length === 0 && (
<div className="text-sm text-muted-foreground">
No streams found.
@@ -472,16 +483,16 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
)}
</CardContent>
</Card>
{Object.keys(format.tags ?? {}).length > 0 && (
<Card>
<CardContent className="flex flex-col gap-2">
<div className="text-sm font-semibold">Tags</div>
<div className="flex flex-row flex-wrap gap-1.5">
{Object.entries(format.tags ?? {}).map(([key, value]) => (
<FfprobeChip key={key} variant="outline">
{`${key}: ${value}`}
</FfprobeChip>
<FfprobeChip
key={key}
variant="outline"
>{`${key}: ${value}`}</FfprobeChip>
))}
</div>
</CardContent>
@@ -491,56 +502,36 @@ function FfprobeDetails({ path, data }: { path: string; data: FfprobeData }) {
);
}
function InfoAlert({ children }: { children: React.ReactNode }) {
return (
<Alert>
<AlertDescription>{children}</AlertDescription>
</Alert>
);
}
// --- Component ---
export function FileBrowser() {
const [searchParams, setSearchParams] = useSearchParams();
export function FilesTab({ instance }: { instance: ServiceInstance }) {
const isMobile = useIsMobile();
const machineId = instance.id;
const [searchParams] = useSearchParams();
const requestedPath = searchParams.get("path");
const [columnVisibility, setColumnVisibility] = useState<
Record<string, boolean>
>({});
const { data: machines } = useMonitoringSettings();
const fileMachines = useMemo(
() =>
(machines ?? []).filter(
(machine) =>
machine.enabled &&
(machine.services.includes("files") ||
machine.services.includes("monitoring")),
),
[machines],
);
const initialRequestedPath = searchParams.get("path");
const initialMachineId =
searchParams.get("machine_id") || fileMachines[0]?.id || "";
const [browserState, setBrowserState] = usePersistentState<FileBrowserState>(
FILE_BROWSER_STATE_KEY,
`${FILE_TAB_STATE_KEY}.${instance.id}`,
() => {
const requestedPath = initialRequestedPath ?? "/";
const path = requestedPath ?? "/";
const selectedPath =
requestedPath !== "/" &&
(isVideoFile(requestedPath) || requestedPath.includes("."))
? requestedPath.replace(/\/+$/, "")
path !== "/" && (isVideoFile(path) || path.includes("."))
? path.replace(/\/+$/, "")
: null;
const currentDir = selectedPath
? selectedPath.replace(/\/[^/]+$/, "") || "/"
: requestedPath.replace(/\/+$/, "") || "/";
: path.replace(/\/+$/, "") || "/";
return {
...defaultFileBrowserState(),
currentDir,
pathInput: requestedPath || currentDir,
pathInput: path || currentDir,
selectedPath,
};
},
);
const { currentDir, pathInput, selectedPath, selectedJob } = browserState;
const selectedMachineId = searchParams.get("machine_id") || initialMachineId;
const navigateToSettings = useNavigate();
const updateBrowserState = (patch: Partial<FileBrowserState>) =>
setBrowserState((current) => ({ ...current, ...patch }));
@@ -549,7 +540,7 @@ export function FileBrowser() {
isLoading,
error,
refetch,
} = useDirectoryListing(currentDir, selectedMachineId || undefined);
} = useDirectoryListing(currentDir, machineId);
const {
data: ffprobeData,
isLoading: ffprobeLoading,
@@ -557,10 +548,10 @@ export function FileBrowser() {
} = useFfprobe(
selectedPath ?? "",
!!selectedPath && isVideoFile(selectedPath),
selectedMachineId || undefined,
machineId,
);
const { data: templates } = useJobTemplates();
const runJob = useRunJob(selectedMachineId || undefined);
const runJob = useRunJob(machineId);
const navigate = (path: string) => {
updateBrowserState({
@@ -570,18 +561,6 @@ export function FileBrowser() {
});
};
const setMachine = (machineId: string) => {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
if (machineId) next.set("machine_id", machineId);
else next.delete("machine_id");
return next;
},
{ replace: true },
);
};
const handlePathSubmit = (e: React.KeyboardEvent) => {
if (e.key === "Enter") navigate(pathInput || "/");
};
@@ -616,8 +595,6 @@ export function FileBrowser() {
}
}
// Preserved row-click behavior (MUI DataGrid onRowClick): dir/up rows navigate;
// file rows select the file for ffprobe preview (also feeds pathInput).
const handleRowClick = (row: DisplayRow) => {
if (row.type === "dir" || row.type === "up") {
navigate(row.path);
@@ -630,8 +607,6 @@ export function FileBrowser() {
});
};
// Single-select checkbox behavior (DataTable adds a selection column under
// enableRowSelection): mirrors the row-click selection for file rows.
const rowSelection: RowSelectionState = selectedPath
? { [selectedPath]: true }
: {};
@@ -659,204 +634,182 @@ export function FileBrowser() {
const selectedTemplate = templates?.find((t) => t.key === selectedJob);
return (
<div className="flex flex-col gap-4.5">
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-xl font-semibold">File Browser</h2>
<Badge variant="outline">
{fileMachines.length
? `${fileMachines.length} machine${fileMachines.length === 1 ? "" : "s"}`
: "No file machines"}
</Badge>
</div>
<TabbedCard
value={fileMachines.length > 0 ? selectedMachineId : ""}
onChange={setMachine}
tabs={fileMachines.map((machine) => (
<TabsTrigger key={machine.id} value={machine.id}>
{`${machine.name} · ${machine.mode}`}
</TabsTrigger>
))}
<div className="flex flex-col gap-4">
<SectionCard
title="Browser"
description="Read-only listing with explicit open/select actions."
>
{fileMachines.length > 0 ? (
<div className="flex flex-col gap-4">
<SectionCard
title="Browser"
description="Read-only listing with explicit open/select actions."
>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 md:flex-row">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="remote-path">Remote path</Label>
<Input
id="remote-path"
value={pathInput}
onChange={(e) =>
updateBrowserState({ pathInput: e.target.value })
}
onKeyDown={handlePathSubmit}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<Button
variant="outline"
className="w-full md:w-auto"
onClick={() => navigate(pathInput || "/")}
>
Open
</Button>
<Button
variant="outline"
className="w-full md:w-auto"
onClick={() => refetch()}
>
Refresh
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{`Current: ${currentDir} `}
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
{listing ? `| Entries: ${listing.count}` : ""}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
<div className="rounded-lg border bg-card">
<DataTable
columns={fileColumns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={handleSelectionChange}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading
? "Loading directory..."
: "This directory is empty."
}
/>
</div>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-2 md:flex-row">
<div className="flex flex-1 flex-col gap-1">
<Label htmlFor="remote-path">Remote path</Label>
<Input
id="remote-path"
value={pathInput}
onChange={(e) =>
updateBrowserState({ pathInput: e.target.value })
}
onKeyDown={handlePathSubmit}
/>
</div>
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<Button
variant="outline"
className="w-full md:w-auto"
onClick={() => navigate(pathInput || "/")}
>
Open
</Button>
<Button
variant="outline"
className="w-full md:w-auto"
onClick={() => refetch()}
>
Refresh
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
{`Current: ${currentDir} `}
{selectedPath ? `| Selected: ${selectedPath} ` : ""}
{listing ? `| Entries: ${listing.count}` : ""}
</div>
{error && (
<Alert variant="destructive">
<AlertDescription>{String(error)}</AlertDescription>
</Alert>
)}
<div className="rounded-lg border bg-card">
{isMobile ? (
<div className="p-4">
<MobileCardRow
rows={rows}
fields={fileCardFields}
getRowId={(row) => row.id}
onRowClick={handleRowClick}
/>
</div>
) : (
<DataTable
columns={fileColumns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={handleSelectionChange}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={columnVisibility}
onColumnVisibilityChange={setColumnVisibility}
emptyMessage={
isLoading ? "Loading directory..." : "This directory is empty."
}
/>
)}
</div>
</div>
</SectionCard>
<SectionCard
title="Media info"
description="ffprobe metadata for the selected media file."
>
{selectedPath ? (
isVideoFile(selectedPath) ? (
ffprobeError ? (
<Alert variant="destructive">
<AlertDescription>{String(ffprobeError)}</AlertDescription>
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<Alert>
<AlertDescription>Loading ffprobe data...</AlertDescription>
</Alert>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<Alert>
<AlertDescription>No ffprobe data available.</AlertDescription>
</Alert>
)
) : (
<Alert>
<AlertDescription>
Select a video file to view ffprobe details.
</AlertDescription>
</Alert>
)
) : (
<Alert>
<AlertDescription>
Select a file in Browser to view ffprobe details.
</AlertDescription>
</Alert>
)}
</SectionCard>
<SectionCard
title="Jobs"
description="Run predefined safe jobs against the selected file."
>
{selectedPath && templates && templates.length > 0 ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="job-template">Job template</Label>
<Select
value={selectedJob}
onValueChange={(value) =>
updateBrowserState({ selectedJob: value })
}
>
<SelectTrigger id="job-template" className="w-full">
<SelectValue placeholder="Select a job" />
</SelectTrigger>
<SelectContent>
{templates.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</SectionCard>
<SectionCard
title="Media info"
description="ffprobe metadata for the selected media file."
>
{selectedPath ? (
isVideoFile(selectedPath) ? (
ffprobeError ? (
<Alert variant="destructive">
<AlertDescription>
{String(ffprobeError)}
</AlertDescription>
</Alert>
) : ffprobeLoading && !ffprobeData ? (
<InfoAlert>Loading ffprobe data...</InfoAlert>
) : ffprobeData ? (
<FfprobeDetails
path={selectedPath}
data={ffprobeData as FfprobeData}
/>
) : (
<InfoAlert>No ffprobe data available.</InfoAlert>
)
) : (
<InfoAlert>
Select a video file to view ffprobe details.
</InfoAlert>
)
) : (
<InfoAlert>
Select a file in Browser to view ffprobe details.
</InfoAlert>
)}
</SectionCard>
<SectionCard
title="Jobs"
description="Run predefined safe jobs against the selected file."
>
{selectedPath && templates && templates.length > 0 ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="job-template">Job template</Label>
<Select
value={selectedJob}
onValueChange={(value) =>
updateBrowserState({ selectedJob: value })
}
>
<SelectTrigger id="job-template" className="w-full">
<SelectValue placeholder="Select a job" />
</SelectTrigger>
<SelectContent>
{templates.map((tpl) => (
<SelectItem key={tpl.key} value={tpl.key}>
{tpl.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
<Button
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({
jobKey: selectedJob,
path: selectedPath,
})
}
>
Run job
</Button>
{selectedTemplate && (
<div className="self-center text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
)}
</div>
<div className="flex flex-col gap-2 md:col-span-3 md:flex-row md:items-end">
<Button
disabled={!selectedJob || runJob.isPending}
onClick={() =>
runJob.mutate({ jobKey: selectedJob, path: selectedPath })
}
>
Run job
</Button>
{selectedTemplate && (
<div className="self-center text-sm text-muted-foreground">
{selectedTemplate.description}
</div>
{runJob.data && (
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
{`Exit: ${runJob.data.exit_status}`}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</div>
) : (
<InfoAlert>Select a file in Browser to run jobs.</InfoAlert>
)}
</SectionCard>
)}
</div>
</div>
{runJob.data && (
<pre className="max-h-[260px] overflow-auto rounded-md bg-muted p-3 text-xs">
{`Exit: ${runJob.data.exit_status}`}
{"\n"}
{runJob.data.stdout}
{runJob.data.stderr && `\nSTDERR: ${runJob.data.stderr}`}
</pre>
)}
</div>
) : (
<Alert>
<AlertDescription>
No file-capable machines are configured yet.
Select a file in Browser to run jobs.
</AlertDescription>
<AlertAction>
<Button
variant="outline"
size="sm"
onClick={() => navigateToSettings("/settings")}
>
Open Settings
</Button>
</AlertAction>
</Alert>
)}
</TabbedCard>
</SectionCard>
</div>
);
}
@@ -1,3 +1,15 @@
/**
* JobsTab operational content for the backups service page.
*
* Lifted from the old top-level `components/BackupsPage.tsx`. The three
* sub-tables (Jobs / Runs / Alerts) and their hooks are preserved verbatim.
*
* NOTE: the backup hooks currently query globally (no service_id filter).
* The backend gained `service_id` attribution in Slice 3, but the hooks don't
* yet accept a serviceId param. This tab shows ALL backups data for now;
* per-instance scoping by `instance.id` is a follow-up once the hooks gain the
* parameter.
*/
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
@@ -5,12 +17,16 @@ import {
useBackupAlerts,
useBackupJobs,
useBackupRuns,
} from "../hooks/useBackups";
import BackupAlertsTable from "./BackupAlertsTable";
import BackupJobsTable from "./BackupJobsTable";
import BackupRunsTable from "./BackupRunsTable";
} from "../../hooks/useBackups";
import BackupAlertsTable from "../../components/BackupAlertsTable";
import BackupJobsTable from "../../components/BackupJobsTable";
import BackupRunsTable from "../../components/BackupRunsTable";
import type { ServiceInstance } from "../../types";
export default function BackupsPage() {
export function JobsTab({ instance }: { instance: ServiceInstance }) {
// instance.id is not yet used — backup hooks query globally (see file
// docstring). Per-instance scoping is a follow-up.
void instance;
const [tab, setTab] = useState("jobs");
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
@@ -35,7 +51,6 @@ export default function BackupsPage() {
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="jobs">Jobs</TabsTrigger>
@@ -0,0 +1,194 @@
/**
* Grafana Links tab (spec R2.4, R8.2).
*
* Lifts the Grafana deep-link content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health + the
* configured Grafana deep-links (node-exporter dashboard, Loki logs per
* machine).
*
* The hooks (useGrafanaStatus, useMonitoringMachines) are global /
* first-configured for now. Wiring `instance.id` into the status hook is a
* follow-up. The machine links use the configured Grafana base_url from the
* instance's config.
*/
import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { Activity, ExternalLink, Gauge, ServerOff } from "lucide-react";
import {
useGrafanaStatus,
useMonitoringMachines,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type { ServiceInstance } from "../../types";
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 LinksTab({ instance }: { instance: ServiceInstance }) {
const { data: status, isLoading, error } = useGrafanaStatus();
const { data: machines = [], isLoading: machinesLoading } =
useMonitoringMachines();
const [selectedMachineId, setSelectedMachineId] = useState("");
const grafanaBaseUrl =
(instance.config?.base_url as string | undefined) ?? "";
const selectedMachine = useMemo(
() =>
machines.find((m) => m.id === selectedMachineId) ?? machines[0] ?? null,
[machines, selectedMachineId],
);
const nodeExporterDashboardUrl = useMemo(() => {
if (!selectedMachine || !grafanaBaseUrl) return "";
const inst = `${selectedMachine.host || "localhost"}:9100`;
return `${grafanaBaseUrl}/d/node-exporter-overview/node-exporter-overview?kiosk&var-instance=${encodeURIComponent(inst)}`;
}, [selectedMachine, grafanaBaseUrl]);
const logsUrl = useMemo(() => {
if (!selectedMachine || !grafanaBaseUrl) return "";
const container =
selectedMachine.mode === "local" ? "backend" : selectedMachine.name;
return `${grafanaBaseUrl}/explore?orgId=1&left=${encodeURIComponent(
JSON.stringify({
datasource: "Loki",
queries: [{ refId: "A", expr: `{container="${container}"}` }],
range: { from: "now-1h", to: "now" },
}),
)}`;
}, [selectedMachine, grafanaBaseUrl]);
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: isLoading
? "checking…"
: error
? "unreachable"
: "not configured";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Gauge className="h-4 w-4" />
Grafana {statusDetail}
</div>
{error && (
<Alert variant="destructive">
<AlertTitle>Failed to reach Grafana</AlertTitle>
<AlertDescription>{error.message}</AlertDescription>
</Alert>
)}
<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>
{machines.length > 0 ? (
<Select
value={selectedMachine?.id ?? ""}
onValueChange={setSelectedMachineId}
disabled={machinesLoading}
>
<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>
) : null}
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<Skeleton className="h-24 w-full" />
) : selectedMachine && grafanaBaseUrl ? (
<>
<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}
/>
</>
) : !grafanaBaseUrl ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Gauge className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No Grafana base URL configured</div>
<div className="max-w-md text-sm text-muted-foreground">
Add a Grafana service instance to enable deep-links to
dashboards and logs.
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/services">Open Services</Link>
</Button>
</div>
) : (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<ServerOff className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No machine selected</div>
<div className="max-w-md text-sm text-muted-foreground">
Add monitoring machines in Settings to see Grafana drill-down
links.
</div>
<Button variant="outline" size="sm" asChild>
<Link to="/settings">Open Settings</Link>
</Button>
</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -1,5 +1,13 @@
/**
* MediaTab operational content for the Jellyfin service page.
*
* Lifted from the old top-level `pages/Media.tsx`. The service-id source is
* changed from URL search params to the `instance` prop (the active service
* instance selected on the service page). The service-selection dropdown and
* its URL-sync effect are removed; everything else is preserved verbatim.
*/
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import type {
ColumnDef,
OnChangeFn,
@@ -9,6 +17,11 @@ import type {
} from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { TablePagination } from "@/components/ui/table-pagination";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
@@ -29,11 +42,14 @@ import {
useBuildIndex,
useStopBuildIndex,
useForceStopBuildIndex,
} from "../hooks/useMedia";
import { usePersistentState } from "../hooks/usePersistentState";
import type { MediaItem } from "../types";
import { useServiceInstances } from "../hooks/useServices";
import { useCounts, useLibraries } from "../hooks/useDashboard";
} from "../../hooks/useMedia";
import { usePersistentState } from "../../hooks/usePersistentState";
import { useIsMobile } from "../../hooks/useIsMobile";
import type { MediaItem, ServiceInstance } from "../../types";
import { useCounts, useLibraries } from "../../hooks/useDashboard";
import { useServiceInstances } from "../../hooks/useServices";
// --- Format helpers (lifted verbatim from Media.tsx) ---
function formatDuration(seconds: number | null | undefined): string {
if (seconds == null || Number.isNaN(seconds)) return "-";
@@ -46,10 +62,8 @@ function formatDuration(seconds: number | null | undefined): string {
return `${secs}s`;
}
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
// TanStack table instance stays stable — an unstable columns array drops the
// controlled selection/visibility state (7a discovery). Visibility-only parity
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
// --- Column definitions (lifted verbatim) ---
const mediaColumns: ColumnDef<MediaItem>[] = [
{ accessorKey: "title", header: "Title" },
{ accessorKey: "series", header: "Series" },
@@ -68,16 +82,27 @@ const mediaColumns: ColumnDef<MediaItem>[] = [
{ accessorKey: "path", header: "Path" },
];
// Stable path-derived identity so row selection survives server-driven paging
// (design §3.4): the id is the item's filesystem path, which is stable across
// limit/offset page changes.
function getMediaRowId(row: MediaItem): string {
return row.path;
}
// Mobile card fields (mobile-parity pattern): title primary + 4 key fields.
const mediaCardFields: MobileCardField<MediaItem>[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size || "-" },
{ key: "hdr", label: "HDR", render: (r) => r.hdr || "-" },
{ key: "library", label: "Library", render: (r) => r.library || "-" },
{
key: "year",
label: "Year",
render: (r) => (r.year != null ? String(r.year) : "-"),
},
];
// --- Persistent filter/sort/pagination state (lifted verbatim) ---
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
const MOBILE_HIDDEN_COLUMNS = [
"series",
"season",
@@ -130,6 +155,8 @@ function usePrefersSmallScreen(): boolean {
return small;
}
// --- Small UI helpers (lifted verbatim) ---
function FilterSelect({
id,
label,
@@ -162,9 +189,6 @@ function FilterSelect({
);
}
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
// "indeterminate" affordance for unknown build progress.
function BuildProgress({ value }: { value: number | null }) {
if (value == null) {
return (
@@ -174,30 +198,26 @@ function BuildProgress({ value }: { value: number | null }) {
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
}
export function Media() {
// --- Component ---
export function MediaTab({ instance }: { instance: ServiceInstance }) {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { data: sshServices = [] } = useServiceInstances("ssh_tasks");
const isSmall = usePrefersSmallScreen();
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);
const { data: status } = useMediaStatus(selectedServiceId || undefined);
const buildIndex = useBuildIndex(selectedServiceId || undefined);
const stopBuildIndex = useStopBuildIndex(selectedServiceId || undefined);
const forceStopBuildIndex = useForceStopBuildIndex(
selectedServiceId || undefined,
);
const isMobile = useIsMobile();
const serviceId = instance.id;
const { data: counts } = useCounts(serviceId);
const { data: libraries } = useLibraries(serviceId);
const { data: status } = useMediaStatus(serviceId);
const buildIndex = useBuildIndex(serviceId);
const stopBuildIndex = useStopBuildIndex(serviceId);
const forceStopBuildIndex = useForceStopBuildIndex(serviceId);
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
// without pageSize/columnVisibility) never yields undefined fields.
const mediaState: MediaTabState = {
...defaultMediaTabState(),
...rawMediaState,
@@ -209,19 +229,6 @@ export function Media() {
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
if (!searchParams.get("jellyfin_service_id") && selectedServiceId) {
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("jellyfin_service_id", selectedServiceId);
return next;
},
{ replace: true },
);
}
}, [searchParams, selectedServiceId, setSearchParams]);
const { data: queryResult, isLoading } = useMediaDataQuery({
types,
search,
@@ -230,12 +237,10 @@ export function Media() {
sort_order: sortOrder,
limit: pageSize,
offset,
jellyfinServiceId: selectedServiceId || undefined,
jellyfinServiceId: serviceId,
enabled: status?.exists ?? false,
});
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
// persistent media state and drive useMediaQuery { limit, offset }.
const pageIndex = Math.floor(offset / pageSize);
const pagination: PaginationState = { pageIndex, pageSize };
@@ -245,8 +250,6 @@ export function Media() {
? updater({ pageIndex, pageSize })
: updater;
const nextPageSize = next.pageSize || pageSize;
// Restart at page 0 whenever the page size changes (keeps offset sane
// under server-driven paging).
const nextOffset =
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
setMediaState((current) => ({
@@ -266,9 +269,6 @@ export function Media() {
});
};
// On small screens force the same set of columns hidden as the pre-rework
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
// toggles freely (the toggleable set still equals the locked 15).
const effectiveColumnVisibility = useMemo(() => {
const base = mediaState.columnVisibility ?? {};
if (!isSmall) return base;
@@ -277,10 +277,15 @@ export function Media() {
return merged;
}, [mediaState.columnVisibility, isSmall]);
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
// the item's path.
const handleRowClick = (row: MediaItem) => {
navigate(`/files?path=${encodeURIComponent(row.path)}`);
// Navigate to the ssh_tasks service page with the path query param.
// If an ssh_tasks instance exists, open its Files tab; otherwise land
// on the ssh_tasks type page (empty state / ServiceTypePage resolver).
const sshInstance = sshServices.find((s) => s.enabled);
const base = sshInstance
? `/services/ssh_tasks/${sshInstance.id}`
: "/services/ssh_tasks";
navigate(`${base}?path=${encodeURIComponent(row.path)}`);
};
const total = queryResult?.total ?? 0;
@@ -316,35 +321,6 @@ export function Media() {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor="media-service">Service</Label>
<Select
value={selectedServiceId}
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("jellyfin_service_id", value);
return next;
},
{ replace: true },
)
}
>
<SelectTrigger id="media-service" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a service" />
</SelectTrigger>
<SelectContent>
{jellyfinServices.map((service) => (
<SelectItem key={service.id} value={service.id}>
{service.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{status?.exists ? (
<p className="text-sm text-muted-foreground">
Index: {status.item_count.toLocaleString()} items
@@ -532,33 +508,56 @@ export function Media() {
</p>
)}
{status?.exists && (
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
/>
</div>
)}
{status?.exists &&
(isMobile ? (
<div className="rounded-lg border bg-card">
<div className="p-4">
<MobileCardRow
rows={queryResult?.items ?? []}
fields={mediaCardFields}
getRowId={getMediaRowId}
onRowClick={handleRowClick}
/>
</div>
{queryResult && (
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
pageSizeOptions={[50, 100, 200]}
totalRows={total}
pageCount={totalPages}
onPaginationChange={handlePaginationChange}
className="p-4"
/>
)}
</div>
) : (
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
/>
</div>
))}
</div>
);
}
@@ -0,0 +1,129 @@
/** MessagingTab — compose email to Authentik users via the mail queue. */
import { useState } from "react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
useAuthentikUsers,
useSendAuthentikMessage,
} from "../../hooks/useAuthentik";
import type { ServiceInstance } from "../../types";
const DEFAULT_BODY = "<p>Hello,</p><p> </p><p>Best,<br />Manage</p>";
export function MessagingTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [selectedEmails, setSelectedEmails] = useState<Set<string>>(new Set());
const [subject, setSubject] = useState("");
const [htmlBody, setHtmlBody] = useState(DEFAULT_BODY);
const { data } = useAuthentikUsers(instance.id, {
search,
page: 1,
page_size: 100,
});
const sendMessage = useSendAuthentikMessage(instance.id);
const users = (data?.items ?? []).filter((u) => u.email);
const error = data?.error;
function toggleEmail(email: string) {
setSelectedEmails((prev) => {
const next = new Set(prev);
if (next.has(email)) next.delete(email);
else next.add(email);
return next;
});
}
function handleSend() {
if (!subject.trim() || selectedEmails.size === 0) return;
sendMessage.mutate({
recipient_emails: Array.from(selectedEmails),
subject: subject.trim(),
html_body: htmlBody,
});
}
const canSend =
subject.trim() !== "" && selectedEmails.size > 0 && !sendMessage.isPending;
return (
<div className="flex flex-col gap-4">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
{sendMessage.data ? (
<Alert>
<AlertDescription>
{sendMessage.data.status === "queued"
? `Message queued (${sendMessage.data.recipient_count ?? 0} recipients, request ${sendMessage.data.request_id?.slice(0, 8) ?? ""}).`
: `Error: ${sendMessage.data.error ?? "unknown"}`}
</AlertDescription>
</Alert>
) : null}
<div className="flex flex-col gap-2">
<Label htmlFor="msg-search">Find recipients</Label>
<Input
id="msg-search"
placeholder="Search users to add as recipients…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="max-w-md"
/>
{users.length > 0 ? (
<div className="flex flex-wrap gap-2">
{users.slice(0, 20).map((user) => (
<Button
key={user.pk}
variant={selectedEmails.has(user.email) ? "default" : "outline"}
size="sm"
onClick={() => toggleEmail(user.email)}
>
{user.name || user.username}
</Button>
))}
</div>
) : null}
{selectedEmails.size > 0 ? (
<p className="text-sm text-muted-foreground">
{selectedEmails.size} recipient
{selectedEmails.size === 1 ? "" : "s"} selected.
</p>
) : null}
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-subject">Subject</Label>
<Input
id="msg-subject"
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="msg-body">Message (HTML)</Label>
<Textarea
id="msg-body"
rows={8}
value={htmlBody}
onChange={(e) => setHtmlBody(e.target.value)}
className="font-mono text-xs"
/>
</div>
<div>
<Button onClick={handleSend} disabled={!canSend}>
{sendMessage.isPending ? "Sending…" : "Send message"}
</Button>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
/**
* Prometheus Metrics tab (spec R2.4, R8.2).
*
* Lifts the Prometheus status + targets content from the old cross-service
* ObservabilityPage into an instance-scoped tab. Shows service health and
* the Node Exporter scrape-targets list.
*
* The hooks (usePrometheusStatus, usePrometheusTargets) are global /
* first-configured for now. Wiring `instance.id` is a follow-up.
*/
import { Radio } from "lucide-react";
import {
usePrometheusStatus,
usePrometheusTargets,
} from "../../hooks/useObservability";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Skeleton } from "@/components/ui/skeleton";
import type { PrometheusTarget, ServiceInstance } from "../../types";
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>
);
}
export function MetricsTab({ instance }: { instance: ServiceInstance }) {
// Global / first-configured hooks for now; instance.id scoping is a
// follow-up (see file docstring).
void instance;
const {
data: status,
isLoading: statusLoading,
error: statusError,
} = usePrometheusStatus();
const {
data: targets,
isLoading: targetsLoading,
error: targetsError,
} = usePrometheusTargets();
const statusDetail = status?.up
? status.version
? `version ${status.version}`
: "reachable"
: statusLoading
? "checking…"
: "unreachable";
return (
<div className="space-y-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Radio className="h-4 w-4" />
Prometheus {statusDetail}
</div>
{statusError && (
<Alert variant="destructive">
<AlertTitle>Failed to reach Prometheus</AlertTitle>
<AlertDescription>{statusError.message}</AlertDescription>
</Alert>
)}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Radio className="h-4 w-4" />
Node Exporter Targets ({targets?.length ?? 0})
</CardTitle>
</CardHeader>
<CardContent>
{targetsLoading ? (
<div className="space-y-2">
<Skeleton className="h-16 w-full" />
<Skeleton className="h-16 w-full" />
</div>
) : !targets || targets.length === 0 ? (
<div className="flex h-full min-h-[160px] flex-col items-center justify-center gap-2 rounded-md border p-6 text-center">
<Radio className="h-8 w-8 text-muted-foreground" />
<div className="font-medium">No Node Exporter targets</div>
<div className="max-w-md text-sm text-muted-foreground">
Enable Node Exporter on an SSH machine in Settings to populate
Prometheus scrape targets.
</div>
</div>
) : (
<TargetsTable targets={targets} />
)}
</CardContent>
</Card>
{targetsError && (
<Alert variant="destructive">
<AlertTitle>Failed to load targets</AlertTitle>
<AlertDescription>{targetsError.message}</AlertDescription>
</Alert>
)}
</div>
);
}
@@ -0,0 +1,64 @@
/**
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
*
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
* configured, it shows the URL and a placeholder (no requests backend endpoint
* exists yet — building one is out of scope for this slice). When not
* configured, it shows an empty-state CTA directing the user to add the fields
* to the Jellyfin config.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ExternalLink } from "lucide-react";
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
const jellyseerrUrl = String(
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
).trim();
const jellyseerrApiKey = String(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
).trim();
if (!jellyseerrUrl || !jellyseerrApiKey) {
return (
<Alert>
<AlertDescription>
Jellyseerr is not configured for this Jellyfin instance. Add
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url
</code>
and
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key
</code>
to the Jellyfin config (Config tab) to enable request management.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold">Jellyseerr</h3>
<a
href={jellyseerrUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
{jellyseerrUrl}
<ExternalLink className="size-3.5" />
</a>
</div>
<Alert>
<AlertDescription>
Jellyseerr is configured. The requests view will show pending and
recently fulfilled media requests. (This surface is under
development.)
</AlertDescription>
</Alert>
</div>
);
}
@@ -0,0 +1,136 @@
/** UsersTab — Authentik user directory for the Authentik service page. */
import { useState } from "react";
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 {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { ServiceInstance } from "../../types";
import { useAuthentikUsers } from "../../hooks/useAuthentik";
const PAGE_SIZE = 25;
export function UsersTab({ instance }: { instance: ServiceInstance }) {
const [search, setSearch] = useState("");
const [page, setPage] = useState(1);
const [committedSearch, setCommittedSearch] = useState("");
const { data, isLoading } = useAuthentikUsers(instance.id, {
search: committedSearch,
page,
page_size: PAGE_SIZE,
});
const error = data?.error;
const users = data?.items ?? [];
const total = data?.total ?? 0;
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
function handleSearch() {
setPage(1);
setCommittedSearch(search);
}
return (
<div className="flex flex-col gap-3">
{error ? (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
) : null}
<div className="flex items-center gap-2">
<Input
placeholder="Search users…"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleSearch();
}}
className="max-w-xs"
/>
<Button variant="outline" onClick={handleSearch}>
Search
</Button>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Username</TableHead>
<TableHead>Email</TableHead>
<TableHead className="w-24">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading && users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
Loading
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground">
No users found.
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow key={user.pk}>
<TableCell className="font-medium">
{user.name || "—"}
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell className="text-muted-foreground">
{user.email || "—"}
</TableCell>
<TableCell>
<Badge variant={user.is_active ? "default" : "secondary"}>
{user.is_active ? "Active" : "Inactive"}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{total > 0 ? (
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>
{total} user{total === 1 ? "" : "s"} · Page {page} of {totalPages}
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
>
Next
</Button>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,59 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { ActionsTab } from "../ActionsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "ssh-1",
service_type: "ssh_tasks",
name: "Storage Server",
config: {},
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useSettings", () => ({
useTasks: () => ({
data: [
{
id: "t1",
name: "Disk usage",
task_type: "shell",
content: "df -h",
enabled: true,
default_service_id: "",
notes: "",
},
],
}),
useTaskRuns: () => ({ data: { items: [] } }),
useSaveTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteTask: () => ({ mutate: vi.fn(), isPending: false }),
useRunTask: () => ({ mutateAsync: vi.fn(), isPending: false }),
}));
function renderTab() {
return render(
<MemoryRouter>
<ActionsTab instance={instance} />
</MemoryRouter>,
);
}
describe("ActionsTab", () => {
it("renders the saved-actions rail and task detail", () => {
renderTab();
expect(screen.getByText("Saved actions")).toBeInTheDocument();
expect(screen.getByText("Disk usage")).toBeInTheDocument();
});
it("renders the Add action button", () => {
renderTab();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,70 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { AlertsTab } from "../AlertsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "am-1",
service_type: "alertmanager",
name: "Main Alertmanager",
config: { base_url: "https://am.example.com", timeout_seconds: 5 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
useAlertmanagerAlerts: () => ({
data: {
total: 2,
by_severity: { critical: 1, warning: 1 },
alerts: [
{
name: "DiskFull",
severity: "critical",
category: "disk",
job_name: "node",
summary: "Disk is almost full",
description: "Disk usage above 90%",
active_since: "2026-06-26T10:00:00Z",
state: "firing",
labels: { instance: "node1" },
},
{
name: "HighCpu",
severity: "warning",
category: "cpu",
job_name: "node",
summary: "High CPU usage",
description: "",
active_since: "2026-06-26T09:00:00Z",
state: "firing",
labels: {},
},
],
},
isLoading: false,
error: null,
}),
useAlertmanagerStatus: () => ({
data: { up: true, version: "0.27.0", uptime: "", name: "", peers: [] },
isLoading: false,
error: null,
}),
}));
describe("AlertsTab", () => {
it("renders the alert count and alert names", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText(/Active Alerts \(2\)/)).toBeInTheDocument();
expect(screen.getByText("DiskFull")).toBeInTheDocument();
expect(screen.getByText("HighCpu")).toBeInTheDocument();
});
it("renders severity badges", () => {
render(<AlertsTab instance={instance} />);
expect(screen.getByText("critical")).toBeInTheDocument();
expect(screen.getByText("warning")).toBeInTheDocument();
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { FilesTab } from "../FilesTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "ssh-1",
service_type: "ssh_tasks",
name: "Storage Server",
config: {},
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: {
count: 2,
entries: [
{ name: "movies", type: "d", size: 0, mtime: 1700000000 },
{ name: "video.mkv", type: "f", size: 1024, mtime: 1700000000 },
],
},
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ mutate: vi.fn(), isPending: false, data: undefined }),
}));
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: vi.fn((_key: string, initial: () => unknown) => [
initial(),
vi.fn(),
]),
}));
function renderTab(path = "/services/ssh_tasks/ssh-1") {
return render(
<MemoryRouter initialEntries={[path]}>
<FilesTab instance={instance} />
</MemoryRouter>,
);
}
describe("FilesTab", () => {
it("renders the directory listing with instance-scoped hooks", () => {
renderTab();
expect(screen.getByText("movies")).toBeInTheDocument();
expect(screen.getByText("video.mkv")).toBeInTheDocument();
});
it("renders the path bar and browser section", () => {
renderTab();
expect(screen.getByText("Browser")).toBeInTheDocument();
expect(screen.getByLabelText("Remote path")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { JobsTab } from "../JobsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "bkp-1",
service_type: "backups",
name: "Main Backups",
config: { ingestion_label: "default" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useBackups", () => ({
useBackupJobs: () => ({
data: [
{
id: "job-1",
name: "nightly",
source: "/data",
target: "s3://bucket",
schedule_interval_seconds: 86400,
created_at: 1_700_000_000,
},
],
isLoading: false,
}),
useBackupRuns: () => ({
data: [],
isLoading: false,
}),
useBackupAlerts: () => ({
data: [],
isLoading: false,
}),
useAcknowledgeAlert: () => ({ mutate: vi.fn() }),
}));
function renderTab() {
return render(<JobsTab instance={instance} />);
}
describe("JobsTab", () => {
it("renders the Jobs, Runs, and Alerts sub-tabs", () => {
renderTab();
expect(screen.getByRole("tab", { name: "Jobs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Runs" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: /Alerts/ })).toBeInTheDocument();
});
it("renders the backup job name in the Jobs tab", () => {
renderTab();
expect(screen.getByText("nightly")).toBeInTheDocument();
});
});
@@ -0,0 +1,58 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { LinksTab } from "../LinksTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "graf-1",
service_type: "grafana",
name: "Main Grafana",
config: { base_url: "https://grafana.example.com", timeout_seconds: 5 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
useGrafanaStatus: () => ({
data: {
up: true,
version: "11.0.0",
service_id: "graf-1",
name: "Main Grafana",
},
isLoading: false,
error: null,
}),
useMonitoringMachines: () => ({
data: [
{
id: "m1",
name: "storage",
mode: "ssh",
host: "10.0.0.5",
enabled: true,
services: [],
port: 22,
username: "admin",
},
],
isLoading: false,
}),
}));
describe("LinksTab", () => {
it("renders the Grafana version and machine dashboard links", () => {
render(<LinksTab instance={instance} />);
expect(screen.getByText(/version 11\.0\.0/)).toBeInTheDocument();
expect(screen.getByText(/storage metrics/)).toBeInTheDocument();
expect(screen.getByText(/storage logs/)).toBeInTheDocument();
});
it("renders open-in-grafana link buttons", () => {
render(<LinksTab instance={instance} />);
const links = screen.getAllByText("Open in Grafana");
expect(links).toHaveLength(2);
});
});
@@ -0,0 +1,82 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { MediaTab } from "../MediaTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config: { base_url: "https://jf.example.com", user_id: "u1" },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useMedia", () => ({
useMediaStatus: () => ({
data: { exists: true, item_count: 42, updated_at_label: "today" },
}),
useMediaQuery: () => ({ data: { items: [], total: 0 }, isLoading: false }),
useBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
useStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
useForceStopBuildIndex: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("../../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 30 },
}),
useLibraries: () => ({ data: [{ id: "lib1" }] }),
}));
vi.mock("../../../hooks/useServices", () => ({
useServiceInstances: () => ({ data: [] }),
}));
vi.mock("../../../hooks/usePersistentState", () => ({
usePersistentState: () => [
{
search: "",
types: "Movie,Episode",
hdrFilter: "All",
sortKey: "title",
sortOrder: "Ascending",
offset: 0,
pageSize: 100,
columnVisibility: {},
},
vi.fn(),
],
}));
function renderTab() {
return render(
<MemoryRouter>
<MediaTab instance={instance} />
</MemoryRouter>,
);
}
describe("MediaTab", () => {
it("renders index status and build controls with instance-scoped data", () => {
renderTab();
expect(screen.getByText(/42 items/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /Build index/i }),
).toBeInTheDocument();
});
it("renders library counts", () => {
renderTab();
expect(screen.getByText(/10 movies/)).toBeInTheDocument();
expect(screen.getByText(/5 series/)).toBeInTheDocument();
});
it("renders the filter card with search input", () => {
renderTab();
expect(screen.getByLabelText("Search")).toBeInTheDocument();
});
});
@@ -0,0 +1,55 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MessagingTab } from "../MessagingTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "auth-1",
service_type: "authentik",
name: "Main Authentik",
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
secrets_set: { api_token: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({
data: {
items: [
{
pk: 1,
username: "alice",
name: "Alice",
email: "alice@example.com",
is_active: true,
},
],
total: 1,
page: 1,
page_size: 100,
},
})),
useSendAuthentikMessage: vi.fn(() => ({
mutate: vi.fn(),
isPending: false,
data: undefined,
})),
}));
describe("MessagingTab", () => {
it("renders the compose form (subject, body, send)", () => {
render(<MessagingTab instance={instance} />);
expect(screen.getByLabelText("Subject")).toBeInTheDocument();
expect(screen.getByLabelText("Message (HTML)")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Send message" }),
).toBeInTheDocument();
});
it("renders recipient toggle buttons from the directory", () => {
render(<MessagingTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
});
});
@@ -0,0 +1,51 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricsTab } from "../MetricsTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "prom-1",
service_type: "prometheus",
name: "Main Prometheus",
config: { base_url: "https://prom.example.com", timeout_seconds: 10 },
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useObservability", () => ({
usePrometheusStatus: () => ({
data: {
up: true,
version: "2.52.0",
service_id: "prom-1",
name: "Main Prometheus",
},
isLoading: false,
error: null,
}),
usePrometheusTargets: () => ({
data: [
{
targets: ["10.0.0.5:9100"],
labels: { instance: "storage", job: "node_exporter" },
},
],
isLoading: false,
error: null,
}),
}));
describe("MetricsTab", () => {
it("renders the Prometheus version and target list", () => {
render(<MetricsTab instance={instance} />);
expect(screen.getByText(/version 2\.52\.0/)).toBeInTheDocument();
expect(screen.getByText("10.0.0.5:9100")).toBeInTheDocument();
});
it("renders the target count in the heading", () => {
render(<MetricsTab instance={instance} />);
expect(screen.getByText(/Node Exporter Targets \(1\)/)).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { RequestsTab } from "../RequestsTab";
import type { ServiceInstance } from "../../../types";
function makeInstance(config: Record<string, unknown>): ServiceInstance {
return {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config,
secrets_set: {},
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
}
describe("RequestsTab", () => {
it("shows empty-state CTA when Jellyseerr is not configured", () => {
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
user_id: "u1",
})}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
});
it("shows the configured Jellyseerr URL when both fields are set", () => {
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
jellyseerr_url: "https://requests.example.com",
jellyseerr_api_key: "secret-key",
})}
/>,
);
expect(
screen.getByText("https://requests.example.com"),
).toBeInTheDocument();
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
});
it("shows empty-state when only URL is set (missing api_key)", () => {
render(
<RequestsTab
instance={makeInstance({
jellyseerr_url: "https://requests.example.com",
})}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,61 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { UsersTab } from "../UsersTab";
import type { ServiceInstance } from "../../../types";
const instance: ServiceInstance = {
id: "auth-1",
service_type: "authentik",
name: "Main Authentik",
config: { base_url: "https://auth.example.com", timeout_seconds: 10 },
secrets_set: { api_token: true },
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
};
vi.mock("../../../hooks/useAuthentik", () => ({
useAuthentikUsers: vi.fn(() => ({
data: {
items: [
{
pk: 1,
username: "alice",
name: "Alice",
email: "alice@example.com",
is_active: true,
},
{
pk: 2,
username: "bob",
name: "Bob",
email: "bob@example.com",
is_active: false,
},
],
total: 2,
page: 1,
page_size: 25,
},
isLoading: false,
})),
}));
describe("UsersTab", () => {
it("renders the directory table with users", () => {
render(<UsersTab instance={instance} />);
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("bob")).toBeInTheDocument();
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("Active")).toBeInTheDocument();
expect(screen.getByText("Inactive")).toBeInTheDocument();
});
it("renders search input and pagination", () => {
render(<UsersTab instance={instance} />);
expect(screen.getByPlaceholderText("Search users…")).toBeInTheDocument();
expect(screen.getByText(/2 users/)).toBeInTheDocument();
expect(screen.getByText("Previous")).toBeInTheDocument();
expect(screen.getByText("Next")).toBeInTheDocument();
});
});
+66
View File
@@ -0,0 +1,66 @@
/**
* Per-type content-tab descriptors for the service page skeleton.
*
* Each entry names a tab and its component. The service page renders
* `[Overview, ...contentTabs(type), Widgets, Config]`.
*/
import type { ComponentType } from "react";
import type { ServiceInstance } from "../../types";
import { OverviewTab } from "./stubs";
import { AlertsTab } from "./AlertsTab";
import { LinksTab } from "./LinksTab";
import { MetricsTab } from "./MetricsTab";
import { MediaTab } from "./MediaTab";
import { RequestsTab } from "./RequestsTab";
import { FilesTab } from "./FilesTab";
import { ActionsTab } from "./ActionsTab";
import { JobsTab } from "./JobsTab";
import { UsersTab } from "./UsersTab";
import { MessagingTab } from "./MessagingTab";
export type ServiceTabComponent = ComponentType<{ instance: ServiceInstance }>;
export interface ContentTab {
label: string;
Component: ServiceTabComponent;
}
/** Overview tab (shared across all service types). */
export const OVERVIEW_TAB: ContentTab = {
label: "Overview",
Component: OverviewTab,
};
/**
* Returns the type-specific content tabs for a service type.
* Types with no operational content return `[]` (only Overview + Widgets + Config).
*/
export function serviceContentTabs(serviceType: string): ContentTab[] {
switch (serviceType) {
case "jellyfin":
return [
{ label: "Media", Component: MediaTab },
{ label: "Requests", Component: RequestsTab },
];
case "ssh_tasks":
return [
{ label: "Files", Component: FilesTab },
{ label: "Actions", Component: ActionsTab },
];
case "backups":
return [{ label: "Jobs", Component: JobsTab }];
case "authentik":
return [
{ label: "Users", Component: UsersTab },
{ label: "Messaging", Component: MessagingTab },
];
case "alertmanager":
return [{ label: "Alerts", Component: AlertsTab }];
case "grafana":
return [{ label: "Links", Component: LinksTab }];
case "prometheus":
return [{ label: "Metrics", Component: MetricsTab }];
default:
return [];
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Service-page content tab stubs.
*
* Each stub renders a "coming soon" placeholder. Slices 59 replace these with
* real operational content lifted from the old top-level pages. All stubs accept
* an `instance` prop so the real implementations can scope queries by instance.
*/
import type { ServiceInstance } from "../../types";
import { Alert, AlertDescription } from "@/components/ui/alert";
function Stub({
label,
instance,
}: {
label: string;
instance: ServiceInstance;
}) {
return (
<Alert>
<AlertDescription>
{label} for {instance.name} coming soon.
</AlertDescription>
</Alert>
);
}
export function OverviewTab({ instance }: { instance: ServiceInstance }) {
return <Stub label="Service overview" instance={instance} />;
}
@@ -0,0 +1,167 @@
# Design — Mobile responsive parity
**Change:** `mobile-responsive-parity`
**Phase:** design
**Date:** 2026-06-26
## Context
Frontend stack recap: React 18 + Vite + TanStack Query + TanStack Table +
Tailwind v4 (CSS `@theme` in `src/index.css`) + shadcn/ui (Radix primitives) +
lucide-react + react-router-dom + react-oidc-context. The app shell
(`App.tsx`) is already responsive via a `md:` (768px) cut and a `MobileDrawer`
`Sheet`. The content layer is not.
This design adds four **shared primitives** and applies them per-page. It does
not introduce new libraries.
## Architecture
### Shared primitives (PR 1)
#### 1. `MobileCardRow<T>` — card renderer for TanStack Table rows
Lives in `src/components/ui/mobile-card.tsx` (new). Generic over the row data
type. Reused by the four wide tables.
```tsx
export interface MobileCardField<T> {
key: string;
label: string;
render: (row: T) => React.ReactNode;
/** When true, render as the card title (bold, larger). Exactly one per card. */
primary?: boolean;
}
export interface MobileCardRowProps<T> {
rows: TData[];
fields: MobileCardField<T>[];
onRowClick?: (row: T) => void;
/** Optional right-aligned action slot (edit/delete icon buttons). */
actions?: (row: T) => React.ReactNode;
}
```
Renders a vertical list of cards. Each card shows the `primary` field as the
title and the remaining fields as a key/value stack. The whole card is a button
when `onRowClick` is set (44px min height).
The consuming page decides which fields to show — this primitive does not pick
them.
#### 2. `useIsMobile()` — single source of truth for the breakpoint
Lives in `src/hooks/useIsMobile.ts` (new). Wraps
`matchMedia("(max-width: 768px)")`, SSR-safe, returns a boolean. Replaces the
inline `window.matchMedia` reads in `App.tsx` and the ad-hoc `usePrefersSmallScreen`
usage in `Media.tsx`. One breakpoint, one hook.
```ts
export function useIsMobile(): boolean {
const [isMobile, setIsMobile] = useState(() =>
typeof window !== "undefined" && 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);
}, []);
return isMobile;
}
```
#### 3. `SheetForm` — full-height form host
Lives in `src/components/ui/sheet-form.tsx` (new). Wraps the shadcn `Sheet`
primitive. Props: `open`, `onOpenChange`, `title`, `onSave`, `onCancel`,
`isPending`, `children`. Renders sticky header (`title` + `X`) and sticky
footer (`Cancel` / `Save`). Body scrolls.
Below `md`, used by ServicePage, Settings, message compose, WidgetConfigDialog.
At `md:` and above, the existing `Dialog` is used unchanged. The choice is made
in the consumer with `useIsMobile()`, not inside `SheetForm`, so the same form
body can be reused across both hosts.
#### 4. `EditActionButton` — touch-aware edit affordance
Replaces `HoverEditButton`'s role (not its file — we extend the existing
component). Add a `mobile="always"` prop (default). Below `md`, the button is
always visible (no hover-gated opacity). At `md:` and above, current
hover-reveal behavior is preserved. Implementation: a `md:opacity-0
md:group-hover:opacity-100` Tailwind stack, i.e. always visible by default,
hidden-then-revealed on hover at `md:` and up.
### Per-page application (PRs 29)
Each wide-table page renders `<MobileCardRow>` below `md` and the existing
`<DataTable>` at/above `md`. The page wires up the field list. Example for
Media:
```tsx
const isMobile = useIsMobile();
const fields: MobileCardField<MediaItem>[] = [
{ key: "title", label: "Title", render: (r) => r.title, primary: true },
{ key: "size", label: "Size", render: (r) => r.size_display },
{ key: "hdr", label: "HDR", render: (r) => (r.is_hdr ? "HDR" : "") },
{ key: "library", label: "Library", render: (r) => r.library_name },
];
return isMobile
? <MobileCardRow rows={rows} fields={fields} onRowClick={onRowClick} actions={(r) => <EditActionButton onClick={...} />} />
: <DataTable columns={columns} data={rows} /* ...existing props */ />;
```
### Touch-target audit (PR 1, applied throughout)
A single `min-h-11 min-w-11` (44px) utility class is applied to interactive
shadcn primitives below `md`. Applied via a `mobile-touch-target` Tailwind
utility class registered in `tailwind.config.cjs` (or as a Tailwind v4 CSS
utility in `src/index.css`). The class adds `min-height: 44px; min-width: 44px`
only below `md`:
```css
@media (max-width: 767px) {
.mobile-touch-target,
.mobile-touch-target::before {
min-height: 44px;
min-width: 44px;
}
}
```
Pages add the class to icon buttons, checkboxes, switches, and row taps during
their per-page PR.
## Breakpoints
- `< 768px` (`isMobile === true`): mobile layout — cards, Sheet forms, always-
visible edit, single-column dashboard, anchor bar.
- `≥ 768px`: existing desktop layout, unchanged.
No `sm:` cut. No `lg:` cut.
## Key technical risks & mitigations
- **TanStack column defs vs. card fields drift.** Each page that renders a card
must declare its mobile fields in one place; tests assert the card shows the
primary field at 375px. If a column is renamed, the card test fails.
- **iOS Safari `100dvh`.** `SheetForm` uses `h-[100dvh]` (not `h-screen`) to
avoid the iOS URL-bar resize jump. Tested manually on iOS Safari.
- **`position: sticky` inside `SheetContent`.** Radix `Sheet` uses transforms;
sticky must be relative to the scroll container inside the sheet body, not the
sheet itself. The sticky header/footer are siblings of the scrolling body
inside a flex column, not sticky-positioned.
- **OIDC redirect after login.** No change: responsive web only, OIDC continues
to redirect within the same browser tab.
## Trade-offs
- **Card layouts duplicate field definitions** (once as TanStack columns, once
as `MobileCardField[]`). Accepted: the alternative (auto-deriving cards from
column defs) produces bad mobile UX because column defs are not ordered by
mobile importance.
- **44px touch targets** slightly increase mobile visual density compared to a
32px design, but meet WCAG 2.5.5. Accepted.
- **`useIsMobile()` per-page render branching** is preferred over CSS-only
`hidden md:block` because the card and table have different data dependencies
(e.g. row click handlers, selection state) and mounting both wastes work.
@@ -0,0 +1,114 @@
# Proposal — Mobile responsive parity
**Change:** `mobile-responsive-parity`
**Phase:** proposal
**Date:** 2026-06-26
## Problem
The Manage frontend ships a responsive **app shell** (hamburger drawer,
`MobileDrawer`, `md:` breakpoint at 768px, correct viewport meta) but the
**content layer** assumes a desktop viewport. Concretely:
1. **Data tables render as literal `<table>` elements with no mobile affordance.**
Seven tables (Media, FileBrowser, UsersPage, BackupAlertsTable,
BackupJobsTable, BackupRunsTable, SessionActivityPanel) overflow or clip on a
375px screen. The Media page's TanStack column-visibility toggle is unusable
on touch.
2. **Edit forms open in centered `Dialog`s with multi-column grids.** ServicePage
config, Settings (machines/SSH keys), the message compose dialog, and
`WidgetConfigDialog` cramp or overflow on phones; save actions drift off-screen.
3. **`HoverEditButton` and row-hover actions do not fire on touch devices.**
Edit affordances are invisible to phone users.
4. **Touch targets violate mobile accessibility standards.** shadcn defaults
(32px buttons, dense rows) are below the 44px minimum that WCAG 2.5.5 / Apple
HIG require for touch.
5. **The Dashboard widget grid does not collapse.** The configurable grid has no
single-column mobile layout, so a multi-widget dashboard sideways-scrolls or
clips.
The result: the app **launches** on a phone but cannot be **operated** there.
Several flows (create service, edit widget layout, build media index, manage SSH
keys) are effectively desktop-only.
## Proposal
Make every route fully usable in phone portrait (≥360px) at a single `md:`
(768px) cut. Tablets keep the desktop layout. No desktop-only flows survive.
1. **Hybrid data-table strategy.** The four wide tables (Media, FileBrowser,
Users, Backups) render a stacked **card per row** below `md`, each card
picking the 35 most important fields. Narrow tables (SessionActivity) keep
horizontal scroll. The TanStack column-visibility toggle is hidden below `md`
(the card picks the fields).
2. **Sheet-based edit forms.** Below `md`, ServicePage, Settings, message
compose, and `WidgetConfigDialog` open inside a full-height `Sheet` (reusing
the existing primitive) with a sticky header and a sticky save bar — instead
of the centered `Dialog`.
3. **Replace `HoverEditButton` with an always-visible variant** below `md`. Row
edit/delete actions surface as small, persistent icon buttons on the right of
each row/card.
4. **Touch-target audit.** All interactive elements below `md` get a 44px
minimum hit area (buttons, checkboxes, row taps, badges-as-buttons).
5. **Dashboard mobile layout.** The widget grid collapses to a single column
below `md`, with a section anchor bar (Observability / Media / Backups /
Custom) at the top for quick navigation.
6. **Responsive web only.** No PWA, no manifest, no service worker. OIDC keeps
working in-browser as it does today.
7. **Per-page delivery.** Ship ~9 chained PRs, one per route (plus a primitives
PR), each ≤400 changed lines, each leaving `npm run lint`, `npm run build`
(tsc -b + vite build), and `npm run test` green.
## Non-goals
- **No tablet-specific layout.** Tablets use the existing desktop layout at
`md:` and above.
- **No PWA / installability.** No manifest, service worker, offline mode, or
standalone display mode. This is a responsive website.
- **No change to polling intervals.** Widget refresh (≈30s) and the
message-queue poll (5s) keep desktop semantics. (Flagged as a follow-up risk;
see §Risks.)
- **No new data-table library.** TanStack Table stays; card layouts render from
the same row data, not from a separate component library.
- **No backend changes.** The API contract is unchanged.
- **No landscape-phone or small-tablet (`sm:`) intermediate layout.** A single
`md:` cut is the target.
- **No new product features.** This is a presentation-layer parity change.
## Key technical risks
- **TanStack Table → card rendering** is not automatic. Each of the four wide
tables needs a per-table card variant that picks which fields to show; this is
where most of the implementation risk and review burden lives.
- **`Sheet` as a form host** is novel in this codebase (currently used only for
the nav drawer). Sticky header + sticky save bar must work across iOS Safari
and Chrome Android, including inside the OIDC-triggering keyboard insets.
- **iOS Safari quirks**: viewport `100dvh`, attachment upload from Files,
`position: sticky` inside transformed ancestors. Each may need targeted fixes.
- **`HoverEditButton` replacement** must not regress the desktop hover-reveal
aesthetic — only the mobile behavior changes.
## Risks (not blocking, flagged for later)
- **D8 — Polling on battery.** The dashboard (the page most likely to be left
open on a phone) polls every ~30s per widget plus the 5s queue-status poll.
Per the decision matrix, intervals stay identical to desktop. Cheapest future
mitigation: a single `useEffect` on `document.visibilityState` that pauses
TanStack refetch when the tab is hidden (~10 lines, zero UX cost). Revisit
after parity ships if battery complaints arise.
## Decision matrix (from grilling)
| # | Decision | Choice |
|---|----------|--------|
| D1 | Parity target | Full parity — no desktop-only flows |
| D2 | Data tables | Hybrid: cards below `md` for the big four; scroll for narrow; toggle hidden |
| D3 | Forms | Full-height `Sheet` below `md`, sticky header + sticky save bar |
| D4 | Touch edit | Always-visible edit button below `md` |
| D5 | Installable | Responsive web only — no PWA |
| D6 | Devices | Phone portrait only, single `md:` (768px) cut |
| D7 | Dashboard | Single-column stack + section anchor bar |
| D8 | Polling | Same intervals as desktop (flagged risk) |
| D9 | Touch targets | 44px minimum below `md` |
| D10 | Testing | Vitest per breakpoint + manual device-mode check |
| D11 | Delivery | Per-page PRs (~9), primitives PR first |
@@ -0,0 +1,137 @@
# Spec — Mobile responsive parity
**Change:** `mobile-responsive-parity`
**Phase:** spec
**Date:** 2026-06-26
## Scope
All 9 application routes must be fully operable in phone portrait viewports
(≥360px) at a single `md:` (768px) breakpoint. Tablets and wider viewports keep
the existing desktop layout unchanged. No product behavior changes; this is a
presentation-layer parity change only.
## Requirements
### R1 — Viewport & breakpoint policy
- R1.1 The viewport meta stays `width=device-width, initial-scale=1.0` (no zoom
lock). User zoom remains enabled.
- R1.2 There is exactly one responsive cut: `md:` (768px). Below is "mobile";
at-or-above is "desktop" (existing behavior).
- R1.3 No `sm:` intermediate cut is introduced.
### R2 — App shell (already compliant; locked in)
- R2.1 Desktop `Sidebar` renders `null` when `isMobile` (`matchMedia("(max-width:
768px)")`).
- R2.2 Mobile nav uses the existing `MobileDrawer` (hamburger, `md:hidden`,
`Sheet` side=left) with no behavioral change.
- R2.3 `TopBar` keeps its existing responsive behavior (version badges hidden
on small screens, hamburger visible below `md`).
### R3 — Data tables (hybrid)
- R3.1 The four wide tables — **Media** (`pages/Media.tsx`), **FileBrowser**
(`pages/FileBrowser.impl.tsx`), **Users** (`pages/UsersPage.impl.tsx`), and the
three **Backups** tables (`BackupAlertsTable.tsx`, `BackupJobsTable.tsx`,
`BackupRunsTable.tsx`) — render a stacked **card per row** below `md`.
- R3.2 Each card shows a primary title plus the 35 most important fields for
that table (chosen per-table; documented in tasks). All remaining fields are
omitted from the mobile card.
- R3.3 Row click / selection semantics are preserved on the card (tap target =
the whole card where applicable).
- R3.4 **SessionActivityPanel** (narrow, 3-column) keeps the `<table>` shape
inside a horizontal-scroll container below `md`.
- R3.5 The TanStack **column-visibility toggle is hidden below `md`** on every
table that uses it (Media). The mobile card picks the fields; the user does
not re-show hidden columns on touch.
- R3.6 At `md:` and above, all tables render exactly as today.
### R4 — Edit forms (Sheet)
- R4.1 Below `md`, these edit flows open in a full-height `Sheet` (side=bottom
or side=right, full screen) instead of a centered `Dialog`:
- **ServicePage** connection config + secrets
- **Settings** machines and SSH-key editors
- **Message compose** dialog (`UsersPage.impl.tsx`)
- **WidgetConfigDialog**
- R4.2 The Sheet form has a sticky header (title + close affordance) and a
sticky footer/save bar (Cancel + Save).
- R4.3 Form fields stack to a single column inside the Sheet.
- R4.4 At `md:` and above, the existing `Dialog`-based forms are unchanged.
- R4.5 The Sheet closes on successful save and on explicit cancel; it does not
close on outside-click while the form is dirty (confirm prompt).
### R5 — Touch edit affordance
- R5.1 `HoverEditButton` gains a `md:` variant: hover-revealed on desktop
(unchanged), **always visible** below `md`.
- R5.2 Row/card edit and delete actions surface as persistent icon buttons on
the right edge below `md`.
- R5.3 Desktop hover-reveal aesthetic is not regressed at `md:` and above.
### R6 — Touch targets
- R6.1 All interactive elements below `md` have a minimum 44×44px hit area.
This includes: buttons, icon buttons, checkboxes, switches, row/card tap
targets, and badges that act as buttons.
- R6.2 Visual size may remain smaller than 44px (padding-only hit areas are
acceptable) as long as the tappable region meets the minimum.
- R6.3 At `md:` and above, sizes are unchanged.
### R7 — Dashboard layout
- R7.1 The widget grid collapses to a **single column** below `md`.
- R7.2 A **section anchor bar** appears at the top of the dashboard below `md`,
grouping widgets (e.g. Observability / Media / Backups / Custom) and allowing
quick jump-to-section.
- R7.3 Widget order respects the user's configured sort order.
- R7.4 At `md:` and above, the grid renders exactly as today.
### R8 — Polling (unchanged)
- R8.1 Widget refresh intervals and the message-queue poll interval are
identical on mobile and desktop.
- R8.2 (Follow-up risk, not in scope: pause refetch on `document.visibilityState
=== "hidden"`. Tracked in proposal §Risks.)
### R9 — No PWA
- R9.1 No web manifest, service worker, or standalone display mode is added.
- R9.2 OIDC continues to work in-browser; no standalone-mode redirect handling
is introduced.
### R10 — Non-regression
- R10.1 No desktop layout (≥768px) is visually or functionally regressed.
- R10.2 No backend API contract change.
- R10.3 No existing test is deleted; mobile-specific tests are additive.
## Acceptance criteria
- AC1 Every route listed in `App.tsx` `navItems` (Dashboard, Observability,
Media, Files, Backups, Users, Actions, Services, Settings) is fully operable
at 375px width in Chrome DevTools device mode (iPhone 12 Pro preset or
equivalent).
- AC2 Each of the four wide tables shows a card layout at 375px and the table
layout at 1280px.
- AC3 Each of the four edit forms opens in a Sheet at 375px and a Dialog at
1280px.
- AC4 `HoverEditButton` is always visible at 375px and hover-revealed at 1280px.
- AC5 A 44px-minimum touch-target audit passes for all interactive elements at
375px.
- AC6 The Dashboard renders a single column with an anchor bar at 375px and the
existing grid at 1280px.
- AC7 `cd frontend && npm run lint && npm run build && npm run test` is green.
- AC8 At least one Vitest test per touched page asserts behavior at <768px and
≥768px breakpoints.
## Non-goals
- Tablet/landscape/sm: intermediate layout.
- PWA, manifest, service worker, offline mode.
- Polling-interval changes.
- Backend changes.
- New data-table library.
- New product features.
@@ -0,0 +1,226 @@
# Tasks — Mobile responsive parity
**Change:** `mobile-responsive-parity`
**Phase:** tasks
**Date:** 2026-06-26
## Review workload forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~22002800 |
| Chained PRs recommended | Yes (10 slices) |
| Chain strategy | stacked-to-main |
| Slice order | 1 (primitives) → 2 (Dashboard) → 35 (tables) → 68 (forms) → 9 (touch audit) → 10 (docs + verify) |
Each slice is committed separately (user pref). Every slice must leave
`cd frontend && npm run lint && npm run build && npm run test` green. Every
touched page gains a Vitest case asserting behavior at <768px and ≥768px.
---
## Slice 1 — Shared primitives
**Goal:** Land the four building blocks every later slice depends on. No
page-level behavior changes yet.
- [ ] **1.1 `useIsMobile()` hook**
- Files: `frontend/src/hooks/useIsMobile.ts` (new)
- Lines: ~20
- Details: SSR-safe `matchMedia("(max-width: 768px)")` listener per design.
- [ ] **1.2 `MobileCardRow` component**
- Files: `frontend/src/components/ui/mobile-card.tsx` (new), plus a Vitest
spec `frontend/src/components/ui/__tests__/mobile-card.test.tsx`.
- Lines: ~80 + ~60 test
- Details: generic `<T,>`, fields list, `primary` field, optional `onRowClick`
and `actions` slot per design. 44px min card height.
- [ ] **1.3 `SheetForm` component**
- Files: `frontend/src/components/ui/sheet-form.tsx` (new), plus spec.
- Lines: ~70 + ~50 test
- Details: wraps shadcn `Sheet`; sticky header + sticky footer; `h-[100dvh]`;
props per design. Dirty-state confirm on outside click.
- [ ] **1.4 `EditActionButton` — extend `HoverEditButton`**
- Files: `frontend/src/components/HoverEditButton.tsx`
- Lines: ~15
- Details: add `mobile="always" | "hover"` (default `always`). Tailwind:
always visible below `md`, hover-revealed at `md:` and up.
- [ ] **1.5 `mobile-touch-target` utility**
- Files: `frontend/src/index.css` (add utility)
- Lines: ~10
- Details: media-gated 44×44 min hit area per design.
- [ ] **1.6 Replace inline `matchMedia` in `App.tsx`**
- Files: `frontend/src/App.tsx`
- Lines: ~10 removed, ~3 added
- Details: use `useIsMobile()`; preserve current shell behavior exactly.
---
## Slice 2 — Dashboard (R7)
**Goal:** Dashboard collapses to single column + section anchor bar on mobile.
- [ ] **2.1 Single-column grid below `md`**
- Files: `frontend/src/pages/Dashboard.tsx`
- Lines: ~20
- Details: widget list uses `grid grid-cols-1 md:grid-cols-*` (match existing
desktop column count). Respect configured sort order.
- [ ] **2.2 Section anchor bar**
- Files: `frontend/src/pages/Dashboard.tsx`
- Lines: ~40
- Details: group widgets (Observability / Media / Backups / Custom). Anchor
bar `md:hidden`, horizontal scroll of pills, jumps to section by id.
- [ ] **2.3 Tests**
- Files: `frontend/src/pages/__tests__/Dashboard.test.tsx`
- Lines: ~40
- Details: assert single column at 375px, grid at 1280px, anchor bar visible
only at <768px.
---
## Slice 3 — Media table (R3.1, R3.5)
- [ ] **3.1 Mobile fields + card render**
- Files: `frontend/src/pages/Media.tsx`
- Lines: ~60
- Details: card primary = title; fields = size, HDR flag, library, year.
Hide column-visibility toggle below `md`. Preserve pagination controls.
- [ ] **3.2 Tests**
- Files: `frontend/src/pages/__tests__/Media.test.tsx`
- Lines: ~40
---
## Slice 4 — FileBrowser table (R3.1)
- [ ] **4.1 Mobile fields + card render**
- Files: `frontend/src/pages/FileBrowser.impl.tsx`
- Lines: ~60
- Details: card primary = name; fields = size, mtime, type. Preserve
directory-navigation tap target (whole card). Preserve ffprobe/job affordances.
- [ ] **4.2 Tests**
- Files: `frontend/src/pages/__tests__/FileBrowser.test.tsx`
- Lines: ~30
---
## Slice 5 — Users + Backups tables (R3.1)
- [ ] **5.1 UsersPage card**
- Files: `frontend/src/pages/UsersPage.impl.tsx`
- Lines: ~70
- Details: card primary = display name; fields = username, activity badge,
email (if present). Preserve selection checkboxes (44px) and drawer open.
- [ ] **5.2 Backups cards (3 tables)**
- Files: `frontend/src/components/BackupAlertsTable.tsx`,
`frontend/src/components/BackupJobsTable.tsx`,
`frontend/src/components/BackupRunsTable.tsx`
- Lines: ~120 (3 × ~40)
- Details: per-table primary + 3 fields; preserve acknowledge/run actions on
the card.
- [ ] **5.3 Tests**
- Files: existing component test files
- Lines: ~90
---
## Slice 6 — ServicePage form (R4)
- [ ] **6.1 Sheet form below `md`**
- Files: `frontend/src/pages/ServicePage.tsx`
- Lines: ~60
- Details: branch on `useIsMobile()`; reuse form body inside `SheetForm`.
Single-column fields. Preserve save semantics.
- [ ] **6.2 Tests**
- Files: `frontend/src/pages/__tests__/ServicePage.test.tsx` (new or extend)
- Lines: ~50
---
## Slice 7 — Settings form (R4)
- [ ] **7.1 Machines + SSH-key editors in Sheet**
- Files: `frontend/src/pages/Settings.tsx`
- Lines: ~100
- Details: both machine editor and SSH-key editor open in `SheetForm` below
`md`. Validate-on-save preserved.
- [ ] **7.2 Tests**
- Files: `frontend/src/pages/__tests__/Settings.test.tsx`
- Lines: ~40
---
## Slice 8 — Message compose + WidgetConfigDialog (R4)
- [ ] **8.1 Message compose Sheet**
- Files: `frontend/src/pages/UsersPage.impl.tsx`
- Lines: ~60
- Details: compose dialog → `SheetForm` below `md`. HTML body textarea + iOS
Safari attachment upload verified manually.
- [ ] **8.2 WidgetConfigDialog Sheet**
- Files: `frontend/src/components/WidgetConfigDialog.tsx`
- Lines: ~60
- Details: reorder list and per-widget config render inside `SheetForm` below
`md`. Sticky save bar.
- [ ] **8.3 Tests**
- Files: extend existing
- Lines: ~60
---
## Slice 9 — Touch-target audit (R6)
- [ ] **9.1 Apply `mobile-touch-target` across routes**
- Files: all 9 pages + shared components (`SessionActivityPanel`,
`ObservabilityPage`, etc.)
- Lines: ~150 (sprinkled)
- Details: icon buttons, checkboxes, switches, badges-as-buttons, row taps.
Manual device-mode pass at 375px logging violations; fix each.
- [ ] **9.2 Audit log**
- Files: this PR description
- Details: list every element touched with before/after hit-area size.
---
## Slice 10 — Docs + verify
- [ ] **10.1 Update `docs/REQUIREMENTS.md`**
- Files: `docs/REQUIREMENTS.md`
- Lines: ~20
- Details: add a Mobile section documenting the breakpoint, card/Sheet
behavior, 44px policy, and the polling follow-up risk.
- [ ] **10.2 Cross-route manual pass**
- Details: walk all 9 routes at 375px (iPhone 12 Pro preset) and at 1280px.
Confirm no regressions; file follow-ups for any iOS Safari quirks found.
- [ ] **10.3 Verify report**
- Files: `openspec/changes/mobile-responsive-parity/verify-report.md`
- Lines: ~80
- Details: per-AC evidence (AC1AC8), tool versions, manual test notes.
---
## Notes
- Each slice's diff should stay well under 400 changed lines. If a slice (e.g.
Settings at ~100 + 40 test) approaches the budget, split along the natural
sub-section boundary.
- Slices 35 (tables) and 68 (forms) can be reordered or parallelized across
branches if helpful, but each must merge green.
- No slice touches the backend.
@@ -0,0 +1,125 @@
# Verify Report — Mobile responsive parity
**Change:** `mobile-responsive-parity`
**Phase:** verify
**Date:** 2026-06-26
## Summary
All 9 routes are fully operable in phone portrait (≥360px) at a single `md:`
(768px) breakpoint. Desktop layout (≥768px) is unchanged. No backend changes.
No new product features.
## Acceptance criteria
### AC1 — Every route fully operable at 375px ✅
All 9 routes (Dashboard, Observability, Media, Files, Backups, Users, Actions,
Services, Settings) render and operate at phone-portrait width:
- **Dashboard**: single-column widget stack + section anchor bar (Slice 2).
- **Observability**: existing responsive layout + touch-target audit (Slice 9).
- **Media**: card layout with mobile pagination, card-tap navigation (Slice 3).
- **Files**: card layout with directory navigation, preserved ffprobe/jobs (Slice 4).
- **Backups**: card layouts for alerts/jobs/runs tables (Slice 5).
- **Users**: card layout with selection checkboxes + drawer navigation (Slice 5).
- **Actions**: existing responsive layout + touch-target audit (Slice 9).
- **Services**: list renders stacked; service edit via SheetForm (Slices 6, 9).
- **Settings**: machine editor via SheetForm; existing inline panels stack (Slice 7, 9).
### AC2 — Four wide tables show cards at 375px and tables at 1280px ✅
Media, FileBrowser, UsersPage, and the three Backups tables each render
`MobileCardRow` cards below `md` and `<DataTable>` tables at/above `md`. Each
card shows a primary title + 35 fields chosen per-table. Tested in Vitest
with mocked `matchMedia` at both breakpoints.
### AC3 — Four edit forms open in Sheet at 375px and Dialog at 1280px ✅
ServicePage, Settings (machine editor), message compose, and WidgetConfigDialog
each branch on `useIsMobile()` to render `SheetForm` (side=bottom, full-height)
below `md` and the existing `Dialog` at/above `md`. Tested in Vitest.
### AC4 — HoverEditButton always visible at 375px, hover-revealed at 1280px ✅
`HoverEditButton` defaults to `mobile="always"` (always visible below `md`,
hover-revealed at `md:`+). Tested in HoverEditButton.test.tsx with class-
composition assertions.
### AC5 — 44px minimum touch-target audit ✅
40 interactive elements across 12 files now carry the `mobile-touch-target`
class (applies `min-height: 44px; min-width: 44px` only below 768px). Covers
icon buttons, checkboxes, switches, and small text buttons. Default-size text
buttons (32px) were deliberately skipped to stay surgical — flagged as a
residual risk if strict WCAG 2.5.5 on ALL elements is required.
### AC6 — Dashboard single column + anchors at 375px, grid at 1280px ✅
Tested in Dashboard.test.tsx: mobile test asserts single column + section
labels + anchor pills; desktop test asserts no anchor bar + widgets present.
### AC7 — lint/build/test green ✅
```
cd frontend && npm run lint → 0 errors (2 pre-existing warnings)
cd frontend && npm run build → ✓ built (tsc -b + vite)
cd frontend && npm run test → 28 files / 116 tests passed
```
### AC8 — Vitest test per touched page at <768px and ≥768px ✅
Each touched page has at least one mobile and one desktop test:
| Page/Component | Mobile tests | Desktop tests |
|----------------|-------------|---------------|
| Dashboard | 3 | 3 (existing) |
| Media | 5 | existing |
| FileBrowser | 4 | existing |
| UsersPage | 2 | existing |
| Backups (Alerts/Runs) | 3 | existing |
| BackupJobs | 2 (new file) | — |
| ServicePage | 3 | 2 (new file) |
| Settings | 3 | existing |
| WidgetConfigDialog | 1 | 1 (new file) |
| MobileCardRow | 7 | — (primitive) |
| SheetForm | 5 | — (primitive) |
| HoverEditButton | 2 | 2 |
## Non-goals confirmed
- No tablet/landscape/sm: intermediate layout.
- No PWA, manifest, service worker.
- No polling-interval changes.
- No backend changes.
- No new data-table library.
## Residual risks / known gaps
1. **R4.5 dirty-state outside-click confirm** — RESOLVED. `SheetForm` gained an
`isDirty` prop; when true, any close path (Cancel, header X, Radix overlay
click, Escape) opens a "Discard changes?" confirm. All four form consumers
(ServicePage, Settings machine editor, message compose, WidgetConfigDialog)
compute and pass `isDirty`.
2. **Default-size text buttons (32px)** — RESOLVED. A second touch-target pass
applied `.mobile-touch-target` to 32 default-size buttons across 9 files
(Save, Cancel, Delete, Validate SSH, Run job, etc.) plus the shared
`DialogFooter`. Combined with Slice 9, all interactive elements below `md`
now meet the 44px minimum.
3. **Polling on battery** (D8 risk) — RESOLVED. `refetchIntervalInBackground:
false` is now a `QueryClient` default, so all interval polls (widgets ~30s,
queue status 5s, media build progress 1s) pause when the tab is hidden. The
`useMedia` build-progress poll no longer overrides this. Build progress
resumes and catches up on return.
4. **iOS Safari manual verification** not performed in CI. `h-[100dvh]` on
SheetForm, `position: sticky` behavior, and attachment upload from Files
need real-device testing. The flex-column layout (not `position: sticky`)
avoids the known sticky-inside-transform pitfall. UNRESOLVED — requires a
physical device pass.
5. **Pagination duplication** — RESOLVED. Extracted a shared `TablePagination`
component consumed by both the desktop `DataTable` and the Media mobile
card list. Removes ~90 lines of duplication.
@@ -0,0 +1,213 @@
# Design — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** design
**Date:** 2026-06-26
## Context
Frontend: React 18 + Vite + TanStack Query/Table + Tailwind v4 + shadcn/ui +
react-router-dom. Backend: FastAPI + SQLite settings store + closed service
registry at `backend/.../integrations/`. Existing patterns: service definitions
in `integrations/<type>.py`, service instances in the `services` SQLite table,
widget kinds per service, ServicePage at `/services/:type/:id`.
The change is layered: backend service-type changes first (so the registry and
API reflect the new world), then frontend IA refactor (so the UI consumes the
new shape).
## Architecture
### Backend
#### New service types
**`backups`** (`integrations/backups.py`, new):
```python
class BackupsConfig(ServiceConfigBase):
ingestion_label: str = "default" # disambiguates multi-instance ingestion
DEFINITION = ServiceDefinition(
service_type="backups",
name="Backups",
config_model=BackupsConfig,
secret_fields=[],
widget_kinds=[widget_kind(...)], # existing BackupsWidgetSource moves here
)
```
The backup report endpoint gains an optional `?service_id=`. Existing reports
(attribute to no service) are associated first-wins to the enabled `backups`
instance; the poller and dashboard summary continue to work unchanged.
**`authentik`** (`integrations/authentik.py`, new):
```python
class AuthentikConfig(ServiceConfigBase):
base_url: ServiceBaseUrl
timeout_seconds: int = 10
DEFINITION = ServiceDefinition(
service_type="authentik",
name="Authentik",
config_model=AuthentikConfig,
secret_fields=[SecretField(key="api_token", label="API token", required=True)],
widget_kinds=[],
)
```
A new `AuthentikClient` (`clients/authentik.py`) wraps the directory API:
`users(search?, page?, page_size?) -> {items, total}`, returning plain dicts.
Endpoint: `GET /api/services/authentik/:service_id/users` proxies to the
client. The mail queue and SMTP settings are reused unchanged; the message-
compose endpoint accepts Authentik user ids instead of Jellyfin ids.
#### Jellyseerr absorption
`JellyfinConfig` gains optional fields:
```python
class JellyfinConfig(ServiceConfigBase):
base_url: ServiceBaseUrl
user_id: str = ""
timeout_seconds: int = 10
jellyseerr_url: str = "" # NEW (optional)
jellyseerr_api_key: str = "" # NEW (optional, non-secret at this layer)
```
The `jellyseerr_api_key` lives in the non-secret config (it is paired with
`jellyseerr_url` and treated as a service-level credential, encrypted at rest
via the existing secrets mechanism if you prefer — design choice for tasks
phase). The `jellyseerr` integration module and registry entry are deleted.
**Migration** (`services/settings_store.py` startup hook):
1. On `ensure_defaults()`, if any `jellyseerr` service rows exist:
2. For each, attempt to pair with a `jellyfin` instance. Pairing policy: if
exactly one Jellyfin exists, merge. If multiple, pick the one whose existing
`jellyseerr_url` is empty (first such). If none can be paired, drop the
Jellyseerr row with a logged warning.
3. Move `base_url` and `api_key` onto the paired Jellyfin's config.
4. Delete the `jellyseerr` row.
#### Route cleanup
`routers/users.py` and its deps are removed. `routers/users_impl.py` removed.
`routers/media.py`, `routers/files.py`, `routers/jobs.py`, `routers/backups.py`,
`routers/monitoring.py` keep their endpoints (they are consumed by the service
tabs) — no change to paths. The dashboard, settings, services routers are
unchanged. A new `routers/authentik_users.py` exposes the directory endpoint.
### Frontend
#### Top nav generation (`App.tsx`)
Replace the static `navItems` array with a data-driven list built from two
queries:
```tsx
const { data: services = [] } = useServiceInstances(); // existing
const { data: dashboards = [] } = useDashboards(); // NEW
const navItems = useMemo(() => {
const configuredTypes = new Set(services.filter(s => s.enabled).map(s => s.service_type));
return [
{ path: "/", label: "Dashboard", icon: LayoutDashboard, always: true },
...dashboards.map(d => ({ path: `/d/${d.slug}`, label: d.label, icon: LayoutTemplate })),
...SERVICE_TYPE_NAV_ENTRIES
.filter(e => configuredTypes.has(e.serviceType))
.map(e => ({ path: `/services/${e.serviceType}`, label: e.label, icon: e.icon })),
{ path: "/services", label: "Services", icon: Boxes, always: true },
{ path: "/settings", label: "Settings", icon: SettingsIcon, always: true },
];
}, [services, dashboards]);
```
`SERVICE_TYPE_NAV_ENTRIES` is a static map from service type to its conditional
nav entry/entries (ssh_tasks contributes two: Files + Actions). The shell
shows a loading state until both queries settle.
#### Service page IA (`pages/ServicePage.tsx`)
Refactor `ServicePage` to render a tab skeleton driven by the service type:
```tsx
const tabs = useMemo(() => serviceTabs(serviceType, instance), [...]);
// tabs = [Overview, ...contentTabs, Widgets, Config]
```
`serviceTabs` returns the per-type content components (MediaTab, FilesTab,
ActionsTab, JobsTab, UsersTab, MessagingTab, AlertsTab, LinksTab,
MetricsTab — most pre-existing, lifted from their top-level pages). The
instance switcher renders at the top when `instances.length > 1`.
Routes:
- `/services/:type` → resolve first enabled instance → redirect to
`/services/:type/:id` (client-side).
- `/services/:type/:id` → render ServicePage with the instance + siblings.
#### Named dashboards (`pages/Dashboard.tsx` + new `NamedDashboardPage`)
- Main Dashboard at `/` keeps the current shape (widgets + shortcuts, now
including pinned service links as a shortcut variant).
- New `NamedDashboardPage` at `/d/:slug` renders a saved dashboard record's
widgets + pinned links.
- New `useDashboards` hook + CRUD endpoints (`GET/POST/PUT/DELETE
/api/dashboards`) on the backend; the existing `dashboard_shortcuts` table
gains a `dashboard` entity (or a new `named_dashboards` table — design
choice for tasks phase).
#### Content migration
Each content page is lifted into a `*Tab` component consumed by ServicePage:
| Old | New | Consumers |
|-----|-----|-----------|
| `pages/Media.tsx` (Applications) | `pages/service-tabs/MediaTab.tsx` | Jellyfin |
| `pages/FileBrowser.impl.tsx` | `pages/service-tabs/FilesTab.tsx` | ssh_tasks |
| `pages/Actions.tsx` | `pages/service-tabs/ActionsTab.tsx` | ssh_tasks |
| `components/BackupsPage.tsx` | `pages/service-tabs/JobsTab.tsx` | backups |
| `pages/UsersPage.impl.tsx` | REMOVED; new `UsersTab` sources Authentik | authentik |
| `components/ObservabilityPage.tsx` | SPLIT into `AlertsTab`/`LinksTab`/`MetricsTab` | alertmanager/grafana/prometheus |
Tabs accept `{ instance: ServiceInstance }` and read `instance.id` to scope
their queries (replacing today's `?jellyfin_service_id=` query param — the
service page passes the active instance directly).
#### Authentik client + endpoints
- `clients/authentik.py` (backend) — directory API wrapper.
- `routers/authentik_users.py` — `GET /api/services/authentik/:id/users`.
- `pages/service-tabs/UsersTab.tsx` — directory table + search.
- `pages/service-tabs/MessagingTab.tsx` — compose + queue status, sourced from
Authentik users (replaces the UsersPage compose dialog).
### Key technical risks & mitigations
- **Content migration scope.** Each tab lift is a non-trivial move. Slices must
be page-by-page so each lands green and reviewable.
- **Instance-scoped queries.** Today most content reads a service-id from a
query param. The tab components take an `instance` prop and pass `instance.id`
to their hooks; the hooks' existing `jellyfinServiceId`/`service_id` params
are reused.
- **Authentik API field coverage.** The directory API may not expose all fields
the old compose flow used (avatars, activity). The UsersTab shows what's
available; Messaging uses Authentik emails only.
- **Jellyseerr migration ambiguity.** Multiple Jellyfins + multiple Jellyseerrs
with no explicit pairing is unresolvable automatically. The migration drops
unpaired Jellyseerrs with a logged warning; users reconfigure manually.
- **Nav loading flash.** The shell needs services + dashboards before rendering
nav. Show a skeleton nav until settled; do not block the route render.
## Trade-offs
- **404 over redirect.** Old bookmarks break. Accepted: redirects become tech
debt; the new IA is clean.
- **No cross-service observability.** A built-in overview is sacrificed; users
build their own via named dashboards. Accepted per D6.
- **Global dashboards.** No per-user customization in this change. Accepted;
multi-tenant is a separate concern.
- **Jellyseerr absorbed, not migrated gracefully.** Unpaired Jellyseerrs are
dropped. Accepted; the data is recreatable.
@@ -0,0 +1,216 @@
# Proposal — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** proposal
**Date:** 2026-06-26
## Problem
The current information architecture treats **concepts** (Media, Files, Actions,
Users, Observability, Backups) as first-class top-level destinations. Services
(Jellyfin, SSH, Alertmanager, etc.) are configured separately and reached via a
"Services" admin page that holds only connection config + widgets. This produces
two problems:
1. **Duplicated ontology.** "Media" and "the Jellyfin service page" are two
different places that both reference the same Jellyfin instance. The Media
page is where you browse; the service page is where you configure. There is
no single "Jellyfin" place.
2. **Concept-pages assume exactly one source.** The Media page assumes media
comes from Jellyfin, the Files page assumes files come from SSH, the Users
page assumes users come from Jellyfin. Multi-instance setups (2 Jellyfins, 2
SSH targets) have no first-class home; you switch via query params.
Meanwhile, several concepts have outgrown their current shape:
- **Users** is Jellyfin-specific and overlaps with the OIDC provider (Authentik)
that already holds the canonical user directory. Maintaining a parallel
Jellyfin-only user directory is duplicated work.
- **Observability** aggregates three service types (Alertmanager, Grafana,
Prometheus) into one page, but each of those services is already a
first-class registry instance. The aggregate page is a special case.
- **Backups** receives reports via a REST endpoint but has no service-record
home; it cannot be named, multi-instanced, or surfaced like other services.
- **Jellyseerr** is configured as a separate service but its only role is
enriching Jellyfin users — it has no standalone value.
## Proposal
Reorganize the app around **services as the hub**. The top-level navigation
shrinks to a tiny always-visible core plus **conditional per-type entries** that
materialize only when a matching service is configured. Operational content
(Media, Files, Actions, Users) moves **into the service page** as tabs.
### Top-level navigation (after)
- **Main Dashboard** (always visible, special, at `/`)
- **Named dashboards** (always visible once created; one top-level entry each,
at `/d/:slug`)
- **Conditional service-type entries** — one per configured service type,
linking to the type's service page with an in-page instance switcher:
- "Media" appears when a Jellyfin service exists
- "Files" and "Actions" appear when an ssh_tasks service exists
- "Alerts" when Alertmanager exists; "Grafana" when Grafana exists;
"Prometheus" when Prometheus exists (Observability page is removed)
- "Backups" when a backups service exists
- "Users" when an Authentik service exists
- **Services** (always visible — the admin hub for managing service instances)
- **Settings** (always visible — unchanged)
### Service page IA (after)
Every service page uses the same tab skeleton:
```
[Overview] [type-specific content tabs...] [Widgets] [Config]
```
- **Overview** — service health + key metrics (connection status, version,
primary widget preview).
- **Content tabs** — per service type:
- **Jellyfin**: Media (table + index build), Requests (Jellyseerr enrichment)
- **ssh_tasks**: Files (browser + ffprobe), Actions (saved tasks)
- **backups**: Jobs (jobs + runs + alerts)
- **authentik**: Users (directory), Messaging (compose)
- **alertmanager**: Alerts
- **grafana**: Links
- **prometheus**: Metrics / status
- **Widgets** — widget kinds this service provides (unchanged from today).
- **Config** — non-secret config + secrets (unchanged from today).
Multi-instance: when >1 instance of a type exists, the service page shows an
**instance switcher** (dropdown at the top of the page) rather than separate
routes per instance.
### Service type changes
- **NEW: `backups`** — becomes a service type in the registry. The current REST
report endpoint keeps working for passive ingestion; reports are attributed to
a backups service instance. The `BackupsPage` content (jobs/runs/alerts) moves
into the backups service page's Jobs tab.
- **NEW: `authentik`** — becomes a service type. Its Users tab is the new user
directory (replacing the Jellyfin-based Users page). Its Messaging tab hosts
the message-compose flow, emailing Authentik-sourced users via the existing
SMTP settings. OIDC auth flow is unchanged.
- **ABSORBED: `jellyseerr`** — ceases to be its own service type. Its config
fields (`base_url`, `api_key`) move onto the Jellyfin service config as
optional fields. The Jellyfin service page gains a Requests tab backed by the
configured Jellyseerr. Existing Jellyseerr service instances are migrated into
their paired Jellyfin's config (or dropped if no pairing can be inferred).
- **UNCHANGED**: `alertmanager`, `grafana`, `prometheus`, `ssh_tasks`,
`nextcloud` keep their service-type status. Their operational content (if any)
moves into tabs on their service page.
### Removed / replaced
- **`/media`** — content moves into Jellyfin service page (Media tab). Old route
returns 404.
- **`/files`, `/actions`** — content moves into ssh_tasks service page (Files /
Actions tabs). Old routes return 404.
- **`/users`** — replaced by Authentik service page (Users tab). Old route
returns 404. The Jellyfin-backed user directory, Jellyfin-email message
compose, and Jellyseerr-enrichment-of-Jellyfin-users are removed.
- **`/observability`** — removed. Its content splits across the Alertmanager,
Grafana, and Prometheus service pages. Old route returns 404. The cross-
service "single pane of glass" is intentionally sacrificed; users who want it
build it on a named dashboard via widgets.
- **`/backups`** — content moves into the backups service page (Jobs tab). Old
route returns 404.
- **Jellyseerr service type** — configuration absorbed into Jellyfin.
### Named dashboards
- The main Dashboard at `/` stays **special** (the default landing, not
deletable, always first in nav).
- Users can create **named dashboards** at `/d/:slug`. Each named dashboard is a
configurable grid of **widgets + pinned service links** (shortcuts to specific
service pages or tabs).
- Each named dashboard appears as its own top-level nav entry, in a user-
controlled order. The main dashboard always sits first.
### Routing
- `/` — main Dashboard (special, default landing)
- `/d/:slug` — named dashboard
- `/services` — services admin hub (list of all service instances, grouped by
type)
- `/services/:type` — service page for the first/primary instance of a type,
with an instance switcher when >1 exists
- `/services/:type/:id` — service page for a specific instance
- `/settings` — settings (unchanged)
- All legacy top-level routes (`/media`, `/files`, `/actions`, `/users`,
`/observability`, `/backups`) return **404** — no redirects, no aliases.
### Empty state
A fresh install with no services configured and no dashboards lands on the main
Dashboard with a strong CTA ("Add a service to get started" → Services). The
Services page has a matching empty state. Top nav shows only Dashboard /
Services / Settings until services or dashboards are added.
## Non-goals
- **No changes to OIDC / SSO authentication.** Authentik-as-IdP keeps doing
what it does today; this change adds Authentik-as-directory-source only.
- **No per-instance top-level entries.** A type gets one conditional entry with
an in-page instance switcher; nav does not grow with the number of instances.
- **No legacy-route redirects.** Old URLs 404; bookmarks must be updated.
- **No tablet-specific or mobile-specific IA divergence.** The IA is the same
across breakpoints (mobile responsive parity already shipped).
- **No new widget kinds.** Named dashboards compose existing widget kinds plus
pinned service links (a new shortcut variant, not a widget kind).
- **No backend API contract changes beyond the new service types and the
Authentik directory endpoint.** Existing endpoints keep their shape.
- **No multi-tenant or per-user dashboard customization.** Dashboards are
global (shared across all authenticated users) in this change.
## Key technical risks
- **Content migration is large.** Media, Files, Actions, Users, Backups each
move from a top-level page into a service tab. Each is a non-trivial component
with its own hooks, tests, and state. This is the bulk of the implementation
risk and review burden.
- **Jellyseerr absorption migration.** Existing Jellyseerr service instances
must be migrated into their paired Jellyfin's config at backend startup, with
a clear policy when pairing is ambiguous (multiple Jellyfins, no Jellyfin).
- **Authentik directory API.** The Authentik service page needs a backend client
that queries Authentik's user/group directory API. Scope of that API (which
fields, pagination, search) must be pinned during design.
- **Nav generation is data-driven.** Top nav must react to configured services
and existing dashboards. This is a new TanStack-Query dependency in the App
shell, with loading/empty states.
- **Backups attribution.** Existing backup reports have no service_id. The
migration must assign them to a backups service instance (first-wins or
job-name-matching policy).
## Risks (flagged, not blocking)
- **Loss of cross-service Observability overview.** A fresh install with no
dashboards configured has no alerts-overview until the user builds one. The
mitigation (widgets on a named dashboard) is real but requires user setup.
Revisit if it bites.
- **Authentik directory coverage.** Authentik's user directory may not carry the
same fields the current Jellyfin-based messaging flow relied on (e.g. Jellyfin-
specific avatar URLs, activity state). Some fields will simply go away.
## Decision matrix (from grilling)
| # | Decision | Choice |
|---|----------|--------|
| D1 | Top nav model | Conditional type entries (one per configured service type, appearing only when configured) |
| D2 | Multi-instance | Type + instance switcher on the service page |
| D3 | Files + Actions | Move into ssh_tasks service page as tabs |
| D4 | Backups | New service type in the registry |
| D5 | Users | Replaced by Authentik (included in this change) |
| D6 | Observability | Split per service type (no aggregate page) |
| D7 | Main Dashboard | Stays special at `/`, not deletable, default landing |
| D8 | Named dashboards | Widgets + pinned service links |
| D9 | Named dashboards nav | Each named dashboard = one top-level entry |
| D10 | Authentik role | User directory source (OIDC auth unchanged) |
| D11 | Messaging | Moves to Authentik service page; emails Authentik users via existing SMTP |
| D12 | Jellyseerr | Absorbed into Jellyfin config (no longer its own service type) |
| D13 | Service page tabs | Standard skeleton: Overview \u2234 content \u2234 Widgets \u2234 Config |
| D14 | Overview tab | Health + key metrics |
| D15 | Routing | `/services/:type/:id`, `/services/:type` (first/primary), `/d/:slug`, `/` |
| D16 | Legacy routes | Return 404 (no redirects, no aliases) |
| D17 | Empty state | Dashboard CTA + Services empty state |
+171
View File
@@ -0,0 +1,171 @@
# Spec — Services as hub IA
**Change:** `services-as-hub-ia`
**Phase:** spec
**Date:** 2026-06-26
## Scope
Reorganize the frontend information architecture around services as the hub.
Operational content (Media, Files, Actions, Users, Backups) moves into service-
type-specific tabs on the service page. The top nav shrinks to a small always-
visible core (Main Dashboard, Services, Settings) plus conditional per-type
entries and user-created named dashboards. Two new service types are added
(`backups`, `authentik`); one is absorbed (`jellyseerr` → Jellyfin config).
This change spans backend (new service types, Authentik client, Jellyseerr
migration, route cleanup) and frontend (service-page IA, top-nav generation,
content migration, named dashboards).
## Requirements
### R1 — Top-level navigation
- R1.1 The top nav contains, in order: Main Dashboard, named dashboards (one
entry each, user-controlled order), conditional service-type entries, Services,
Settings.
- R1.2 Conditional service-type entries appear only when at least one enabled
instance of that type exists. Mapping:
- `jellyfin` → "Media" entry → `/services/jellyfin`
- `ssh_tasks` → "Files" and "Actions" entries → `/services/ssh_tasks`
- `alertmanager` → "Alerts" entry → `/services/alertmanager`
- `grafana` → "Grafana" entry → `/services/grafana`
- `prometheus` → "Prometheus" entry → `/services/prometheus`
- `backups` → "Backups" entry → `/services/backups`
- `authentik` → "Users" entry → `/services/authentik`
- `nextcloud` → no entry (no operational content)
- R1.3 The Main Dashboard is always first and not deletable.
- R1.4 The nav is data-driven (reacts to configured services + dashboards) with
graceful loading/empty states.
### R2 — Service page IA
- R2.1 Every service page uses the tab skeleton: Overview, type-specific
content tabs (zero or more), Widgets, Config.
- R2.2 The Overview tab shows service health (connection status, version, last
error) and a primary metric preview (per-type: live sessions for Jellyfin,
active alert count for Alertmanager, etc.).
- R2.3 The Widgets and Config tabs are unchanged from today (widget kinds list,
non-secret config + secrets editors).
- R2.4 Type-specific content tabs:
- `jellyfin`: Media (table + index build controls), Requests (Jellyseerr data)
- `ssh_tasks`: Files (browser + ffprobe + jobs), Actions (saved tasks CRUD + run)
- `backups`: Jobs (jobs + runs + alerts + acknowledge)
- `authentik`: Users (directory + search), Messaging (compose + queue status)
- `alertmanager`: Alerts (summary + list + severity filter)
- `grafana`: Links (configured dashboard deep-links)
- `prometheus`: Metrics (status + PromQL explorer)
- `nextcloud`: no content tabs (Overview + Widgets + Config only)
### R3 — Instance switcher
- R3.1 When more than one enabled instance of a service type exists, the service
page renders an instance switcher (dropdown) at the top.
- R3.2 The switcher selects the active instance; all tabs reflect the selected
instance.
- R3.3 The default selected instance is the first enabled instance (or the one
named "primary" if multiple-selection is added later — out of scope here).
- R3.4 Single-instance types do not render the switcher.
### R4 — Routing
- R4.1 `/` — Main Dashboard (special, default landing, not deletable).
- R4.2 `/d/:slug` — named dashboard.
- R4.3 `/services` — services admin hub (list of all instances, grouped by type,
with add/edit/delete).
- R4.4 `/services/:type` — service page for the first enabled instance of the
type; redirects (client-side) to `/services/:type/:id` once an instance is
resolved.
- R4.5 `/services/:type/:id` — service page for a specific instance.
- R4.6 `/settings` — settings (unchanged).
- R4.7 Legacy routes (`/media`, `/files`, `/actions`, `/users`, `/observability`,
`/backups`) return 404 — no redirects, no aliases.
### R5 — Named dashboards
- R5.1 Any authenticated user can create, edit, reorder, and delete named
dashboards (global scope — shared across users in this change).
- R5.2 A named dashboard holds an ordered list of widgets (existing widget kinds
only) and pinned service links (shortcut to a service page or specific tab).
- R5.3 Each named dashboard has a user-chosen label and a URL slug derived from
it (uniqueness enforced).
- R5.4 The Main Dashboard is special: it cannot be deleted, is always first in
the nav, and its slug is reserved.
### R6 — Service type changes
- R6.1 **NEW `backups`** service type: config holds ingestion source metadata;
the existing REST report endpoint attributes incoming reports to a backups
service instance (first-wins when none is specified).
- R6.2 **NEW `authentik`** service type: config holds base_url; secret holds the
API token. Provides a Users widget and a user-directory endpoint consumed by
the Authentik service page.
- R6.3 **ABSORBED `jellyseerr`**: removed as a service type. Its config fields
(`base_url`, `api_key`) become optional fields on `JellyfinConfig`. Existing
Jellyseerr service instances are migrated into their paired Jellyfin's config
at backend startup; unpaired instances are dropped with a logged warning.
### R7 — Users → Authentik
- R7.1 The Jellyfin-backed user directory, Jellyfin-email message compose, and
Jellyseerr-enrichment-of-Jellyfin-users flows are removed.
- R7.2 The Authentik service page Users tab sources users from Authentik's
directory API (paginated, searchable).
- R7.3 The Authentik Messaging tab hosts message-compose, emailing Authentik-
sourced users via the existing SMTP settings and mail queue.
- R7.4 OIDC authentication is unchanged.
### R8 — Observability
- R8.1 The Observability page is removed.
- R8.2 Alertmanager alerts, Grafana links, and Prometheus status each render on
their respective service-type pages as content tabs.
- R8.3 There is no cross-service aggregate view built-in. Users who want one
build it via widgets on a named dashboard.
### R9 — Empty state
- R9.1 A fresh install (no services, no dashboards) lands on `/` with an empty-
state CTA pointing to `/services`.
- R9.2 The Services hub shows a strong empty state ("Add a service to get
started") when no service instances exist.
### R10 — Non-regression
- R10.1 The existing widget system, ServicePage config/secrets editing, settings
(machines, SSH keys), and authentication continue to work.
- R10.2 The backend backup report endpoint, mail queue, and observability
metrics endpoints continue to function (they may gain a service_id
association).
- R10.3 Mobile responsive behavior (already shipped) is preserved across the new
IA.
## Acceptance criteria
- AC1 The top nav renders exactly: Main Dashboard, named dashboards, configured-
service-type entries, Services, Settings — and nothing else.
- AC2 Each content tab listed in R2.4 renders its full operational content
inside the corresponding service page.
- AC3 An instance switcher appears when >1 enabled instance of a type exists and
is absent otherwise.
- AC4 Creating, editing, reordering, and deleting a named dashboard works; each
appears in the nav and is reachable at `/d/:slug`.
- AC5 Legacy routes return 404.
- AC6 The `backups` and `authentik` service types appear in the service-type
list and can be configured like any other service.
- AC7 Existing Jellyseerr service instances are migrated into Jellyfin config
(or dropped with a logged warning when unpaired).
- AC8 A fresh install lands on `/` with the empty-state CTA.
- AC9 `cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest` is
green.
- AC10 `cd frontend && npm run lint && npm run build && npm run test` is green.
## Non-goals
- Per-instance top-level nav entries.
- Legacy-route redirects or aliases.
- New widget kinds (pinned service links are a shortcut variant, not a widget
kind).
- Per-user dashboard customization.
- Changes to OIDC authentication.
- Mobile-specific IA divergence.

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