Compare commits

...

59 Commits

Author SHA1 Message Date
Developer 5cb5e79032 chore(service-storage-harness): archive verified+synced change
Move to openspec/changes/archive/2026-07-09-service-storage-harness/
(history preserved via rename detection). 9 artifacts: proposal/spec/design/
tasks/apply-progress/verify-report/sync-report/archive-report + delta spec.
Canonical openspec/specs/service-storage/ remains. Native status engine
discrepancy (ambiguous change selection) disregarded per parent verification.
2026-07-09 09:47:05 +00:00
Developer c9201a004c spec(service-storage-harness): sync into canonical service-storage domain
New canonical openspec/specs/service-storage/spec.md (28 reqs SS-101..128).
Change-side delta + sync-report. web-ui + prometheus-charting canonicals
untouched.
2026-07-09 09:37:22 +00:00
Developer 40a7ac80d3 spec(service-storage-harness): verify + reconcile tracking
Write apply-progress.md, tick all 35 tasks, add verify-report.md (28/28
SS-101..128 PASS). Gates green: 322 pytest, ruff clean, FE build+lint 0
errors, PrometheusChartWidget extraction 4/4 non-regressive. No blocking
code findings. Process note: slice 2 was +644 lines over 400-line budget
(additive, no scope creep; retrospectively extract+widgets could split).
2026-07-09 09:30:52 +00:00
Developer c9404f0794 spec(service-storage-harness): add spec (28 requirements SS-101..128)
Covers harness lifecycle, QbittorrentSampleStore, QbittorrentClient,
3 widget kinds (totals=item count, active=DL/UL, speed=LineSeriesChart
reuse), MediaIndex migration (+service_id, scoped replace_items bug fix,
backward-compat query), cascade-delete wiring, test/build greenness.
2026-07-09 09:15:32 +00:00
Developer 75c949ad25 feat(service-storage-harness): slice 4 — cascade-delete wiring + integration test
Wire ServiceDataHarness.cascade_delete into SettingsStore.delete_service
(best-effort try/except, logs on failure). Fix migration runner to also
catch 'no such table' on fresh DBs (ALTER TABLE before init_schema).
Integration test proves end-to-end cascade across both concerns (qBit
samples + media items) with multi-instance preservation.

Backend: 322 pytest pass, ruff clean.
2026-07-09 09:11:42 +00:00
Developer c87f398e37 feat(service-storage-harness): slice 3 — migrate MediaIndex onto harness (scoped replace_items, +service_id)
Register MediaIndex as a harness concern with ALTER TABLE migration to add
service_id column (idempotent). Scope replace_items by service_id (FIXES latent
global-clear bug where building for one Jellyfin wiped another's rows). Scope
query by service_id (empty-string = all rows, backward-compat). Thread
service_id through build_media_index + worker + query_media router. New
regression test proves scoped replace preserves other services' rows.

Backend: 321 pytest pass, ruff clean. Frontend: build green.
2026-07-09 09:00:31 +00:00
Developer 1fb12b8a0a feat(service-storage-harness): slice 2 — qbit widgets + LineSeriesChart extract 2026-07-09 08:49:20 +00:00
Developer e7bd0afdd1 feat(service-storage-harness): slice 1 — harness + qbit store + client + integration
ServiceDataHarness (services/service_data.py): lifecycle-only registry of
per-concern storage — DB provisioning, idempotent migrations (ALTER TABLE
duplicate-column-name caught per-statement), cascade_delete(service_id).
QbittorrentSampleStore: append/window/prune (MAX_SAMPLES=120) in
qbittorrent.db. QbittorrentClient: cookie-login Web API client (403 re-login,
/sync/maindata). Integration registered with 3 widget kinds (totals/active/
speed). Harness initialized in main.py lifespan.

Backend: 314 pytest pass (21 new), ruff clean.
2026-07-09 08:24:08 +00:00
Developer 9a251db23c spec(service-storage-harness): add tasks (4 slices, each <=400 lines)
S1 harness+store+client+integration; S2 widget adapter+3 FE widgets+
LineSeriesChart extract; S3 MediaIndex +service_id migration (fixes latent
global-clear bug); S4 cascade-delete wiring. Each slice leaves pytest/npm
build/npm lint green. Risk flags on LineSeriesChart extract + MediaIndex migration.
2026-07-09 08:02:31 +00:00
Developer cff082c7a1 spec(service-storage-harness): add design
10 design decisions incl: lifecycle-only harness, per-concern DB files,
QbittorrentSampleStore (120-sample cap), qBit cookie client, 3 widget kinds
(totals=item count, active=DL/UL state, speed=reuses Change A renderer via
shared LineSeriesChart extract), MediaIndex +service_id backfill, scoped
replace_items (fixes latent global-clear bug), best-effort cascade-delete.
4-slice plan. 3 source findings (worker already threads service_id).
2026-07-09 07:55:59 +00:00
Developer bc389ae3f7 spec(service-storage-harness): reconcile proposal post Change A + question round
Resolve Q1 (reuse Change A's PrometheusChartWidget renderer via InService
data path), Q2 (totals = item count, not transfer bytes), Q3 (active =
downloading/uploading), Q4 (N instances), Q5 (username/password cookie
auth). Remove TBD/stale thin-dashboard caveats.
2026-07-09 07:45:55 +00:00
Developer 7efc06a629 chore(prometheus-direct-charting): archive verified+synced change
Move to openspec/changes/archive/2026-07-08-prometheus-direct-charting/
(git mv, history preserved). 9 artifacts: proposal/spec/design/tasks/
apply-progress/verify-report/sync-report/archive-report + delta spec.
Canonical openspec/specs/prometheus-charting/ remains.
2026-07-08 23:01:35 +00:00
Developer 3e77075171 spec(prometheus-direct-charting): sync into canonical prometheus-charting domain
New canonical domain openspec/specs/prometheus-charting/spec.md with all
27 requirements (SC-101..127) as the durable post-change contract. Change-side
delta specs/prometheus-charting/spec.md + sync-report.md. web-ui canonical
untouched (different concern).
2026-07-08 22:57:29 +00:00
Developer 7440603cdb spec(prometheus-direct-charting): verify + close SC-125 + reconcile tracking
Add loading-state tests to the three Prometheus widget test files
(closes SC-125 PARTIAL). Write apply-progress.md, tick all 39 tasks,
add verify-report.md (26/27 PASS, 1 PARTIAL->PASS). All gates green:
293 pytest, ruff clean, npm build+lint 0 errors. No blocking findings.
2026-07-08 22:50:30 +00:00
Developer 67ca0fc3bc feat(prometheus-direct-charting): slice 3 — remove grafana + config rewrite + changelog
Remove the entire Grafana surface: integrations/grafana.py, GrafanaWidgetSource
(+ _fetch_chart, now redundant since prometheus chart exists), GrafanaLinkWidget,
LinksTab, get_grafana_status endpoint, useGrafanaStatus hook, GrafanaStatus type,
fetchGrafanaStatus client fn, registry/nav/tab entries (FE+BE). Rewrite
config.yaml thin-dashboard rule to match reality (recharts is sanctioned for
Prometheus-backed series). CHANGELOG migration note added.

Backend: 293 pytest pass, ruff clean. Frontend: build+lint green (0 errors).
SC-115/116 grep-clean (only prometheus_range.py migration comments + Dashboard.test
shortcut fixture remain — both spec-allowed).
2026-07-08 22:35:06 +00:00
Developer 65bae95e3c feat(prometheus-direct-charting): slice 2 — gauge + mean widgets
Add gauge widget (recharts RadialBarChart with configurable threshold
bands, scalar-only per SC-111) and mean widget (client-side average over
range-query window, scalar-only per SC-114). Extract shared _instant_query
helper from the metric path; _fetch_gauge and _fetch_mean dispatch in
PrometheusWidgetSource.fetch(). Both new widget kinds declared in
integrations/prometheus.py and frontend registry.

Backend: 305 pytest pass, ruff clean. Frontend: 136 vitest pass, build+lint green.
2026-07-08 22:10:11 +00:00
Developer 5dad98231f feat(prometheus-direct-charting): slice 1 — prom range query + chart rebrand
Add PrometheusWidgetSource._fetch_chart hitting /api/v1/query_range directly
(SC-101..104). New shared helpers in widgets/prometheus_range.py:
step_for_window (window preset -> step, ~200pts) and normalize_prometheus_matrix
(extracted label/dedup rule, retargeted at Prom matrix, robust to malformed
data). Chart widget kind moved grafana->prometheus in both registries;
GrafanaChartWidget renamed -> PrometheusChartWidget (git mv, recharts body
preserved). Grafana binding/service untouched (removed in slice 3).

Backend: 298 pytest pass, ruff clean. Frontend: 128 vitest pass, build+lint green.
2026-07-08 21:55:02 +00:00
Developer d906b0392b spec(prometheus-direct-charting): add tasks (3 slices, each <=400 lines)
S1 adds Prom chart (range query + rebrand, no grafana removal); S2 adds
gauge+mean; S3 removes grafana + config rewrite + changelog. Each slice
leaves pytest/npm build/npm lint green. Review-workload forecast per slice.
2026-07-08 21:33:16 +00:00
Developer 9b7415080b spec(prometheus-direct-charting): patch SC-116/118 for service-tabs refactor
ObservabilityPage.tsx was refactored into service-tabs/; update removal
criteria to name real targets and whitelist the Dashboard.test fixture
shortcut-label collision.
2026-07-08 21:29:09 +00:00
Developer 78e273efe8 spec(prometheus-direct-charting): add design
5 locked design decisions: step derivation formula (max(15, round(s/200))),
shared normalize_prometheus_matrix helper in widgets/prometheus_range.py,
recharts RadialBarChart gauge w/ threshold bands, mean via client-side
avg over query_range, 3-slice plan each <=400 lines.
Source-verified: ObservabilityPage refactored into service-tabs/ (map stale).
2026-07-08 21:28:02 +00:00
Developer b7e5ca3cbc spec(prometheus-direct-charting): add proposal + spec
Drop Grafana as chart middleman; query Prometheus directly via
/api/v1/query_range. Rebrand GrafanaChartWidget -> PrometheusChartWidget,
add gauge + mean widget kinds, remove Grafana surface, rewrite stale
thin-dashboard rule in config.yaml. 27 acceptance requirements (SC-101..127).
2026-07-08 21:21:14 +00:00
Developer 67c51f9fc0 Fix: user_id validation causes read timeout on slow Jellyfin
The previous fix called client.users() unconditionally to validate the
user_id, adding an extra HTTP round-trip before the build. On a slow
Jellyfin connection this burned through the 10s timeout before the
actual libraries() call.

Restructured to try libraries(user_id) directly first. Only when that
fails does the worker resolve the username via the users API. So:
- Correctly configured user_id (internal hash): zero extra round-trips.
- Username like 'admin': libraries() fails → users() resolves it → retry.

283 backend tests pass; ruff clean.
2026-07-06 17:35:19 +00:00
Developer 7497469d5e Fix: user_id 'admin' rejected by Jellyfin API (400)
The Jellyfin service config's user_id field accepts either the internal
Jellyfin user ID (a long hash) or a username (e.g. 'admin'). The worker
passed the raw value directly to client.libraries(user_id), but Jellyfin's
API rejects usernames with a 400.

Now validates the configured user_id against the Jellyfin users API:
1. If it matches a user's Id (internal hash), use it directly.
2. If it matches a user's Name (username like 'admin'), resolve the Id.
3. If no match, fall back to the first user and log a warning.

283 backend tests pass; ruff clean.
2026-07-06 15:45:51 +00:00
Developer a3888026ab Fix: media worker crashes silently before writing error state
The worker called _resolve_jellyfin() and client.libraries() OUTSIDE the
try/except block. If Jellyfin was unreachable, the worker crashed with
an unhandled exception and NO error state was written to the DB — the
status stayed 'queued' forever with zero feedback.

Moved _resolve_jellyfin + client.libraries INSIDE the try block, and
created final_index + staging_index BEFORE the try so the except handler
can write the error state. Now any failure (connection refused, timeout,
missing config) is written to the DB as build_error and the frontend
shows it inline.

283 backend tests pass; ruff clean.
2026-07-06 15:36:07 +00:00
Developer 787f46700f Fix: media index build never starts (blocking Jellyfin dependency)
The build endpoint had Depends(get_jellyfin_client) and Depends(get_user_id)
which executed BEFORE the function body. If Jellyfin was unreachable, these
raised HTTPException(503), the function never ran, and the worker was never
started. The frontend mutation had no onError handler, so the failure was
completely silent — the button briefly showed 'Building...' then reverted
to 'Build index' with zero feedback.

Backend fix: removed the Jellyfin dependencies from post_build_index.
The worker subprocess resolves its own Jellyfin connection via
_resolve_jellyfin(service_id) — the endpoint just needs to start the
worker process. The libraries count starts at 0 and gets updated by
the worker once it connects.

Frontend fix: added onError to useBuildIndex that invalidates the status
query (so the UI reflects the non-building state). MediaTab now displays
the build error inline: 'Build failed: <message>' next to the button.

283 backend tests pass (updated build test for new no-dependency flow);
128 frontend tests pass; ruff/eslint clean.
2026-07-06 15:25:48 +00:00
Developer 57fe04ae7b Fix: edit button on referenced widgets opened list view instead of edit
The WidgetConfigDialog useEffect that auto-enters edit mode when
editWidgetId is set only searched owned widget instances (from
useWidgetInstances). Referenced widgets (from useWidgetReferences)
were never found, so startEdit never fired and the dialog fell through
to the list view.

Now the effect searches both owned instances and referenced widgets,
so clicking edit on any widget — owned or referenced — opens the edit
form directly.

128 tests pass; 0 lint errors; build clean.
2026-07-06 14:59:24 +00:00
Developer 5a43894875 Fix: sheet scroll, direct-edit close, mobile copy btn, service badge
Four fixes:

1. Mobile edit fullscreen scroll: the Sheet primitive's
   data-[side=bottom]:h-auto was overriding our h-[100dvh] on
   SheetForm, preventing scroll. Added data-[side=bottom]:h-[100dvh]
   to the SheetForm className to win the specificity battle.

2. Direct-edit close showed list view: when opened via editWidgetId
   (the hover edit button), saving or canceling called reset() which
   showed the widget list instead of closing the dialog. Now derives
   directEdit from editWidgetId — when true, reset() calls onClose()
   to close entirely.

3. Copy button missing on mobile: MobileWidgetSections didn't pass
   onCopy to its WidgetInstanceCard instances. Now accepts and wires
   onCopyWidget, so referenced widgets show the copy/detach button on
   mobile too.

4. Service enabled badge stale: ServiceConfigEditor showed
   instance.enabled (the initial prop) instead of the local enabled
   state. Now reads the local enabled variable so the badge updates
   when the user toggles the switch.

128 tests pass; 0 lint errors; build clean.
2026-07-06 14:51:14 +00:00
Developer 04871bd7d4 Fix: dialog scroll, isDirty false positive, mobile edit btn, widget copy
Five fixes:

1. Dialog mobile scroll: DialogContent now has max-h-[calc(100dvh-2rem)]
   overflow-y-auto so dialogs that don't fit on screen can scroll
   instead of clipping their footer (and Cancel button) off-screen.

2. isDirty false positive: WidgetConfigDialog's SheetForm used
   isDirty={draft !== null} which was true the moment you opened edit
   mode, even with no changes. Now stores a draftBaseline at startEdit
   time and compares JSON.stringify(draft) !== JSON.stringify(baseline).
   The discard-confirmation only appears when something actually changed.

3. Mobile edit button always visible: the widget card's edit button was
   opacity-0 group-hover:opacity-100 (hover-only). Changed to
   md:opacity-0 md:group-hover:opacity-100 — always visible below md,
   hover-reveal at md+.

4. Copy button for referenced widgets: WidgetInstanceCard gains an onCopy
   prop. On the Dashboard, referenced widgets get a Copy icon button that
   triggers detachRef (creates an independent clone). The edit button on
   referenced widgets edits the original (shared config).

5. ConfirmDialog Cancel: fixed by #1 (the Cancel button was off-screen
   on mobile dialogs that couldn't scroll).

128 tests pass; lint/build green.
2026-07-06 14:26:58 +00:00
Developer a63467e163 Fix: main dashboard edit showed all widgets (missing scope filter)
WidgetConfigDialog fetched useWidgetInstances(serviceId) with no scope
filter. On the main dashboard (serviceId undefined, dashboardScope='main'),
this returned ALL widget instances including service-scoped ones from
service overviews — so editing the main dashboard showed widgets that
were never added there.

Fix: pass scope='dashboard' when serviceId is empty and dashboardScope
is set. This fetches only dashboard-scoped widgets (service_id IS NULL).
The 'Add existing' picker (allWidgets) stays unscoped so users can still
reference service widgets onto the dashboard.

128 tests pass; lint/build green.
2026-07-06 14:09:22 +00:00
Developer d8c0a37210 Fix: settings master/detail, widget kind filter, reorder, media worker
Four fixes:

1. Settings > Services tab: master/detail layout. Replaced the vertical
   stack of ServiceConfigEditor cards with a SelectionRailCard (list on
   left) + SectionCard (details on right) — same pattern as Machines
   and SSH Keys tabs. Click a service in the rail to edit it.

2. Service Overview widget restriction. When adding widgets on a
   service's Overview, the dialog now only shows built-in widgets +
   widgets for THAT service type (not all services). Dashboard/named
   dashboards (no serviceId) still see all.

3. Reorder buttons fixed. The swap-sort_order approach was a no-op when
   both items had sort_order=0 (the default). Now moveInstance
   renumbers ALL items by their new index position (i * 10) after the
   swap, guaranteeing values change. References use updateRef, owned
   widgets use saveWidget, both sequential.

4. Media index worker resolution. The subprocess worker called
   get_jellyfin_client/get_user_id (FastAPI request dependencies) which
   don't work outside request context. Now accepts a service_id
   parameter (passed from post_build_index) and resolves the Jellyfin
   instance directly from the settings store via _resolve_jellyfin.
   Raises RuntimeError (not HTTPException) on failure.

283 backend tests pass; 128 frontend tests pass; ruff/eslint clean.
2026-07-06 14:03:05 +00:00
Developer eeb0cccbce Add hover-reveal edit button to widget cards + auto-open edit mode
Each widget card now shows a settings icon in the top-right corner on
hover (desktop) or always-visible (mobile via mobile-touch-target).
Clicking it opens the WidgetConfigDialog directly in edit mode for that
widget (via a new editWidgetId prop on WidgetConfigDialog that
auto-enters the draft-edit path via useEffect).

Wired across all three widget surfaces:
- Dashboard (both mobile section + desktop grid)
- Service OverviewTab
- NamedDashboardPage

128 tests pass; build/lint green.
2026-07-06 12:53:14 +00:00
Developer 691d78ff06 Dedup _resolve_service_record into shared service_resolution module
Extract the duplicated _resolve_service_record helper (identical in
routers/monitoring.py and routers/authentik_users.py) into a shared
services/service_resolution.py module. Both routers now import
resolve_service_record from the shared module.

The authentik router previously hardcoded service_type='authentik' in
its local copy; the shared helper takes service_type as a param (same
as monitoring's did).

Tests updated: test_api.py patches now target the correct module paths
(resolve_service_record on the monitoring module where it's imported,
build_service_record on the service_resolution module).

283 backend tests pass; ruff clean.
2026-07-06 12:25:17 +00:00
Developer 1e636fdbe2 Follow-ups: reference reorder, detach service_id, named-dashboard widgets
Three reusable-widget follow-up fixes:

1. Reference sort_order independently reorderable. Reordering a
   referenced widget now updates the widget_references.sort_order (per-
   dashboard), not the shared widget instance sort_order. New backend
   update_widget_reference method + PUT /api/widgets/references/{id}
   endpoint. Frontend moveInstance checks _ref_id to choose the right
   mutation (updateRef for references, saveWidget for owned).

2. Detach preserves service_id. detach_widget_reference now copies the
   original widget's service_id into the clone, so service-bound widgets
   (Grafana chart, Jellyfin activity) continue to render after detach.

3. Named dashboards support widget references. NamedDashboardPage
   fetches useWidgetReferences('named:<slug>') and renders them via
   WidgetInstanceCard alongside pinned links. 'Edit widgets' button
   opens WidgetConfigDialog with dashboardScope='named:<slug>'.

Also: removed useMemo on combinedWidgets in WidgetConfigDialog to fix
a react-hooks/preserve-manual-memoization lint error (the React Compiler
ESLint plugin couldn't verify the spread+sort memoization).

283 backend tests pass (+1 update_reference test); 128 frontend tests
pass; ruff clean; 0 lint errors.
2026-07-06 12:11:47 +00:00
Developer c36262d7b6 Reusable widgets: reference widgets across dashboards + detach to clone
Widgets configured on one dashboard (e.g., a Grafana service's Overview)
can now be live-referenced on other dashboards. Editing the widget config
updates it everywhere it's referenced. References can be detached into
independent clones.

Backend: new widget_references table (dashboard_scope, widget_id,
sort_order) with ON DELETE CASCADE. CRUD methods + 4 endpoints:
GET/POST /api/widgets/references, DELETE /api/widgets/references/{id},
POST /api/widgets/references/{id}/detach (clones the widget into a
standalone instance, then removes the reference).

Frontend: WidgetConfigDialog gains a dashboardScope prop. When set
(the main Dashboard passes 'main'), the dialog shows:
- Owned + referenced widgets in a combined list, with a link badge on
  references.
- 'Add existing widget' picker: searchable list of ALL widget instances
  not already on this dashboard. Click to create a reference.
- Detach button on references: clones the widget (service_id=NULL) and
  removes the reference.
- Delete on a reference removes the REFERENCE (not the original widget).

Dashboard renders referenced widgets alongside owned widgets.

Detaching a service-bound widget clones it with service_id=NULL — the
clone may need re-binding to a service to render correctly. Named
dashboards don't pass dashboardScope yet (pinned-links-only); when they
gain widget support, the backend already handles any scope string.

282 backend tests pass (+2 reference lifecycle); 127 frontend tests
pass; ruff/eslint/tsc/vite all green.
2026-07-06 11:34:48 +00:00
Developer 94bf830955 Fix invisible chart lines + extract Prometheus labels for multi-series
Two fixes for the Grafana chart widget:

1. Invisible lines: the CHART_COLORS used 'hsl(var(--chart-1))' but the
   CSS variable is named '--color-chart-1' and already contains a hex
   color (#4f8cff). The hsl() wrapper produced invalid CSS, making
   every stroke invisible. Fixed to var(--color-chart-1).

2. Multiple series collision: the backend labeled all Prometheus series
   with the value field name (often just 'Value'), so multiple time
   series collided on the same recharts dataKey and overwrote each
   other. Now extracts meaningful labels from the Grafana frame metadata:
   prefers displayName, then Prometheus metric labels (e.g.
   'instance=server1:9100 mode=iowait'), then falls back to the field
   name. Duplicate labels get a numeric suffix for uniqueness.

Multi-series queries now render correctly: each Prometheus time series
gets its own colored line with a unique label in the legend/tooltip.

280 backend tests pass (+1 labels test); 127 frontend tests pass; ruff/
eslint clean.
2026-07-06 10:59:16 +00:00
Developer f355d04278 Use multi-line textarea for complex widget config fields
The Grafana chart widget's 'query' field (PromQL) and any schema field
marked format:'textarea' now render as a resizable 4-row Textarea with
monospace font, instead of a single-line Input. Makes complex queries
much easier to read and edit.

The field-renderer heuristic: key === 'query' or schema format ===
'textarea' → Textarea; everything else stays an Input (numbers stay
number inputs). 127 tests pass; lint/build green.
2026-07-06 10:43:12 +00:00
Developer bfe7ce7367 Fix: WidgetConfigDialog scope + reorder race condition
Two bugs in the widget config dialog:

1. Service Overview edit showed main-dashboard widgets. The dialog
   called useWidgetInstances() with no args, fetching ALL widgets. Now
   accepts a serviceId prop; OverviewTab passes instance.id so the
   dialog lists + creates only service-scoped widgets. New built-in
   widgets added from a service Overview inherit the serviceId.

2. Reorder up/down buttons did nothing. moveInstance fired two
   saveWidget mutations via Promise.all — the first mutation's
   onSuccess cache invalidation triggered a refetch before the second
   completed, reverting the swap. Changed to sequential awaits so
   both sort_order writes land before the cache refreshes.

127 tests pass; lint/build green.
2026-07-06 10:29:24 +00:00
Developer 447775048c Replace Grafana iframe panel with server-side chart widget
The iframe-based 'panel' widget didn't work: the browser couldn't
authenticate against the OIDC-protected Grafana (Authentik), and
iframes can't carry Bearer tokens or share cross-origin session
cookies. Result: blank iframe or login redirect.

Replace it with a 'chart' widget that queries Grafana's datasource
API server-side:

Backend (GrafanaWidgetSource): POSTs to /api/ds/query with the stored
api_key (which bypasses OIDC), using the widget's configured PromQL
query, datasource_uid, time range, and resolution. Normalizes Grafana's
frame-based response into a simple {series: [{label, points: [{t, v}]}]}
shape. The api_key is never exposed to the browser.

Frontend (GrafanaChartWidget): renders the series data as a recharts
LineChart with dark-mode-aware colors (Tailwind --chart-* tokens),
responsive container, custom tooltip, and per-series lines. Loading
skeleton, error Alert, and empty state. recharts ^3.9.2 added.

The 'link' widget kind (deep-link URL) is unchanged. The 'panel' kind
and GrafanaPanelWidget are fully removed.

Backend: 279 tests pass (+1 net: -2 panel + 3 chart). Frontend: 127
tests pass (net 0: -3 panel + 3 chart). Lint/build green both sides.
2026-07-06 10:19:57 +00:00
Developer b877a32ad8 Add Jellyfin Now Playing + Grafana Panel embed widgets
Two new additive widget kinds:

Jellyfin 'now_playing': like the existing 'activity' widget but filters
to only sessions with active playback (NowPlayingItem present + not
paused). Shows who's actually watching right now. The 'activity' kind
is unchanged (shows all sessions including idle).

Grafana 'panel': embeds a single Grafana panel directly in the app via
an iframe, using Grafana's /d-solo/ endpoint (renders one panel without
dashboard chrome, kiosk=tv). Configurable dashboard_uid, panel_id, and
time range (from/to, defaults now-1h/now). Includes a fallback 'Open in
Grafana' link for when embedding is blocked by X-Frame-Options/CSP. The
'link' kind is unchanged (still builds a deep-link URL).

Backend: new widget configs + definitions on jellyfin/grafana; source
adapter logic (session filter for now_playing; d-solo embed URL for
panel); 6 new tests.

Frontend: JellyfinNowPlayingWidget + GrafanaPanelWidget components;
registry bindings; 6 new tests.

278 backend tests pass (+6); 127 frontend tests pass (+6); lint/build
green both sides.
2026-06-28 15:26:43 +00:00
Developer 8d2e4c9bfd Service IA refinement: nav naming, instance tabs, config to Settings, configurable Overview
Four coupled changes to the services-as-hub IA:

1. Nav entries use service TYPE names (Jellyfin, SSH Tasks, Alertmanager,
   Grafana, Prometheus, Backups, Authentik) instead of conceptual names
   (Media, Files, Actions, Alerts, Users). ssh_tasks collapses to one
   entry ('SSH Tasks') instead of two. The content tabs inside each
   service page surface the concepts (Files, Actions).

2. Service page gains a two-level tab structure when multiple enabled
   instances of the same type exist: instance tabs on top ([Main Jellyfin]
   [Backup Jellyfin]), content tabs below ([Overview] [Media] [Requests]
   [Widgets]). Clicking an instance tab navigates to the sibling's route.
   Single instance: no instance tabs. Replaces the dropdown switcher.

3. Config tab (connection fields, secrets, enable/disable, delete) moves
   from the service page to Settings > Services tab. The service page
   becomes a PURE operational view (Overview + content tabs + Widgets) --
   no save/delete/config state. Settings gains a 4th tab 'Services' with
   ServiceConfigEditor per instance (schema-driven config fields, secrets
   with leave-blank-to-keep semantics, ConfirmDialog on delete).

4. Overview tab is now a configurable widget grid per service instance.
   Each instance manages its own set of widgets on its Overview. Backend
   widget list endpoints gain ?service_id= and ?scope= (dashboard|service)
   filter params; the main Dashboard uses scope=dashboard to exclude
   service-scoped widgets. The OverviewTab reuses WidgetInstanceCard +
   WidgetConfigDialog. Empty state CTA for instances with no widgets.

All service-tab stubs are replaced; stubs.tsx deleted.

272 backend tests pass (+1 widget filter); 121 frontend tests pass (+3
instance-tabs + OverviewTab); lint/build green both sides.
2026-06-26 22:25:46 +00:00
Developer fef0ded76f Fix: tabs.tsx data-orientation variants were dead (side-by-side layout)
The shared Tabs primitive used data-horizontal:* / data-vertical:* Tailwind
variants, but the component sets data-orientation='horizontal' (not
data-horizontal). Tailwind v4 data-* variants match attribute names, so
data-horizontal:flex-col on the Tabs root never applied -- the TabsList
and TabsContent laid out side-by-side instead of stacking.

Other consumers (TabbedCard, Settings) wrap their tab children in <div>s,
so the broken flex direction was masked. ServicePage puts TabsList and
TabsContent as direct children of <Tabs>, exposing the bug.

Fix: switch every dead variant to data-[orientation=horizontal]:* /
data-[orientation=vertical]:* (the root flex-col, the list h-8/h-fit/
flex-col, the trigger w-full/justify-start, and the active-indicator
after-element positioning). The full orientation system now works as
intended for both horizontal and vertical tabs.

117 tests pass; lint/build green.
2026-06-26 21:33:19 +00:00
Developer f7f590fa47 Fix: ServicePage content tabs wrapped in SheetForm on mobile
The reconciliation with mobile-responsive-parity applied the SheetForm
wrapper (designed when ServicePage was config-only) to the ENTIRE
service page, including content tabs. Clicking a nav item like 'Media'
on mobile opened a form sheet with Save/Cancel instead of the tabbed
content browser.

Fix: ServicePage now renders the Tabs skeleton on ALL breakpoints.
Content tabs (Media, Files, Actions, etc.) are operational views, not
forms -- they have their own mobile handling (MobileCardRow, etc.) and
should not be wrapped in a Save/Cancel sheet. The Config tab renders
inline like every other tab.

Removes the isMobile branch + SheetForm wrapper + dead imports
(useIsMobile, SheetForm) + sheetOpen state. 117 tests pass; lint/build
green.
2026-06-26 21:21:59 +00:00
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
177 changed files with 17923 additions and 5142 deletions
+141
View File
@@ -0,0 +1,141 @@
# Grafana Chart Widget — worker output
## Files changed (10 files, ~400 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +12/-12 (panel→chart config + kind) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +70/-12 (chart query adapter replaces panel URL logic) |
| `backend/tests/test_widgets.py` | modified | +55/-20 (3 new chart tests replace 2 panel tests) |
| `backend/tests/test_services.py` | modified | +2/-2 (grafana widget-kind + API-metadata assertions) |
| `frontend/src/widgets/GrafanaChartWidget.tsx` | **new** | 100 |
| `frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx` | **new** | 57 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | **deleted** | -50 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | **deleted** | -72 |
| `frontend/src/integrations/registry.ts` | modified | +24/-14 (chart binding replaces panel) |
| `frontend/src/integrations/registry.test.ts` | modified | +1/-1 (panel→chart) |
| `frontend/src/widgets/index.ts` | modified | +1/-0 (export GrafanaChartWidget) |
| `frontend/package.json` + `package-lock.json` | modified | +1 dep (recharts ^3.9.2) |
**recharts version installed:** `^3.9.2`
## Grafana `/api/ds/query` request/response shape
**Request** (POST):
```json
{
"queries": [{
"datasource": {"uid": "prometheus", "type": "prometheus"},
"expr": "rate(cpu[5m])",
"format": "time_series",
"intervalMs": 30000,
"maxDataPoints": 100,
"refId": "A"
}],
"from": "now-1h",
"to": "now"
}
```
Headers: `Authorization: Bearer {api_key}`, `Content-Type: application/json`
**Response** (abbreviated):
```json
{
"results": {
"A": {
"frames": [{
"data": { "values": [[1000, 2000], [0.5, 0.8]] },
"schema": { "fields": [{"name":"Time"}, {"name":"cpu_usage"}] }
}]
}
}
}
```
## Series normalization logic
Iterates `results[*].frames[]`. For each frame with `values` having >=2 arrays (timestamps + values), extracts the series label from `schema.fields[-1].name` and zips timestamps+values into `[{t: int, v: float|null}]`. Returns `{"series": [{"label": "...", "points": [...]}]}`.
## Frontend chart rendering
`GrafanaChartWidget` fetches widget data, extracts `data.series`, merges all series by timestamp into a single recharts data array (`[{time, cpu_usage: 0.5, mem: 0.3}, ...]`), and renders a `<LineChart>` with one `<Line>` per series. Uses Tailwind CSS variables (`--chart-1` through `--chart-5`) for colors so it respects dark mode. Includes loading skeleton, error Alert, and empty-state Alert.
## Validation
```
cd backend && .venv/bin/ruff check . && .venv/bin/python -m pytest → 279 passed, ruff clean
cd frontend && npm run lint && npm run build && npm run test → 127 passed, lint/build clean
```
## Deviations
1. **No deviations from spec.** The `link` widget kind is unchanged. The `panel` kind is fully replaced by `chart`.
2. **recharts `labelFormatter` type workaround.** Recharts 3.x types `labelFormatter` as `(label: ReactNode, ...) => ReactNode`, not `(number) => string`. Wrapped with `(label) => formatTime(Number(label))` to satisfy TS strict.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- The chart widget assumes the Grafana datasource is Prometheus-type (hardcoded `"type": "prometheus"` in the query body). If the user has a non-Prometheus datasource (InfluxDB, etc.), the query body format may need adjustment. The `datasource_uid` is configurable but the `type` is not.
- recharts is ~45KB gzipped added to the bundle.
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Replaces the broken iframe panel widget with a server-side chart query widget. Backend queries /api/ds/query with stored api_key; frontend renders recharts LineChart. No iframe, no browser auth, no CORS. The link widget kind is unchanged. 279 backend + 127 frontend tests pass; lint/build green both sides."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/integrations/grafana.py",
"backend/src/media_library_viewer_api/widgets/sources.py",
"backend/tests/test_widgets.py",
"backend/tests/test_services.py",
"frontend/src/widgets/GrafanaChartWidget.tsx",
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
"frontend/src/widgets/GrafanaPanelWidget.tsx (deleted)",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx (deleted)",
"frontend/src/integrations/registry.ts",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/index.ts",
"frontend/package.json"
],
"testsAddedOrUpdated": [
"backend/tests/test_widgets.py",
"backend/tests/test_services.py",
"frontend/src/widgets/__tests__/GrafanaChartWidget.test.tsx",
"frontend/src/integrations/registry.test.ts"
],
"commandsRun": [
{ "command": "cd backend && .venv/bin/ruff check .", "result": "passed", "summary": "All checks passed" },
{ "command": "cd backend && .venv/bin/python -m pytest tests/ -q", "result": "passed", "summary": "279 passed, 2 pre-existing warnings" },
{ "command": "cd frontend && npm run lint", "result": "passed", "summary": "0 errors, 0 warnings" },
{ "command": "cd frontend && npm run build", "result": "passed", "summary": "tsc + vite build clean" },
{ "command": "cd frontend && npm run test", "result": "passed", "summary": "39 files / 127 tests passed" }
],
"validationOutput": [
"Backend ruff clean; 279 tests pass (was 278; -2 panel + 3 chart = +1 net).",
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (-3 panel + 3 chart = net 0).",
"GrafanaWidgetSource._fetch_chart POSTs to /api/ds/query with Bearer token; normalizes response to {series:[{label,points}]}",
"GrafanaChartWidget renders recharts LineChart with dark-mode CSS variable colors.",
"link widget kind unchanged; panel widget kind fully removed."
],
"residualRisks": [
"Chart query body hardcodes datasource type 'prometheus' — non-Prometheus datasources (InfluxDB etc.) may need a type field on the config.",
"recharts adds ~45KB gzipped to the frontend bundle."
],
"noStagedFiles": true,
"diffSummary": "~400 lines: replaces Grafana panel iframe widget with server-side datasource-query chart widget. Backend: /api/ds/query POST with api_key + series normalization (70 lines). Frontend: recharts LineChart component with dark-mode support (100 lines). 3 backend + 3 frontend tests. recharts ^3.9.2 installed.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "recharts labelFormatter type workaround: recharts 3.x types it as (ReactNode) => ReactNode, not (number) => string. Wrapped with Number() cast. The link widget kind is fully preserved. The panel widget kind and all its code/tests are fully deleted."
}
```
+81
View File
@@ -0,0 +1,81 @@
# Service IA Refinement — Instance Tabs + Config to Settings
## Files changed (5 files, +310/-381)
| File | Status | Lines |
|------|--------|-------|
| `frontend/src/integrations/navEntries.ts` | modified | +31/-31 (type names + ssh_tasks collapsed to one entry) |
| `frontend/src/integrations/__tests__/navEntries.test.ts` | modified | +23/-23 (updated labels) |
| `frontend/src/pages/ServicePage.tsx` | modified | +113/-218 (simplified: removed Config tab, ConfigBody, all save/delete state; added instance tabs) |
| `frontend/src/pages/Settings.tsx` | modified | +230/-5 (added Services tab + ServicesAdminCard + ServiceConfigEditor) |
| `frontend/src/pages/__tests__/ServicePage.test.tsx` | modified | +76/-76 (removed Config/secret tests, added instance-tabs tests) |
## New ServicePage structure
The service page is now a **pure operational view** — no save/delete/config state at all.
**When >1 enabled sibling:**
```
[Main Jellyfin] [Backup Jellyfin] ← instance tabs (click to navigate)
[Overview] [Media] [Requests] [Widgets] ← content tabs
<content>
```
**When 1 instance:**
```
[Overview] [Media] [Requests] [Widgets] ← content tabs only
<content>
```
- No Config tab. No `<Select>` switcher. No `ConfigBody`, `buildInput`, `save`, `draftConfig`, `draftSecrets`, `name`, `enabled`, `hydrated`, `deleteOpen` state.
- Instance tabs use the shadcn `Tabs` component (outer level). Content tabs use a nested `Tabs` (inner level). Clicking an instance tab navigates to `/services/:type/:id`.
- Removed imports: `useState`, `useSaveServiceInstance`, `useDeleteServiceInstance`, `useServiceTypes`, `Input`, `Label`, `Switch`, `Select*`, `ConfirmDialog`, `ServiceInstanceInput`, `ServiceTypeInfo`, `Field` helper.
## New Settings tab structure
Settings now has 4 tabs: **Machines | SSH Keys | Services | Danger Zone**.
The **Services** tab renders `ServicesAdminCard`:
- Lists all service instances grouped by type (alphabetical) using `SectionCard` per group.
- Each instance renders inside a `ServiceConfigEditor` component with:
- Name field (editable Input)
- Enabled toggle (Switch)
- Connection config fields (schema-driven from type info, same logic as old ConfigBody)
- Secret fields (password inputs, "leave blank to keep" semantics)
- Save + Delete buttons
- The `ServiceConfigEditor` owns its own draft state (name, enabled, draftConfig, draftSecrets), initialized from the instance. `buildInput` + `handleSave` replicate the old ConfigBody logic.
## How instance tabs work
- `siblings` is computed as `services.filter(s => s.service_type === serviceType && s.enabled)`.
- When `siblings.length > 1`, an outer `<Tabs value={instance.id}>` renders one `<TabsTrigger>` per sibling. Each trigger has `onClick={() => navigate(`/services/${serviceType}/${sibling.id}`)}`.
- The content tabs (`<Tabs defaultValue="Overview">`) are a separate nested Tabs component below the instance tabs.
- Single instance: no instance tabs rendered (the condition is false).
## Validation
```
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc -b + vite)
cd frontend && npm run test → 36 files / 118 tests passed (was 117; +1 instance-tabs test)
```
## Deviations
1. **No ConfirmDialog on delete in ServiceConfigEditor.** The old ServicePage had a ConfirmDialog before deleting. The new ServiceConfigEditor calls `deleteService.mutate(instance.id)` directly on the Delete button click. This is a minor UX regression; a follow-up can add the confirm dialog. Kept simple to stay within scope.
2. **Instance tabs use onClick navigation, not Radix tab state.** The outer Tabs `value` is bound to `instance.id` (the current route), and clicking a trigger navigates. Radix's internal state management isn't used for the instance level — navigation is the source of truth.
3. **tabs.tsx formatting discarded.** The write tool normalized tabs.tsx (semicolons + indentation). I discarded that diff to keep the change focused on the 5 intended files.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- No ConfirmDialog on service delete in the Settings > Services tab (minor UX regression vs the old ServicePage).
- The ServicesPage (`/services`) still has its own create flow; the Settings > Services tab is edit-only. These are complementary (create on Services, edit on Settings), but a user might expect both on the same page.
+138
View File
@@ -0,0 +1,138 @@
# Configurable per-service Overview (change 4)
## Files changed (10 files, ~310 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/services/settings_store.py` | modified | +20/-3 (`list_widgets` gains `service_id` + `scope` params) |
| `backend/src/media_library_viewer_api/routers/widgets.py` | modified | +12/-4 (`list_instances` gains `service_id` + `scope` query params) |
| `backend/tests/test_widgets.py` | modified | +36 (filter test) |
| `frontend/src/api/widgets.ts` | modified | +8/-1 (`fetchWidgetInstances` accepts `serviceId?` + `scope?`) |
| `frontend/src/hooks/useWidgets.ts` | modified | +6/-4 (`useWidgetInstances` accepts params; queryKey includes them) |
| `frontend/src/pages/Dashboard.tsx` | modified | +1/-1 (passes `scope="dashboard"` to exclude service-scoped widgets) |
| `frontend/src/pages/service-tabs/OverviewTab.tsx` | **new** | 67 |
| `frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx` | **new** | 79 |
| `frontend/src/pages/service-tabs/index.ts` | modified | +1/-1 (import real OverviewTab) |
| `frontend/src/pages/service-tabs/stubs.tsx` | **deleted** | -19 |
## Backend filter shape
`GET /api/widgets/instances` now accepts:
- `?service_id=X` — filter to widgets for service X
- `?scope=dashboard` — only NULL service_id widgets (main dashboard)
- `?scope=service` — only non-NULL service_id widgets
`SettingsStore.list_widgets(service_id=None, *, scope=None)` builds WHERE clauses dynamically. No-args returns all (backward-compatible).
## OverviewTab structure
`OverviewTab({ instance })`:
- Fetches `useWidgetInstances(instance.id)` (scoped to this service).
- Renders enabled, sorted widgets in a `grid-cols-1 md:grid-cols-2` grid via `WidgetInstanceCard`.
- "Edit widgets" button opens the existing `WidgetConfigDialog` (reused from the Dashboard).
- Empty state: "No widgets on this overview yet" + "Add widgets" button.
- The WidgetConfigDialog is shared — it lists all widget instances from the default query (unscoped). When used from OverviewTab, the user adds service-bound widgets via the dialog's service-widget section.
## Config dialog integration
Reuses the existing `WidgetConfigDialog` as-is. It already supports adding service-bound widgets (pick a service + widget kind). The dialog manages widget instances globally; the OverviewTab filters by `instance.id`. This means the dialog shows ALL widgets (including dashboard ones), but the Overview only renders the service-scoped ones. A follow-up could scope the dialog to the current service, but the shared dialog is functional as-is.
## Validation
```
cd backend && .venv/bin/ruff check . → All checks passed!
cd backend && .venv/bin/python -m pytest tests/ → 272 passed, 2 warnings
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc + vite)
cd frontend && npm run test → 36 files / 121 tests passed
```
## Deviations
1. **WidgetConfigDialog is unscoped.** It lists all widget instances. The OverviewTab filters by `instance.id` at render time, but the dialog shows everything. Scoping the dialog would require adding a `serviceId` prop to it and filtering internally — a follow-up for a cleaner UX.
2. **stubs.tsx deleted.** All stubs were replaced; the file had no remaining exports after removing OverviewTab.
3. **ServicePage tests updated.** Added mocks for `useWidgets`, `WidgetConfigDialog`, and `WidgetInstanceCard` since OverviewTab now calls them.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- WidgetConfigDialog is shared and unscoped — adding a widget from the OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.
- The `all_widgets` param on `list_widgets` was simplified to just `service_id` + `scope` (the `all_widgets` kwarg is unused but kept in the signature for clarity; it defaults to True and is a no-op).
- No ConfirmDialog on service delete in the Settings Services tab (pre-existing from change 2+3, not introduced here).
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Implements configurable per-service Overview (widget grid scoped by instance.id) + backend filter params (?service_id= + ?scope=) + Dashboard scope fix + tests. No scope widening: 10 files, ~310 lines. 272 backend + 121 frontend tests pass; lint/build green both sides."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/services/settings_store.py",
"backend/src/media_library_viewer_api/routers/widgets.py",
"backend/tests/test_widgets.py",
"frontend/src/api/widgets.ts",
"frontend/src/hooks/useWidgets.ts",
"frontend/src/pages/Dashboard.tsx",
"frontend/src/pages/service-tabs/OverviewTab.tsx",
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
"frontend/src/pages/service-tabs/index.ts",
"frontend/src/pages/service-tabs/stubs.tsx"
],
"testsAddedOrUpdated": [
"backend/tests/test_widgets.py",
"frontend/src/pages/service-tabs/__tests__/OverviewTab.test.tsx",
"frontend/src/pages/__tests__/ServicePage.test.tsx"
],
"commandsRun": [
{
"command": "cd backend && .venv/bin/ruff check .",
"result": "passed",
"summary": "All checks passed"
},
{
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
"result": "passed",
"summary": "272 passed, 2 warnings (pre-existing)"
},
{
"command": "cd frontend && npm run lint",
"result": "passed",
"summary": "0 errors, 0 warnings"
},
{
"command": "cd frontend && npm run build",
"result": "passed",
"summary": "tsc + vite build clean"
},
{
"command": "cd frontend && npm run test",
"result": "passed",
"summary": "36 files / 121 tests passed"
}
],
"validationOutput": [
"Backend list_widgets supports service_id + scope filtering; test covers all/dash scope/service scope/filtered.",
"Frontend fetchWidgetInstances + useWidgetInstances accept serviceId + scope; queryKey includes them.",
"Dashboard uses scope=dashboard to exclude service-scoped widgets.",
"OverviewTab renders instance-scoped widget grid with edit button + empty state.",
"stubs.tsx deleted (all stubs replaced)."
],
"residualRisks": [
"WidgetConfigDialog is shared and unscoped — adding a widget from OverviewTab's edit button could add a dashboard widget that doesn't show on this overview.",
"No ConfirmDialog on service delete in Settings Services tab (pre-existing from change 2+3)."
],
"noStagedFiles": true,
"diffSummary": "~310 lines across 10 files: backend widget-list filtering (service_id + scope params), frontend hook/API scope support, new OverviewTab (instance-scoped widget grid + edit/empty states), Dashboard scope fix, stubs.tsx deleted, ServicePage test mocks updated.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "Nothing is staged. The WidgetConfigDialog is reused as-is (functional but unscoped); a follow-up could add a serviceId prop for tighter scoping. The all_widgets kwarg on list_widgets is unused but kept for API clarity."
}
+142
View File
@@ -0,0 +1,142 @@
# New widgets: Jellyfin now_playing + Grafana panel embed
## Files changed (10 files, ~390 lines)
| File | Status | Lines |
|------|--------|-------|
| `backend/src/media_library_viewer_api/integrations/jellyfin.py` | modified | +12 (new widget kind + config model) |
| `backend/src/media_library_viewer_api/integrations/grafana.py` | modified | +17 (new widget kind + config model) |
| `backend/src/media_library_viewer_api/widgets/sources.py` | modified | +12 (now_playing filter + panel embed URL) |
| `backend/tests/test_services.py` | modified | +3 (updated widget-kind assertions) |
| `backend/tests/test_widgets.py` | modified | +85 (import + 6 new tests) |
| `frontend/src/widgets/JellyfinNowPlayingWidget.tsx` | new | 41 |
| `frontend/src/widgets/GrafanaPanelWidget.tsx` | new | 50 |
| `frontend/src/integrations/registry.ts` | modified | +24 (2 new widget bindings) |
| `frontend/src/integrations/registry.test.ts` | modified | +1 (updated grafana kinds) |
| `frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx` | new | 72 |
| `frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx` | new | 72 |
## Session-filter logic for now_playing
```python
if widget_kind == "now_playing":
sessions = [
s for s in sessions
if s.get("NowPlayingItem")
and not s.get("PlayState", {}).get("IsPaused", True)
]
```
Filters raw Jellyfin sessions BEFORE `_map_sessions_to_activity_rows`. A session is "actively playing" when it has a `NowPlayingItem` (something is playing, not just idle) AND `PlayState.IsPaused` is false. The `activity` kind (default) is unchanged — shows all sessions including idle and paused.
## Embed URL format for panel
```python
embed_url = f"{base_url}/d-solo/{dashboard_uid}/manage?panelId={panel_id}&from={from_ts}&to={to_ts}&kiosk=tv"
```
Uses Grafana's `/d-solo/` endpoint which renders a single panel without dashboard chrome. `kiosk=tv` hides the top nav. Defaults: `from_ts="now-1h"`, `to_ts="now"`.
## Validation
```
cd backend && .venv/bin/ruff check src/ tests/ → All checks passed!
cd backend && .venv/bin/python -m pytest tests/ → 278 passed, 2 warnings (pre-existing)
cd frontend && npm run lint → 0 errors, 0 warnings
cd frontend && npm run build → ✓ built (tsc + vite)
cd frontend && npm run test → 39 files / 127 tests passed
```
Backend: +6 new tests (definition assertions x2, grafana panel URL x2, jellyfin now_playing filter x1, jellyfin activity shows all x1).
Frontend: +6 new tests (JellyfinNowPlayingWidget x3, GrafanaPanelWidget x3).
## Deviations
1. **No deviations from spec.** Both widgets are additive — no existing behavior changed. The `activity` and `link` kinds work exactly as before.
2. **GrafanaPanelWidget pi-lens advisory** for `<Button asChild><a>` is a false positive (Radix Slot merges props, doesn't create nested `<a>`). Same pattern as GrafanaLinkWidget, ObservabilityPage, and PinnedServiceLink. Build and lint pass.
## skill_resolution
`none` — no project/user SKILL.md paths were injected; no `.atl/skill-registry.md` found.
## Residual risks
- **Grafana embedding may be blocked** by `X-Frame-Options` or CSP depending on Grafana config. The fallback "Open in Grafana" link is provided.
- **GrafanaPanelWidget iframe height is fixed at 300px** — not responsive to panel content height. A follow-up could use Grafana's panel-content-height API or a ResizeObserver.
- **now_playing filter operates on raw sessions before mapping** — if Jellyfin changes its session shape (e.g. moves `NowPlayingItem`/`PlayState`), the filter silently passes all sessions. Same fragility as the existing activity mapping.
```acceptance-report
{
"criteriaSatisfied": [
{
"id": "criterion-1",
"status": "satisfied",
"evidence": "Implements two additive widget kinds (jellyfin now_playing + grafana panel embed) without changing any existing behavior. Backend: new widget configs + definitions + source adapter logic + 6 tests. Frontend: 2 new components + registry bindings + 6 tests. 278 backend + 127 frontend tests pass; ruff/eslint/tsc/vite all green. No staged files."
}
],
"changedFiles": [
"backend/src/media_library_viewer_api/integrations/jellyfin.py",
"backend/src/media_library_viewer_api/integrations/grafana.py",
"backend/src/media_library_viewer_api/widgets/sources.py",
"backend/tests/test_services.py",
"backend/tests/test_widgets.py",
"frontend/src/widgets/JellyfinNowPlayingWidget.tsx",
"frontend/src/widgets/GrafanaPanelWidget.tsx",
"frontend/src/integrations/registry.ts",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
],
"testsAddedOrUpdated": [
"backend/tests/test_services.py",
"backend/tests/test_widgets.py",
"frontend/src/integrations/registry.test.ts",
"frontend/src/widgets/__tests__/JellyfinNowPlayingWidget.test.tsx",
"frontend/src/widgets/__tests__/GrafanaPanelWidget.test.tsx"
],
"commandsRun": [
{
"command": "cd backend && .venv/bin/ruff check src/ tests/",
"result": "passed",
"summary": "All checks passed"
},
{
"command": "cd backend && .venv/bin/python -m pytest tests/ -q",
"result": "passed",
"summary": "278 passed, 2 warnings (pre-existing deprecation)"
},
{
"command": "cd frontend && npm run lint",
"result": "passed",
"summary": "0 errors, 0 warnings"
},
{
"command": "cd frontend && npm run build",
"result": "passed",
"summary": "tsc + vite build clean"
},
{
"command": "cd frontend && npm run test",
"result": "passed",
"summary": "39 files / 127 tests passed"
}
],
"validationOutput": [
"Backend ruff clean; 278 tests pass (+6 new).",
"Frontend eslint clean; tsc + vite build clean; 127 tests pass (+6 new).",
"Jellyfin now_playing filters: session has NowPlayingItem + IsPaused=false.",
"Grafana panel embed URL: /d-solo/{uid}/manage?panelId={id}&from={from}&to={to}&kiosk=tv.",
"Existing activity + link widget kinds unchanged (tested)."
],
"residualRisks": [
"Grafana iframe may be blocked by X-Frame-Options/CSP; fallback link provided.",
"Iframe height fixed at 300px (not responsive to panel content).",
"now_playing filter depends on Jellyfin session shape (NowPlayingItem/PlayState)."
],
"noStagedFiles": true,
"diffSummary": "~390 lines across 11 files: 2 new backend widget kinds (jellyfin now_playing + grafana panel) with source adapter logic, 2 new frontend components, registry bindings, and 12 new tests (6 backend + 6 frontend). Purely additive — no existing behavior changed.",
"reviewFindings": [
"no blockers"
],
"manualNotes": "The JellyfinClient mock approach uses patch on the class directly (not asyncio.to_thread) — let real asyncio handle the threading. The pi-lens nested-<a> advisory on GrafanaPanelWidget is a false positive (Button asChild uses Radix Slot)."
}
+18
View File
@@ -4,6 +4,24 @@ All notable changes to Manage. Breaking changes are marked with **BREAKING**.
## [Unreleased] ## [Unreleased]
### Added — Direct Prometheus charting
- **Prometheus is now the direct source for in-app charts.** New widget kinds
on the `prometheus` service: `chart` (multi-series line chart via recharts,
backed by `/api/v1/query_range`), `gauge` (instant scalar with configurable
threshold bands), and `mean` (client-side average over a time window).
### **BREAKING** — Grafana service type removed
- The `grafana` service type, Grafana link widget, Grafana chart widget, and
`GET /api/monitoring/grafana-status` endpoint were **removed**. Manage now
queries Prometheus directly for all chart data.
- **Migration:** Delete any existing Grafana service instances and create
Prometheus service instances instead (pointing at your Prometheus URL). Any
configured `grafana/chart` widgets must be recreated as `prometheus/chart`
widgets. Grafana link widgets are gone — use Prometheus chart/metric widgets
instead.
### Added — Observability service registry ### Added — Observability service registry
- **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and - **Alertmanager is now a service type.** Configure Alertmanager, Grafana, and
@@ -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,
}
@@ -0,0 +1,80 @@
"""Minimal qBittorrent Web API client (read-only: sync/maindata only).
Modeled on :class:`~media_library_viewer_api.clients.jellyfin.JellyfinClient`'s
session pattern. Authentication uses username/password login which stores an
SID cookie in the requests session. The client re-logins transparently on 403.
"""
from __future__ import annotations
import logging
from typing import Any
import requests
logger = logging.getLogger(__name__)
class QbittorrentClient:
"""Small wrapper around the qBittorrent Web API.
Only the endpoints needed by the dashboard widgets are implemented
(currently just ``/sync/maindata``). All calls share a single
:class:`requests.Session` that carries the login cookie.
"""
def __init__(self, base_url: str, username: str, password: str, timeout: int = 10) -> None:
if not base_url:
raise ValueError("qBittorrent base_url is required")
if not username:
raise ValueError("qBittorrent username is required")
self.base_url = base_url.rstrip("/")
if not self.base_url.endswith("/api/v2"):
self.base_url += "/api/v2"
self._username = username
self._password = password
self.timeout = timeout
self._session = requests.Session()
self._logged_in = False
def _login(self) -> None:
"""POST username/password to ``/auth/login``; store the SID cookie.
qBittorrent returns the plain text ``"Ok."`` on success. The
``Referer`` header is required by some qBittorrent CSRF protections.
"""
resp = self._session.post(
f"{self.base_url}/auth/login",
data={"username": self._username, "password": self._password},
timeout=self.timeout,
headers={"Referer": self.base_url},
)
resp.raise_for_status()
if resp.text.strip() != "Ok.":
raise RuntimeError(f"qBittorrent login failed: {resp.text.strip()}")
self._logged_in = True
logger.info("qBittorrent login successful for %s", self.base_url)
def _get(self, path: str, **params: Any) -> dict[str, Any]:
"""GET an endpoint with auto-login on first call and re-login on 403."""
if not self._logged_in:
self._login()
url = f"{self.base_url}{path}"
resp = self._session.get(url, params=params, timeout=self.timeout)
if resp.status_code == 403:
logger.debug("qBittorrent 403 on %s, re-logging in", path)
self._logged_in = False
self._login()
resp = self._session.get(url, params=params, timeout=self.timeout)
resp.raise_for_status()
return resp.json()
def maindata(self) -> dict[str, Any]:
"""Fetch ``/sync/maindata``.
Returns a dict with ``server_state`` (containing ``dl_info_speed``,
``up_info_speed``, etc.) and ``torrents`` (a dict of
``{hash: {name, state, progress, size, dlspeed, upspeed, ...}}``).
"""
return self._get("/sync/maindata")
@@ -18,7 +18,6 @@ from typing import Any
from fastapi import HTTPException, Request from fastapi import HTTPException, Request
from media_library_viewer_api.clients.jellyfin import JellyfinClient 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.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings 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) 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: def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient:
"""Build a RemoteSSHClient from a machine config dict.""" """Build a RemoteSSHClient from a machine config dict."""
store = store or get_settings_store() 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,
),
],
)
@@ -1,7 +1,7 @@
"""Base classes for service integrations. """Base classes for service integrations.
A *service definition* is a closed, compile-time description of an external service A *service definition* is a closed, compile-time description of an external service
the app can talk to (Grafana, Jellyfin, …). Each definition declares: the app can talk to (Jellyfin, Prometheus, …). Each definition declares:
* its non-secret ``config_schema`` (derived from a Pydantic model), * its non-secret ``config_schema`` (derived from a Pydantic model),
* the secret fields it accepts (API keys / tokens), * the secret fields it accepts (API keys / tokens),
@@ -24,7 +24,7 @@ from pydantic import BaseModel, BeforeValidator, Field
def _validate_service_base_url(value: Any) -> str: def _validate_service_base_url(value: Any) -> str:
"""Require an absolute http(s) URL for service ``base_url`` fields. """Require an absolute http(s) URL for service ``base_url`` fields.
Relative hosts (e.g. ``grafana.example.com``) break downstream HTTP clients Relative hosts (e.g. ``example.com``) break downstream HTTP clients
because ``requests`` treats them as relative paths, so we fail fast with a because ``requests`` treats them as relative paths, so we fail fast with a
clear error instead of letting the call silently malfunction. clear error instead of letting the call silently malfunction.
""" """
@@ -1,47 +0,0 @@
"""Grafana service definition."""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class GrafanaConfig(ServiceConfigBase):
"""Non-secret Grafana connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 5
class GrafanaLinkWidgetConfig(WidgetConfigBase):
"""Deep-link to a Grafana dashboard or panel."""
dashboard_uid: str
panel_id: int | None = None
DEFINITION = ServiceDefinition(
service_type="grafana",
name="Grafana",
description="Dashboards, metrics, and logs.",
config_model=GrafanaConfig,
secret_fields=[
SecretField(key="api_key", label="API key", helper="Service account token (optional)"),
],
widget_kinds=[
widget_kind(
kind="link",
name="Dashboard link",
description="Deep-link to a Grafana dashboard or panel.",
model_cls=GrafanaLinkWidgetConfig,
default_config={"dashboard_uid": ""},
refresh_interval_ms=0,
),
],
)
@@ -13,11 +13,20 @@ from media_library_viewer_api.integrations.base import (
class JellyfinConfig(ServiceConfigBase): 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 base_url: ServiceBaseUrl
user_id: str = "" user_id: str = ""
timeout_seconds: int = 10 timeout_seconds: int = 10
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
class JellyfinActivityWidgetConfig(WidgetConfigBase): class JellyfinActivityWidgetConfig(WidgetConfigBase):
@@ -27,6 +36,12 @@ class JellyfinActivityWidgetConfig(WidgetConfigBase):
pass pass
class JellyfinNowPlayingWidgetConfig(WidgetConfigBase):
"""Only show sessions with active playback (not idle/paused)."""
pass
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="jellyfin", service_type="jellyfin",
name="Jellyfin", name="Jellyfin",
@@ -44,5 +59,13 @@ DEFINITION = ServiceDefinition(
default_config={}, default_config={},
refresh_interval_ms=30_000, refresh_interval_ms=30_000,
), ),
widget_kind(
kind="now_playing",
name="Now Playing",
description="Only sessions actively playing media.",
model_cls=JellyfinNowPlayingWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
], ],
) )
@@ -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=[],
)
@@ -25,6 +25,32 @@ class PrometheusMetricWidgetConfig(WidgetConfigBase):
promql: str promql: str
class PrometheusChartWidgetConfig(WidgetConfigBase):
"""A PromQL range query rendered as a multi-series line chart (SC-101..SC-104)."""
promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
class PrometheusGaugeWidgetConfig(WidgetConfigBase):
"""A PromQL instant query rendered as a gauge with optional threshold bands (SC-109..SC-111)."""
promql: str
warn_at: float | None = None
crit_at: float | None = None
min: float | None = None
max: float | None = None
unit: str | None = None
class PrometheusMeanWidgetConfig(WidgetConfigBase):
"""A PromQL range query averaged client-side into a single value (SC-112..SC-114)."""
promql: str
window: str = "1h" # one of 1h / 6h / 24h / 7d (see WINDOW_PRESETS)
unit: str | None = None
DEFINITION = ServiceDefinition( DEFINITION = ServiceDefinition(
service_type="prometheus", service_type="prometheus",
name="Prometheus", name="Prometheus",
@@ -42,5 +68,29 @@ DEFINITION = ServiceDefinition(
default_config={"promql": ""}, default_config={"promql": ""},
refresh_interval_ms=30_000, refresh_interval_ms=30_000,
), ),
widget_kind(
kind="chart",
name="Chart",
description="Multi-series line chart from a PromQL range query.",
model_cls=PrometheusChartWidgetConfig,
default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000,
),
widget_kind(
kind="gauge",
name="Gauge",
description="Instant query rendered as a gauge with optional threshold bands.",
model_cls=PrometheusGaugeWidgetConfig,
default_config={"promql": ""},
refresh_interval_ms=30_000,
),
widget_kind(
kind="mean",
name="Mean",
description="Average value of a PromQL query over a time window.",
model_cls=PrometheusMeanWidgetConfig,
default_config={"promql": "", "window": "1h"},
refresh_interval_ms=60_000,
),
], ],
) )
@@ -0,0 +1,68 @@
"""qBittorrent service definition.
Declares the config model (base URL + timeout), secret fields (username +
password), and three widget kinds (totals, active, speed). Models on
:mod:`media_library_viewer_api.integrations.prometheus`.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceBaseUrl,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class QbittorrentConfig(ServiceConfigBase):
"""Non-secret qBittorrent connection config."""
base_url: ServiceBaseUrl
timeout_seconds: int = 10
class QbittorrentWidgetConfig(WidgetConfigBase):
"""Per-widget config (empty — all three kinds derive from the service connection)."""
pass
DEFINITION = ServiceDefinition(
service_type="qbittorrent",
name="qBittorrent",
description="Torrent client activity, speeds, and item counts.",
config_model=QbittorrentConfig,
secret_fields=[
SecretField(key="username", label="Username", required=True),
SecretField(key="password", label="Password", required=True, helper="Stored encrypted"),
],
widget_kinds=[
widget_kind(
kind="totals",
name="Totals",
description="Count of all listed torrents, broken down by state.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
widget_kind(
kind="active",
name="Active torrents",
description="Torrents currently downloading or uploading.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=15_000,
),
widget_kind(
kind="speed",
name="Speed chart",
description="Live download/upload speed over a short window.",
model_cls=QbittorrentWidgetConfig,
default_config={},
refresh_interval_ms=5_000,
),
],
)
@@ -7,22 +7,24 @@ There is no runtime plugin loading.
from __future__ import annotations from __future__ import annotations
from media_library_viewer_api.integrations.alertmanager import DEFINITION as ALERTMANAGER 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.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.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.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.qbittorrent import DEFINITION as QBITTORRENT
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = { SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
GRAFANA.service_type: GRAFANA,
PROMETHEUS.service_type: PROMETHEUS, PROMETHEUS.service_type: PROMETHEUS,
ALERTMANAGER.service_type: ALERTMANAGER, ALERTMANAGER.service_type: ALERTMANAGER,
JELLYFIN.service_type: JELLYFIN, JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD, NEXTCLOUD.service_type: NEXTCLOUD,
QBITTORRENT.service_type: QBITTORRENT,
SSH_TASKS.service_type: SSH_TASKS, SSH_TASKS.service_type: SSH_TASKS,
BACKUPS.service_type: BACKUPS,
AUTHENTIK.service_type: AUTHENTIK,
} }
+13 -2
View File
@@ -21,8 +21,12 @@ from media_library_viewer_api.observability import (
record_request, record_request,
set_current_request_id, 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 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 services as services_router
from media_library_viewer_api.routers import widgets as widgets_router from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router from media_library_viewer_api.routers.settings import router as settings_router
@@ -48,6 +52,12 @@ async def lifespan(app: FastAPI):
get_settings_store().ensure_defaults() get_settings_store().ensure_defaults()
except Exception: except Exception:
logger.exception("Failed to seed default settings during startup") logger.exception("Failed to seed default settings during startup")
try:
from media_library_viewer_api.services.service_data import get_service_data_harness
get_service_data_harness()
except Exception:
logger.exception("Failed to initialize service data harness during startup")
mail_queue = get_mail_queue() mail_queue = get_mail_queue()
backup_poller = get_backup_poller() backup_poller = get_backup_poller()
mail_queue.start() mail_queue.start()
@@ -136,12 +146,13 @@ app.include_router(monitoring.router)
app.include_router(media.router) app.include_router(media.router)
app.include_router(files.router) app.include_router(files.router)
app.include_router(jobs.router) app.include_router(jobs.router)
app.include_router(users.router)
app.include_router(tasks.router) app.include_router(tasks.router)
app.include_router(settings_router) app.include_router(settings_router)
app.include_router(backups_router.router) app.include_router(backups_router.router)
app.include_router(widgets_router.router) app.include_router(widgets_router.router)
app.include_router(dashboards_router.router)
app.include_router(services_router.router) app.include_router(services_router.router)
app.include_router(authentik_users_router.router)
@app.get("/api/health") @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
@@ -2,7 +2,7 @@
Widgets are either: Widgets are either:
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared * **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
by that service's definition (Grafana link, Prometheus metric, Jellyfin by that service's definition (Prometheus metric, Jellyfin
activity, SSH task output); or activity, SSH task output); or
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the * **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
service-less kinds (backups, static). service-less kinds (backups, static).
@@ -0,0 +1,122 @@
"""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.service_resolution import resolve_service_record
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord
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 _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, "authentik", 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, "authentik", 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, "authentik", 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"]) 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) job = store.get_backup_job_by_name(report.name)
if not job: if not job:
job = store.upsert_backup_job( job = store.upsert_backup_job(
@@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source, "source": report.source,
"target": report.target, "target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds, "schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
} }
) )
elif report.schedule_interval_seconds: elif report.schedule_interval_seconds:
@@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic
"source": report.source, "source": report.source,
"target": report.target, "target": report.target,
"schedule_interval_seconds": report.schedule_interval_seconds, "schedule_interval_seconds": report.schedule_interval_seconds,
"service_id": service_id,
} }
) )
job = store.get_backup_job(job["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") @router.post("/report")
def post_backup_report( def post_backup_report(
report: BackupReportRequest, report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key), _auth: str = Depends(require_api_key),
) -> BackupRunResponse: ) -> 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) # Check for duplicate (same job + started_at within 1s)
existing_runs = store.list_backup_runs(job_id=job["id"], limit=5) existing_runs = store.list_backup_runs(job_id=job["id"], limit=5)
@@ -88,10 +108,12 @@ def post_backup_report(
@router.post("/report/start") @router.post("/report/start")
def post_backup_start( def post_backup_start(
report: BackupReportRequest, report: BackupReportRequest,
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
_auth: str = Depends(require_api_key), _auth: str = Depends(require_api_key),
) -> BackupRunResponse: ) -> 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 = { run_data = {
"job_id": job["id"], "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"}
@@ -98,7 +98,7 @@ def _serialize_status(status: Any) -> dict[str, Any]:
} }
def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]: def _worker_command(final_db_path: Path, staging_db_path: Path, service_id: str = "") -> list[str]:
return [ return [
sys.executable, sys.executable,
"-m", "-m",
@@ -107,14 +107,16 @@ def _worker_command(final_db_path: Path, staging_db_path: Path) -> list[str]:
str(final_db_path), str(final_db_path),
"--staging-path", "--staging-path",
str(staging_db_path), str(staging_db_path),
"--service-id",
service_id,
] ]
def _start_worker(index: MediaIndex) -> subprocess.Popen[bytes]: def _start_worker(index: MediaIndex, service_id: str = "") -> subprocess.Popen[bytes]:
staging_path = _staging_db_path(index) staging_path = _staging_db_path(index)
staging_path.unlink(missing_ok=True) staging_path.unlink(missing_ok=True)
return subprocess.Popen( return subprocess.Popen(
_worker_command(index.db_path, staging_path), _worker_command(index.db_path, staging_path, service_id),
start_new_session=True, start_new_session=True,
env=os.environ.copy(), env=os.environ.copy(),
) )
@@ -131,20 +133,28 @@ def get_index_status(index: MediaIndex = Depends(get_media_index)) -> dict[str,
@router.post("/build", status_code=status.HTTP_202_ACCEPTED) @router.post("/build", status_code=status.HTTP_202_ACCEPTED)
def post_build_index( def post_build_index(
client: JellyfinClient = Depends(get_jellyfin_client), jellyfin_service_id: str | None = None,
user_id: str = Depends(get_user_id),
index: MediaIndex = Depends(get_media_index), index: MediaIndex = Depends(get_media_index),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Start a media index build in a subprocess worker.""" """Start a media index build in a subprocess worker.
The worker resolves its own Jellyfin connection from the settings store.
We do NOT use Depends(get_jellyfin_client) here because the worker runs
in a separate process and needs to resolve the client itself. Validating
the connection here would fail if Jellyfin is briefly unreachable, even
though the build just needs to start the worker process.
"""
with _build_lock: with _build_lock:
current_status = _clean_stale_build_state(index) current_status = _clean_stale_build_state(index)
if current_status.build_running and _pid_is_alive(current_status.build_pid): if current_status.build_running and _pid_is_alive(current_status.build_pid):
logger.warning("Media build already running pid=%s", current_status.build_pid) logger.warning("Media build already running pid=%s", current_status.build_pid)
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress") raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Media index build already in progress")
libraries = client.libraries(user_id) logger.info(
logger.info("Starting media index build user_id=%s libraries=%s", user_id, len(libraries)) "Starting media index build service_id=%s",
process = _start_worker(index) jellyfin_service_id or "<default>",
)
process = _start_worker(index, jellyfin_service_id or "")
_set_build_metadata( _set_build_metadata(
index, index,
{ {
@@ -156,7 +166,7 @@ def post_build_index(
"build_items_total": 0, "build_items_total": 0,
"build_current_library": "", "build_current_library": "",
"build_library_index": 0, "build_library_index": 0,
"build_libraries_total": len(libraries), "build_libraries_total": 0,
"build_library_progress": None, "build_library_progress": None,
"build_library_items_processed": 0, "build_library_items_processed": 0,
"build_library_items_total": 0, "build_library_items_total": 0,
@@ -272,6 +282,7 @@ def query_media(
sort_order: str = Query("Ascending", description="Ascending or Descending"), sort_order: str = Query("Ascending", description="Ascending or Descending"),
limit: int = Query(100, ge=1, le=1000), limit: int = Query(100, ge=1, le=1000),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
jellyfin_service_id: str | None = None,
client: JellyfinClient = Depends(get_jellyfin_client), client: JellyfinClient = Depends(get_jellyfin_client),
user_id: str = Depends(get_user_id), user_id: str = Depends(get_user_id),
index: MediaIndex = Depends(get_media_index), index: MediaIndex = Depends(get_media_index),
@@ -306,6 +317,7 @@ def query_media(
sort_order=sort_order, sort_order=sort_order,
limit=limit, limit=limit,
offset=offset, offset=offset,
service_id=jellyfin_service_id or "",
) )
logger.info("Media query returned total=%s rows=%s", total, len(rows)) logger.info("Media query returned total=%s rows=%s", total, len(rows))
@@ -1,6 +1,6 @@
"""Monitoring router — observability service status. """Monitoring router — observability service status.
Observability components (Alertmanager, Grafana, Prometheus) are resolved from Observability components (Alertmanager, Prometheus) are resolved from
the service registry, not environment variables. The endpoints pick the first the service registry, not environment variables. The endpoints pick the first
enabled instance of a type when no ``service_id`` is given, and return graceful enabled instance of a type when no ``service_id`` is given, and return graceful
"not configured" / "unreachable" payloads so the UI always renders a health card. "not configured" / "unreachable" payloads so the UI always renders a health card.
@@ -15,34 +15,14 @@ import requests
from fastapi import APIRouter, Body, Depends from fastapi import APIRouter, Body, Depends
from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.service_resolution import resolve_service_record
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.services.targets import build_node_exporter_targets from media_library_viewer_api.services.targets import build_node_exporter_targets
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record from media_library_viewer_api.widgets.sources import ServiceRecord
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _resolve_service_record(
store: SettingsStore, service_type: str, service_id: str | None = None
) -> ServiceRecord | None:
"""Return the requested service instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled instance of ``service_type`` is configured.
"""
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 _base_url(service: ServiceRecord) -> str: def _base_url(service: ServiceRecord) -> str:
return str(service.config.get("base_url") or "").rstrip("/") return str(service.config.get("base_url") or "").rstrip("/")
@@ -104,7 +84,7 @@ def get_alertmanager_alerts(
is configured the endpoint returns an empty summary with an is configured the endpoint returns an empty summary with an
``alertmanager_not_configured`` error so the UI can render a health card. ``alertmanager_not_configured`` error so the UI can render a health card.
""" """
service = _resolve_service_record(store, "alertmanager", service_id) service = resolve_service_record(store, "alertmanager", service_id)
if service is None: if service is None:
return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"} return {"total": 0, "by_severity": {}, "alerts": [], "error": "alertmanager_not_configured"}
try: try:
@@ -149,7 +129,7 @@ def get_alertmanager_status(
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Return Alertmanager cluster/status for the UI health card.""" """Return Alertmanager cluster/status for the UI health card."""
service = _resolve_service_record(store, "alertmanager", service_id) service = resolve_service_record(store, "alertmanager", service_id)
if service is None: if service is None:
return { return {
"up": False, "up": False,
@@ -192,36 +172,13 @@ def get_alertmanager_status(
} }
@router.get("/grafana-status")
def get_grafana_status(
service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Probe a Grafana service instance's ``/api/health`` endpoint."""
service = _resolve_service_record(store, "grafana", service_id)
if service is None:
return _status_response(None, error="no_service_configured")
try:
response = requests.get(
f"{_base_url(service)}/api/health",
headers=_auth_headers(service),
timeout=_timeout(service, 5),
)
response.raise_for_status()
data = response.json()
except Exception:
logger.exception("Failed to fetch Grafana status")
return _status_response(service, error="grafana_unreachable")
return _status_response(service, version=data.get("version", ""))
@router.get("/prometheus-status") @router.get("/prometheus-status")
def get_prometheus_status( def get_prometheus_status(
service_id: str | None = None, service_id: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]: ) -> dict[str, Any]:
"""Probe a Prometheus service instance's health and build info.""" """Probe a Prometheus service instance's health and build info."""
service = _resolve_service_record(store, "prometheus", service_id) service = resolve_service_record(store, "prometheus", service_id)
if service is None: if service is None:
return _status_response(None, error="no_service_configured") return _status_response(None, error="no_service_configured")
base = _base_url(service) base = _base_url(service)
@@ -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,
}
@@ -12,6 +12,7 @@ import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.base import validate_config from media_library_viewer_api.integrations.base import validate_config
@@ -34,6 +35,15 @@ from media_library_viewer_api.widgets.sources import (
get_service_adapter, get_service_adapter,
) )
class WidgetReferenceCreate(BaseModel):
"""Payload for creating a widget reference (live-link)."""
dashboard_scope: str
widget_id: str
sort_order: int = 0
router = APIRouter(prefix="/api/widgets", tags=["widgets"]) router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -103,10 +113,18 @@ def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
@router.get("/instances") @router.get("/instances")
def list_instances( def list_instances(
service_id: str | None = None,
scope: str | None = None,
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
"""Return all persisted widget instances.""" """Return widget instances, optionally filtered.
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
- ``?service_id=X``: only widgets for service X.
- ``?scope=dashboard``: only widgets with NULL service_id.
- ``?scope=service``: only widgets with a non-null service_id.
- No params: all widgets (backward-compatible).
"""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets(service_id=service_id, scope=scope)]
@router.post("/instances", status_code=status.HTTP_201_CREATED) @router.post("/instances", status_code=status.HTTP_201_CREATED)
@@ -213,3 +231,65 @@ async def fetch_data(
error=data.get("error"), error=data.get("error"),
fetched_at=int(time.time()), fetched_at=int(time.time()),
).model_dump() ).model_dump()
# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------
@router.get("/references")
def list_references(
dashboard_scope: str,
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""List widget references for a dashboard scope."""
return store.list_widget_references(dashboard_scope)
@router.post("/references", status_code=status.HTTP_201_CREATED)
def create_reference(
body: WidgetReferenceCreate,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a widget reference (live-link) on a dashboard."""
try:
return store.create_widget_reference(body.dashboard_scope, body.widget_id, body.sort_order)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.delete("/references/{reference_id}")
def delete_reference(
reference_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Remove a widget reference from a dashboard."""
store.delete_widget_reference(reference_id)
return {"status": "deleted"}
@router.put("/references/{reference_id}")
def update_reference(
reference_id: str,
sort_order: int,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Update a widget reference's sort_order (per-dashboard reordering)."""
try:
return store.update_widget_reference(reference_id, sort_order)
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@router.post("/references/{reference_id}/detach")
def detach_reference(
reference_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Clone the referenced widget into a standalone instance and remove the reference."""
try:
cloned = store.detach_widget_reference(reference_id, "")
return WidgetInstance(**cloned).model_dump()
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(exc)) from exc
@@ -17,6 +17,7 @@ from typing import Any, Callable, Iterable
from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.domain.media import display_media_row, normalize_media_item from media_library_viewer_api.domain.media import display_media_row, normalize_media_item
from media_library_viewer_api.path_utils import resolve_remote_media_path from media_library_viewer_api.path_utils import resolve_remote_media_path
from media_library_viewer_api.services.service_data import StorageConcern
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -25,6 +26,20 @@ logger = logging.getLogger(__name__)
DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite") DEFAULT_INDEX_PATH = Path(".cache/media_library_viewer/media_index.sqlite")
MEDIA_TYPES = "Movie,Episode,Video" MEDIA_TYPES = "Movie,Episode,Video"
# Harness concern registration: the media_index DB is scoped by service_id so
# multiple Jellyfin instances can coexist. The ALTER TABLE migration adds the
# service_id column to existing DBs; init_schema adds it for fresh installs.
# The harness run_migrations catches "duplicate column name" on re-runs.
MEDIA_INDEX_CONCERN = StorageConcern(
concern_key="media_index",
db_filename=DEFAULT_INDEX_PATH.name,
migrations=[
"ALTER TABLE media_items ADD COLUMN service_id TEXT NOT NULL DEFAULT ''",
],
tables=["media_items"],
service_id_column="service_id",
)
# Only values from this whitelist are interpolated into ORDER BY. User-selected # Only values from this whitelist are interpolated into ORDER BY. User-selected
# sort keys map to these known SQL snippets to avoid SQL injection. # sort keys map to these known SQL snippets to avoid SQL injection.
SORT_COLUMNS = { SORT_COLUMNS = {
@@ -135,7 +150,8 @@ class MediaIndex:
date_added_ts INTEGER, date_added_ts INTEGER,
path TEXT, path TEXT,
library_id TEXT, library_id TEXT,
library_name TEXT library_name TEXT,
service_id TEXT NOT NULL DEFAULT ''
); );
CREATE TABLE IF NOT EXISTS index_metadata ( CREATE TABLE IF NOT EXISTS index_metadata (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@@ -167,8 +183,13 @@ class MediaIndex:
(key, str(value)), (key, str(value)),
) )
def replace_items(self, rows: Iterable[dict[str, Any]]) -> int: def replace_items(self, rows: Iterable[dict[str, Any]], service_id: str = "") -> int:
"""Atomically replace indexed media rows with a freshly built set.""" """Atomically replace indexed media rows with a freshly built set.
Scoped by ``service_id``: only rows belonging to this service are
deleted before the new batch is inserted. This means building for one
Jellyfin instance no longer wipes another instance's rows.
"""
self.init_schema() self.init_schema()
row_list = list(rows) row_list = list(rows)
columns = [ columns = [
@@ -194,13 +215,14 @@ class MediaIndex:
"path", "path",
"library_id", "library_id",
"library_name", "library_name",
"service_id",
] ]
placeholders = ",".join(["?"] * len(columns)) placeholders = ",".join(["?"] * len(columns))
with self.connect() as conn: with self.connect() as conn:
conn.execute("DELETE FROM media_items") conn.execute("DELETE FROM media_items WHERE service_id = ?", (service_id,))
conn.executemany( conn.executemany(
f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})", f"INSERT OR REPLACE INTO media_items ({','.join(columns)}) VALUES ({placeholders})",
[[row.get(column) for column in columns] for row in row_list], [[row.get(column) if column != "service_id" else service_id for column in columns] for row in row_list],
) )
conn.execute( conn.execute(
"INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)", "INSERT OR REPLACE INTO index_metadata (key, value) VALUES ('updated_at', ?)",
@@ -287,8 +309,14 @@ class MediaIndex:
sort_order: str = "Ascending", sort_order: str = "Ascending",
limit: int = 100, limit: int = 100,
offset: int = 0, offset: int = 0,
service_id: str = "",
) -> tuple[list[dict[str, Any]], int]: ) -> tuple[list[dict[str, Any]], int]:
"""Query indexed media with full-index filters, sorting, and pagination.""" """Query indexed media with full-index filters, sorting, and pagination.
When ``service_id`` is non-empty, only rows matching that service are
returned. When empty (the default), all rows are returned (backward-
compatible with callers that are not multi-instance aware).
"""
self.init_schema() self.init_schema()
where = [] where = []
params: list[Any] = [] params: list[Any] = []
@@ -309,6 +337,9 @@ class MediaIndex:
where.append("hdr = 1") where.append("hdr = 1")
elif hdr_filter == "SDR/unknown only": elif hdr_filter == "SDR/unknown only":
where.append("(hdr IS NULL OR hdr = 0)") where.append("(hdr IS NULL OR hdr = 0)")
if service_id:
where.append("service_id = ?")
params.append(service_id)
where_sql = " WHERE " + " AND ".join(where) if where else "" where_sql = " WHERE " + " AND ".join(where) if where else ""
sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"]) sort_sql = SORT_COLUMNS.get(sort_key, SORT_COLUMNS["title"])
@@ -338,6 +369,7 @@ def build_media_index(
fallback_prefix: str = "", fallback_prefix: str = "",
progress_callback: Callable[[dict[str, Any]], None] | None = None, progress_callback: Callable[[dict[str, Any]], None] | None = None,
should_cancel: Callable[[], bool] | None = None, should_cancel: Callable[[], bool] | None = None,
service_id: str = "",
) -> int: ) -> int:
"""Fetch Jellyfin pages for all selected libraries and rebuild the index.""" """Fetch Jellyfin pages for all selected libraries and rebuild the index."""
index = index or MediaIndex() index = index or MediaIndex()
@@ -453,7 +485,7 @@ def build_media_index(
logger.info("Media index finalizing rows=%s", len(normalized_rows)) logger.info("Media index finalizing rows=%s", len(normalized_rows))
emit("finalizing", "Writing index to disk") emit("finalizing", "Writing index to disk")
ensure_not_cancelled() ensure_not_cancelled()
count = index.replace_items(normalized_rows) count = index.replace_items(normalized_rows, service_id=service_id)
duration = time.perf_counter() - started_at duration = time.perf_counter() - started_at
index.set_metadata("build_duration_seconds", f"{duration:.3f}") index.set_metadata("build_duration_seconds", f"{duration:.3f}")
processed_total = count processed_total = count
@@ -0,0 +1,91 @@
"""Speed-sample storage for qBittorrent widgets.
This module defines the storage concern for qBittorrent speed data and a
bespoke store with ``append``/``window`` operations. It is registered with the
:class:`~media_library_viewer_api.services.service_data.ServiceDataHarness` as
the first real consumer of the harness lifecycle layer.
"""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from media_library_viewer_api.services.service_data import StorageConcern
if TYPE_CHECKING:
from media_library_viewer_api.services.service_data import ServiceDataHarness
logger = logging.getLogger(__name__)
QBITTORRENT_CONCERN = StorageConcern(
concern_key="qbittorrent",
db_filename="qbittorrent.db",
migrations=[
"""
CREATE TABLE IF NOT EXISTS qbittorrent_speed_samples (
service_id TEXT NOT NULL,
ts INTEGER NOT NULL,
dl_speed INTEGER NOT NULL DEFAULT 0,
up_speed INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_qbit_samples_service_ts
ON qbittorrent_speed_samples(service_id, ts);
"""
],
tables=["qbittorrent_speed_samples"],
)
#: Maximum samples kept per service (~2 min at 1 s poll, ~4 min at 2 s poll).
MAX_SAMPLES = 120
class QbittorrentSampleStore:
"""Bespoke speed-sample store for qBittorrent widgets.
Each ``append`` inserts a new sample and prunes entries beyond
:data:`MAX_SAMPLES`, keeping only the most recent rows for the given
``service_id``.
"""
def __init__(self, harness: ServiceDataHarness | None = None) -> None:
if harness is None:
from media_library_viewer_api.services.service_data import get_service_data_harness
harness = get_service_data_harness()
self._harness = harness
def append(self, service_id: str, ts: int, dl_speed: int, up_speed: int) -> None:
"""Append a sample and prune old entries beyond ``MAX_SAMPLES``."""
with self._harness.connect("qbittorrent") as conn:
conn.execute(
"INSERT INTO qbittorrent_speed_samples (service_id, ts, dl_speed, up_speed) VALUES (?, ?, ?, ?)",
(service_id, ts, dl_speed, up_speed),
)
conn.execute(
"DELETE FROM qbittorrent_speed_samples "
"WHERE service_id = ? AND ts NOT IN ("
" SELECT ts FROM qbittorrent_speed_samples"
" WHERE service_id = ?"
" ORDER BY ts DESC LIMIT ?"
")",
(service_id, service_id, MAX_SAMPLES),
)
conn.commit()
def window(self, service_id: str, since_ts: int | None = None) -> list[dict[str, Any]]:
"""Return all samples for a service since a timestamp (or all if ``None``)."""
with self._harness.connect("qbittorrent") as conn:
if since_ts is not None:
rows = conn.execute(
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples "
"WHERE service_id = ? AND ts >= ? ORDER BY ts ASC",
(service_id, since_ts),
).fetchall()
else:
rows = conn.execute(
"SELECT ts, dl_speed, up_speed FROM qbittorrent_speed_samples WHERE service_id = ? ORDER BY ts ASC",
(service_id,),
).fetchall()
return [{"ts": r[0], "dl_speed": r[1], "up_speed": r[2]} for r in rows]
@@ -0,0 +1,158 @@
"""Lifecycle-only storage harness for service-owned persistent data.
This module owns the *lifecycle* of per-concern SQLite databases: provisioning,
schema migrations, and cascade-delete when a service instance is removed. It
does **not** own data operations — each integration implements its own Store
with bespoke operations (``append``/``window``, ``replace_items``/``query``,
etc.). This keeps the general interface narrow (lifecycle) and the specific
interfaces rich (per-integration operations).
Each storage *concern* is registered with a :class:`StorageConcern` dataclass
declaring its DB filename, ordered migration statements, owned tables, and the
column used for service scoping.
"""
from __future__ import annotations
import logging
import os
import sqlite3
from dataclasses import dataclass, field
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class StorageConcern:
"""A per-integration storage namespace registered with the harness."""
concern_key: str # e.g. "qbittorrent", "media_index"
db_filename: str # e.g. "qbittorrent.db", "media_index.sqlite"
migrations: list[str] # ordered CREATE/ALTER statements (idempotent via IF NOT EXISTS or ALTER-catch)
tables: list[str] = field(default_factory=list) # tables owned by this concern (for cascade)
service_id_column: str = "service_id"
class ServiceDataHarness:
"""Lifecycle-only registry of per-concern storage.
Owns:
* DB provisioning (per-concern SQLite files under ``base_dir``).
* Schema migrations (run on first access via :meth:`run_migrations`).
* ``service_id`` cascade-delete when a service instance is removed.
Does **not** own:
* Data operations — each store keeps bespoke append/window/query/etc.
* A generic value table or generic CRUD layer.
"""
def __init__(self, base_dir: Path | str) -> None:
self._base_dir = Path(base_dir)
self._concerns: dict[str, StorageConcern] = {}
@property
def base_dir(self) -> Path:
return self._base_dir
def register(self, concern: StorageConcern) -> None:
"""Register a storage concern. Called at startup / on first access."""
self._concerns[concern.concern_key] = concern
def db_path(self, concern_key: str) -> Path:
"""Return the absolute path to a concern's DB file."""
concern = self._concerns[concern_key]
return self._base_dir / concern.db_filename
def connect(self, concern_key: str) -> sqlite3.Connection:
"""Open a WAL-mode connection to a concern's DB."""
path = self.db_path(concern_key)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=30)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA busy_timeout=30000")
return conn
def run_migrations(self) -> None:
"""Run pending migrations for every registered concern.
Each migration string is split into individual statements (by ``;``)
and executed individually. ``ALTER TABLE ... ADD COLUMN`` statements
that fail with "duplicate column name" are silently skipped, making
migrations idempotent across re-runs and fresh installs where
``init_schema`` may have already created the column.
"""
for concern in self._concerns.values():
path = self.db_path(concern.concern_key)
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path, timeout=30)
try:
conn.execute("PRAGMA journal_mode=WAL")
for migration_sql in concern.migrations:
statements = [s.strip() for s in migration_sql.split(";") if s.strip()]
for stmt in statements:
try:
conn.execute(stmt)
except sqlite3.OperationalError as exc:
lowered = str(exc).lower()
if "duplicate column name" in lowered or "no such table" in lowered:
logger.debug("Skipping migration (already applied or table absent): %s", stmt[:80])
else:
raise
conn.commit()
finally:
conn.close()
def cascade_delete(self, service_id: str) -> None:
"""Delete all rows for a ``service_id`` across every concern's tables.
Called from :meth:`SettingsStore.delete_service` after the service row
is removed. Best-effort: callers wrap in try/except so a harness
failure does not block service deletion.
"""
for concern in self._concerns.values():
col = concern.service_id_column
path = self.db_path(concern.concern_key)
if not path.exists():
continue
with sqlite3.connect(path, timeout=30) as conn:
for table in concern.tables:
cols = {row[1] for row in conn.execute(f"PRAGMA table_info({table})").fetchall()}
if col in cols:
conn.execute(f"DELETE FROM {table} WHERE {col} = ?", (service_id,))
# ---------------------------------------------------------------------------
# Module-level singleton
# ---------------------------------------------------------------------------
_HARNESS: ServiceDataHarness | None = None
def get_service_data_harness() -> ServiceDataHarness:
"""Return the process-wide harness singleton, initializing it on first call.
Lazy registration of built-in concerns happens here (local imports avoid
circular dependencies). Migrations are run immediately after registration.
"""
global _HARNESS
if _HARNESS is None:
base_dir = Path(os.environ.get("BACKEND_CACHE_DIR", ".cache/media_library_viewer"))
_HARNESS = ServiceDataHarness(base_dir)
# Register built-in concerns (lazy import avoids circular dependency).
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN
_HARNESS.register(QBITTORRENT_CONCERN)
from media_library_viewer_api.services.media_index_impl import MEDIA_INDEX_CONCERN
_HARNESS.register(MEDIA_INDEX_CONCERN)
_HARNESS.run_migrations()
return _HARNESS
def reset_service_data_harness() -> None:
"""Reset the singleton (for testing)."""
global _HARNESS
_HARNESS = None
@@ -0,0 +1,37 @@
"""Shared helpers for resolving service instances at request time.
Extracted from the duplicated ``_resolve_service_record`` helpers that lived
in ``routers/monitoring.py`` and ``routers/authentik_users.py``. Both routers
need the same logic: return the requested service instance (by id), or fall
back to the first enabled instance of the type. Returns ``None`` when the
instance does not exist, is the wrong type, is disabled, or when no enabled
instance of the type is configured.
"""
from __future__ import annotations
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ServiceRecord, build_service_record
def resolve_service_record(
store: SettingsStore,
service_type: str,
service_id: str | None = None,
) -> ServiceRecord | None:
"""Return the requested service instance, else the first enabled one.
Returns ``None`` when the instance does not exist / is the wrong type, or
when no enabled instance of ``service_type`` is configured.
"""
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
@@ -8,6 +8,7 @@ in the same UI.
from __future__ import annotations from __future__ import annotations
import json import json
import logging
import sqlite3 import sqlite3
import time import time
import uuid import uuid
@@ -19,6 +20,8 @@ import paramiko
from media_library_viewer_api.models.widgets import _validate_config_keys 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") DEFAULT_SETTINGS_PATH = Path(".cache/media_library_viewer/settings.sqlite")
LOCAL_MACHINE_ID = "local" LOCAL_MACHINE_ID = "local"
DEFAULT_SERVICES = ["monitoring", "files"] DEFAULT_SERVICES = ["monitoring", "files"]
@@ -78,7 +81,7 @@ class SettingsStore:
) )
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)") conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned; # The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan # metrics now live in Prometheus/node_exporter. Drop the orphan
# table on startup so existing databases get a clean slate. # table on startup so existing databases get a clean slate.
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions") conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
conn.execute( conn.execute(
@@ -162,6 +165,19 @@ class SettingsStore:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT") conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
if "widget_kind" not in widget_cols: if "widget_kind" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT") conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS widget_references (
id TEXT PRIMARY KEY,
dashboard_scope TEXT NOT NULL,
widget_id TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
created_at INTEGER NOT NULL,
FOREIGN KEY (widget_id) REFERENCES dashboard_widgets(id) ON DELETE CASCADE
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_widget_references_scope ON widget_references(dashboard_scope)")
conn.execute(""" conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs ( CREATE TABLE IF NOT EXISTS backup_jobs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -172,6 +188,9 @@ class SettingsStore:
created_at INTEGER NOT NULL 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(""" conn.execute("""
CREATE TABLE IF NOT EXISTS backup_runs ( CREATE TABLE IF NOT EXISTS backup_runs (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
@@ -244,6 +263,19 @@ class SettingsStore:
conn.execute( conn.execute(
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)" "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 @staticmethod
def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]: def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]:
@@ -415,6 +447,75 @@ class SettingsStore:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone() row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0: if not row or int(row[0]) == 0:
self._seed_local_machine() 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]]: def list_machines(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
@@ -892,6 +993,7 @@ class SettingsStore:
"source": row["source"], "source": row["source"],
"target": row["target"], "target": row["target"],
"schedule_interval_seconds": row["schedule_interval_seconds"], "schedule_interval_seconds": row["schedule_interval_seconds"],
"service_id": row["service_id"],
"created_at": row["created_at"], "created_at": row["created_at"],
} }
@@ -910,12 +1012,18 @@ class SettingsStore:
schedule_interval_seconds = (current or {}).get("schedule_interval_seconds") schedule_interval_seconds = (current or {}).get("schedule_interval_seconds")
if schedule_interval_seconds is not None: if schedule_interval_seconds is not None:
schedule_interval_seconds = int(schedule_interval_seconds) 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 { return {
"id": job_id, "id": job_id,
"name": name, "name": name,
"source": source, "source": source,
"target": target, "target": target,
"schedule_interval_seconds": schedule_interval_seconds, "schedule_interval_seconds": schedule_interval_seconds,
"service_id": service_id,
} }
def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None: def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None:
@@ -933,17 +1041,19 @@ class SettingsStore:
created_at = int(existing[0]) if existing else now created_at = int(existing[0]) if existing else now
conn.execute( conn.execute(
""" """
INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at) INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at)
VALUES (?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
source = excluded.source, source = excluded.source,
target = excluded.target, 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 ON CONFLICT(name) DO UPDATE SET
source = excluded.source, source = excluded.source,
target = excluded.target, target = excluded.target,
schedule_interval_seconds = excluded.schedule_interval_seconds schedule_interval_seconds = excluded.schedule_interval_seconds,
service_id = excluded.service_id
""", """,
( (
job["id"], job["id"],
@@ -951,6 +1061,7 @@ class SettingsStore:
job["source"], job["source"],
job["target"], job["target"],
job["schedule_interval_seconds"], job["schedule_interval_seconds"],
job["service_id"],
created_at, created_at,
), ),
) )
@@ -1305,10 +1416,36 @@ class SettingsStore:
"sort_order": sort_order, "sort_order": sort_order,
} }
def list_widgets(self) -> list[dict[str, Any]]: def list_widgets(
self,
service_id: str | None = None,
*,
scope: str | None = None,
all_widgets: bool = True,
) -> list[dict[str, Any]]:
"""List widget instances, optionally filtered.
- ``service_id=X``: only widgets for service X.
- ``scope="dashboard"``: only widgets with NULL service_id.
- ``scope="service"``: only widgets with a non-null service_id.
- ``all_widgets=True, service_id=None, scope=None``: all widgets.
"""
self.init_schema() self.init_schema()
clauses: list[str] = []
params: list[Any] = []
if service_id is not None:
clauses.append("service_id = ?")
params.append(service_id)
if scope == "dashboard":
clauses.append("service_id IS NULL")
elif scope == "service":
clauses.append("service_id IS NOT NULL")
where = f" WHERE {' AND '.join(clauses)}" if clauses else ""
with self.connect() as conn: with self.connect() as conn:
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall() rows = conn.execute(
f"SELECT * FROM dashboard_widgets{where} ORDER BY sort_order ASC, created_at ASC",
params,
).fetchall()
return [self._row_to_widget(row) for row in rows] return [self._row_to_widget(row) for row in rows]
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None: def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
@@ -1367,6 +1504,123 @@ class SettingsStore:
self.init_schema() self.init_schema()
with self.connect() as conn: with self.connect() as conn:
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,)) conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
conn.execute("DELETE FROM widget_references WHERE widget_id = ?", (widget_id,))
# ------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ------------------------------------------------------------------
def list_widget_references(self, dashboard_scope: str) -> list[dict[str, Any]]:
"""List widget references for a dashboard scope, joined with widget data."""
self.init_schema()
with self.connect() as conn:
rows = conn.execute(
"""
SELECT wr.id AS ref_id, wr.dashboard_scope, wr.widget_id, wr.sort_order,
wr.created_at AS ref_created_at
FROM widget_references wr
WHERE wr.dashboard_scope = ?
ORDER BY wr.sort_order ASC, wr.created_at ASC
""",
(dashboard_scope,),
).fetchall()
result: list[dict[str, Any]] = []
for row in rows:
widget = self.get_widget(row["widget_id"])
if not widget:
continue
result.append(
{
"id": row["ref_id"],
"dashboard_scope": row["dashboard_scope"],
"widget_id": row["widget_id"],
"sort_order": int(row["sort_order"]),
"created_at": row["ref_created_at"],
"widget": widget,
}
)
return result
def create_widget_reference(self, dashboard_scope: str, widget_id: str, sort_order: int = 0) -> dict[str, Any]:
self.init_schema()
widget = self.get_widget(widget_id)
if not widget:
raise ValueError(f"Widget {widget_id} not found")
ref_id = uuid.uuid4().hex[:12]
now = int(time.time())
with self.connect() as conn:
conn.execute(
"""
INSERT INTO widget_references (id, dashboard_scope, widget_id, sort_order, created_at)
VALUES (?, ?, ?, ?, ?)
""",
(ref_id, dashboard_scope, widget_id, sort_order, now),
)
return {
"id": ref_id,
"dashboard_scope": dashboard_scope,
"widget_id": widget_id,
"sort_order": sort_order,
"created_at": now,
"widget": widget,
}
def delete_widget_reference(self, reference_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM widget_references WHERE id = ?", (reference_id,))
def update_widget_reference(self, reference_id: str, sort_order: int) -> dict[str, Any]:
"""Update only the sort_order on a widget reference (per-dashboard reordering)."""
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT * FROM widget_references WHERE id = ?",
(reference_id,),
).fetchone()
if not row:
raise ValueError(f"Reference {reference_id} not found")
conn.execute(
"UPDATE widget_references SET sort_order = ? WHERE id = ?",
(sort_order, reference_id),
)
widget = self.get_widget(row["widget_id"])
return {
"id": row["id"],
"dashboard_scope": row["dashboard_scope"],
"widget_id": row["widget_id"],
"sort_order": sort_order,
"created_at": int(row["created_at"]),
"widget": widget,
}
def detach_widget_reference(self, reference_id: str, dashboard_scope: str) -> dict[str, Any]:
"""Clone the referenced widget into a new standalone instance owned by the scope."""
self.init_schema()
with self.connect() as conn:
row = conn.execute(
"SELECT widget_id FROM widget_references WHERE id = ?",
(reference_id,),
).fetchone()
if not row:
raise ValueError(f"Reference {reference_id} not found")
source = self.get_widget(row["widget_id"])
if not source:
raise ValueError(f"Source widget {row['widget_id']} not found")
# Clone: copy the widget verbatim including service_id (so service-bound
# widgets keep working), only the id/created_at change.
cloned = self.upsert_widget(
{
"service_id": source.get("service_id"),
"widget_kind": source["widget_kind"],
"title": source["title"],
"config": source["config"],
"enabled": source["enabled"],
"sort_order": source["sort_order"],
}
)
self.delete_widget_reference(reference_id)
return cloned
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Service registry # Service registry
@@ -1486,7 +1740,7 @@ class SettingsStore:
return self.get_service(service["id"]) or service return self.get_service(service["id"]) or service
def delete_service(self, service_id: str) -> None: def delete_service(self, service_id: str) -> None:
"""Delete a service and cascade-delete widgets referencing it.""" """Delete a service and cascade-delete widgets + harness data."""
self.init_schema() self.init_schema()
with self.connect() as conn: with self.connect() as conn:
# The service_id column on dashboard_widgets is added in a later # The service_id column on dashboard_widgets is added in a later
@@ -1499,6 +1753,15 @@ class SettingsStore:
) )
conn.execute("DELETE FROM services WHERE id = ?", (service_id,)) conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
# Cascade-delete harness-managed data (best-effort: the service row is
# already removed; data cleanup must not block service deletion).
try:
from media_library_viewer_api.services.service_data import get_service_data_harness
get_service_data_harness().cascade_delete(service_id)
except Exception:
logger.exception("Failed to cascade-delete harness data for service %s", service_id)
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]: def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Append a service task run history row.""" """Append a service task run history row."""
self.init_schema() self.init_schema()
@@ -1566,6 +1829,113 @@ class SettingsStore:
for row in rows 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 _store: SettingsStore | None = None
@@ -0,0 +1,97 @@
"""Shared helpers for Prometheus range queries.
These two pieces were called out by the spec's downstream-notes as needing a
home: the step-derivation function (SC-104) and the series normalization helper
(SC-102). Keeping them in their own module makes them unit-testable in isolation
and reusable by the chart and mean widget paths (and, later, the in-service data
path of the service-storage-harness change) without ``sources.py`` growing
unbounded.
``normalize_prometheus_matrix`` is a direct extraction of the metric-label →
readable-label rule that previously lived inside the Grafana datasource-proxy
path, retargeted at the native Prometheus ``/api/v1/query_range`` matrix shape so
users migrating a ``grafana/chart`` widget to ``prometheus/chart`` see identical
labels.
"""
from __future__ import annotations
from typing import Any
#: Window presets (SC-108, SC-112). Users pick one of these rather than typing
#: raw ``from``/``to``/``step`` values. Values are window lengths in seconds.
WINDOW_PRESETS: dict[str, int] = {
"1h": 3_600,
"6h": 21_600,
"24h": 86_400,
"7d": 604_800,
}
#: Sentinel values Prometheus serialises for non-finite floats; map these to
#: ``None`` so the frontend renderer can skip them via ``connectNulls``.
_NON_NUMERIC = (None, "NaN", "+Inf", "-Inf")
def step_for_window(window_seconds: int, target_points: int = 200) -> int:
"""Derive a scrape ``step`` for a window that yields ~``target_points`` samples.
Clamped to a minimum of 15 seconds so Prometheus does not reject
sub-15s resolutions on high-cardinality queries. The spec (SC-104) requires
the resulting point count to land in the 100300 band; with
``target_points=200`` every preset yields 200 points.
"""
return max(15, round(window_seconds / target_points))
def normalize_prometheus_matrix(result: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Turn a Prometheus ``/api/v1/query_range`` ``data.result`` matrix into the
``{label, points:[{t:int, v:float|None}]}`` series shape the frontend chart
renderer consumes.
Label rule (matches the removed Grafana path so labels are stable on
migration):
1. Drop ``__name__`` (and any other ``__``-prefixed) metric labels.
2. If labels remain, join them as ``k=v k=v`` (sorted for determinism).
3. Else fall back to ``"value"``.
4. Dedup label collisions with a `` (n)`` suffix.
"""
series: list[dict[str, Any]] = []
seen: dict[str, int] = {}
for entry in result:
metric = entry.get("metric") or {}
values = entry.get("values") or []
parts = [f"{k}={v}" for k, v in sorted(metric.items()) if not str(k).startswith("__")]
label = " ".join(parts) if parts else "value"
if label in seen:
seen[label] += 1
label = f"{label} ({seen[label]})"
else:
seen[label] = 0
points: list[dict[str, Any]] = []
for ts, raw in values:
t = _safe_int(ts)
if t is None:
# Drop samples whose timestamp is unusable rather than raising.
continue
points.append({"t": t, "v": _safe_float(raw)})
series.append({"label": label, "points": points})
return series
def _safe_float(raw: Any) -> float | None:
"""Best-effort float conversion; Prometheus sentinels and junk → ``None``."""
if raw in _NON_NUMERIC:
return None
try:
return float(raw)
except (TypeError, ValueError):
return None
def _safe_int(ts: Any) -> int | None:
"""Best-effort int conversion for a Prometheus timestamp."""
try:
return int(float(ts))
except (TypeError, ValueError):
return None
@@ -12,19 +12,27 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Protocol from typing import Any, Protocol
import requests import requests
from media_library_viewer_api.clients.jellyfin import JellyfinClient from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
from media_library_viewer_api.domain.dashboard import ( from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows, _map_sessions_to_activity_rows,
build_backup_dashboard_summary, build_backup_dashboard_summary,
) )
from media_library_viewer_api.integrations.alertmanager import summarize_alerts from media_library_viewer_api.integrations.alertmanager import summarize_alerts
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
from media_library_viewer_api.services.task_runner import run_saved_task from media_library_viewer_api.services.task_runner import run_saved_task
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_prometheus_matrix,
step_for_window,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -96,29 +104,8 @@ class StaticWidgetSource:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class GrafanaWidgetSource:
"""Build a Grafana deep-link (no embedding)."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Grafana widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
dashboard_uid = config.get("dashboard_uid")
if not dashboard_uid:
return {"error": "dashboard_uid is required"}
url = f"{base_url}/d/{dashboard_uid}"
panel_id = config.get("panel_id")
if panel_id is not None:
url = f"{url}?viewPanel={panel_id}"
return {"url": url}
except Exception as exc:
logger.exception("grafana adapter failed")
return {"error": f"Grafana link failed: {exc}"}
class PrometheusWidgetSource: class PrometheusWidgetSource:
"""Run a PromQL instant query against a Prometheus service.""" """Run PromQL queries against a Prometheus service (instant + range)."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]: async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try: try:
@@ -126,14 +113,63 @@ class PrometheusWidgetSource:
return {"error": "Prometheus widget is missing its service"} return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/") base_url = str(service.config.get("base_url") or "").rstrip("/")
timeout = int(service.config.get("timeout_seconds") or 10) timeout = int(service.config.get("timeout_seconds") or 10)
promql = config.get("promql") if widget_kind == "chart":
if not promql: return await self._fetch_chart(base_url, timeout, config)
return {"error": "promql is required"} if widget_kind == "gauge":
url = f"{base_url}/api/v1/query" return await self._fetch_gauge(base_url, timeout, config)
if widget_kind == "mean":
return await self._fetch_mean(base_url, timeout, config)
# Default: instant-query metric path (unchanged).
raw = await self._instant_query(base_url, timeout, config.get("promql", ""))
return raw
except Exception as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"}
async def _range_query(self, base_url: str, timeout: int, promql: str, window: int) -> dict[str, Any]:
"""Run a Prometheus ``/api/v1/query_range`` over a window (seconds).
Shared by the ``chart`` (SC-101) and ``mean`` widget kinds. Returns
``{"matrix": result}`` on success or ``{"error": str}`` (never raises,
per SC-103).
"""
step = step_for_window(window)
end = int(time.time())
start = end - window
try:
response = await asyncio.wait_for( response = await asyncio.wait_for(
asyncio.to_thread( asyncio.to_thread(
requests.get, requests.get,
url, f"{base_url}/api/v1/query_range",
params={"query": promql, "start": start, "end": end, "step": step},
timeout=timeout,
),
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
except asyncio.TimeoutError:
return {"error": "Prometheus query timed out"}
except requests.RequestException as exc:
logger.exception("prometheus range query failed")
return {"error": f"Prometheus query failed: {exc}"}
result = payload.get("data", {}).get("result", [])
return {"matrix": result}
async def _instant_query(self, base_url: str, timeout: int, promql: str) -> dict[str, Any]:
"""Run a Prometheus ``/api/v1/query`` instant query.
Shared by the ``metric`` and ``gauge`` widget kinds. Returns
``{"result": data}`` on success or ``{"error": str}`` (never raises,
per SC-103).
"""
if not promql:
return {"error": "promql is required"}
try:
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
f"{base_url}/api/v1/query",
params={"query": promql}, params={"query": promql},
timeout=timeout, timeout=timeout,
), ),
@@ -141,15 +177,80 @@ class PrometheusWidgetSource:
) )
response.raise_for_status() response.raise_for_status()
payload = response.json() payload = response.json()
return {"result": payload.get("data", {})}
except asyncio.TimeoutError: except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"} return {"error": "Prometheus query timed out"}
except requests.RequestException as exc: except requests.RequestException as exc:
logger.exception("prometheus adapter failed") logger.exception("prometheus instant query failed")
return {"error": f"Prometheus query failed: {exc}"}
except Exception as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"} return {"error": f"Prometheus query failed: {exc}"}
return {"result": payload.get("data", {})}
async def _fetch_chart(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Range query → ``{series}`` for the chart widget (SC-101..SC-104)."""
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
raw = await self._range_query(base_url, timeout, promql, window)
if "error" in raw:
return raw
return {"series": normalize_prometheus_matrix(raw["matrix"])}
async def _fetch_gauge(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Instant query → scalar for the gauge widget (SC-109, SC-110, SC-111).
Scalar-only: a multi-series query returns an error (SC-111). Threshold
config (``warn_at``/``crit_at``/``min``/``max``/``unit``) is passed
through for the frontend renderer.
"""
raw = await self._instant_query(base_url, timeout, config.get("promql") or "")
if "error" in raw:
return raw
result = raw["result"].get("result", [])
if len(result) != 1:
return {"error": "Gauge requires a single-series query; refine your PromQL"}
try:
value = float(result[0]["value"][1])
except (KeyError, IndexError, ValueError, TypeError):
return {"error": "Gauge query returned no scalar value"}
return {
"value": value,
"warn_at": config.get("warn_at"),
"crit_at": config.get("crit_at"),
"min": config.get("min"),
"max": config.get("max"),
"unit": config.get("unit"),
}
async def _fetch_mean(self, base_url: str, timeout: int, config: dict[str, Any]) -> dict[str, Any]:
"""Range query → client-side mean for the mean widget (SC-112..SC-114).
Runs ``query_range`` over the configured window preset, averages all
non-null numeric samples of the single series, and returns a scalar.
Scalar-only: a multi-series query returns an error (SC-114).
"""
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
window = WINDOW_PRESETS.get(config.get("window", "1h"), WINDOW_PRESETS["1h"])
raw = await self._range_query(base_url, timeout, promql, window)
if "error" in raw:
return raw
result = raw["matrix"]
if len(result) != 1:
return {"error": "Mean requires a single-series query; refine your PromQL"}
points = result[0].get("values") or []
nums: list[float] = []
for _, v in points:
if v in (None, "NaN", "+Inf", "-Inf"):
continue
try:
nums.append(float(v))
except (TypeError, ValueError):
continue
if not nums:
return {"error": "Mean query returned no numeric samples in the window"}
mean = sum(nums) / len(nums)
return {"value": mean, "unit": config.get("unit")}
class AlertmanagerWidgetSource: class AlertmanagerWidgetSource:
@@ -208,6 +309,10 @@ class JellyfinWidgetSource:
asyncio.to_thread(client.sessions), asyncio.to_thread(client.sessions),
timeout=timeout, timeout=timeout,
) )
if widget_kind == "now_playing":
sessions = [
s for s in sessions if s.get("NowPlayingItem") and not s.get("PlayState", {}).get("IsPaused", True)
]
rows = _map_sessions_to_activity_rows(sessions) rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows} return {"sessions": rows}
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -265,13 +370,75 @@ def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeo
logger.exception("failed to record ssh task timeout") logger.exception("failed to record ssh task timeout")
class QbittorrentWidgetSource:
"""Fetch qBittorrent data for totals, active, and speed widgets."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "qBittorrent widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
username = str(service.secrets.get("username") or "")
password = str(service.secrets.get("password") or "")
timeout = int(service.config.get("timeout_seconds") or 10)
if not base_url or not username or not password:
return {"error": "qBittorrent service is missing base_url, username, or password"}
client = QbittorrentClient(base_url, username, password, timeout)
data = await asyncio.wait_for(asyncio.to_thread(client.maindata), timeout=timeout)
server_state = data.get("server_state", {})
torrents = data.get("torrents", {})
if widget_kind == "totals":
by_state: dict[str, int] = {}
for t in torrents.values():
state = str(t.get("state", "unknown"))
by_state[state] = by_state.get(state, 0) + 1
return {"total": len(torrents), "by_state": by_state}
if widget_kind == "active":
active = [
{
"name": t.get("name"),
"state": t.get("state"),
"size": t.get("size"),
"progress": t.get("progress"),
"dl_speed": t.get("dlspeed"),
"up_speed": t.get("upspeed"),
}
for t in torrents.values()
if str(t.get("state", "")) in {"downloading", "uploading"}
]
return {"torrents": active}
if widget_kind == "speed":
dl = int(server_state.get("dl_info_speed", 0))
up = int(server_state.get("up_info_speed", 0))
ts = int(time.time())
store = QbittorrentSampleStore()
store.append(service.id, ts, dl, up)
samples = store.window(service.id)
series = [
{"label": "download", "points": [{"t": s["ts"] * 1000, "v": s["dl_speed"]} for s in samples]},
{"label": "upload", "points": [{"t": s["ts"] * 1000, "v": s["up_speed"]} for s in samples]},
]
return {"series": series}
return {"error": f"Unknown qBittorrent widget kind: {widget_kind}"}
except asyncio.TimeoutError:
return {"error": "qBittorrent data fetch timed out"}
except Exception as exc:
logger.exception("qbittorrent adapter failed")
return {"error": f"qBittorrent fetch failed: {exc}"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Registries # Registries
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = { SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"grafana": GrafanaWidgetSource(),
"prometheus": PrometheusWidgetSource(), "prometheus": PrometheusWidgetSource(),
"qbittorrent": QbittorrentWidgetSource(),
"alertmanager": AlertmanagerWidgetSource(), "alertmanager": AlertmanagerWidgetSource(),
"jellyfin": JellyfinWidgetSource(), "jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(), "ssh_tasks": SshTaskWidgetSource(),
@@ -14,7 +14,6 @@ from pathlib import Path
from typing import Any from typing import Any
from media_library_viewer_api.config import get_settings from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client, get_user_id
from media_library_viewer_api.logging_utils import configure_logging, describe_settings from media_library_viewer_api.logging_utils import configure_logging, describe_settings
from media_library_viewer_api.services.media_index import ( from media_library_viewer_api.services.media_index import (
MediaIndex, MediaIndex,
@@ -88,15 +87,70 @@ def _progress_callback(index: MediaIndex, pid: int, state: dict[str, Any]) -> No
) )
def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> int: def _resolve_jellyfin(service_id: str) -> tuple[Any, str]:
"""Resolve the Jellyfin client + user_id from the settings store.
In a subprocess we cannot use the FastAPI dependency layer (no request),
so we query the settings store directly. When ``service_id`` is given,
resolve that specific instance; otherwise fall back to first-enabled.
"""
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.dependencies import _service_record, get_settings_store
store = get_settings_store()
service = _service_record(store, "jellyfin", service_id or None)
if service is None:
raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
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:
raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
timeout = int(service.get("config", {}).get("timeout_seconds", 10))
client = JellyfinClient(base_url, api_key, timeout)
user_id = str(service.get("config", {}).get("user_id") or "")
if not user_id:
users = client.users()
if not users:
raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
user_id = users[0]["Id"]
else:
# Try the configured user_id directly. It might be the internal
# Jellyfin Id (a long hash) — in that case libraries() succeeds
# without an extra users() round-trip. Only if it fails do we
# resolve it via the users API (the config field accepts usernames
# like 'admin' too, but Jellyfin's API rejects them on /Users/<id>).
try:
client.libraries(user_id)
except Exception:
users = client.users()
match = next((u for u in users if str(u.get("Name", "")) == user_id), None)
if match:
resolved = match["Id"]
logger.info(
"Resolved username '%s' to Jellyfin Id '%s'",
user_id,
resolved,
)
user_id = resolved
elif users:
user_id = users[0]["Id"]
logger.warning(
"user_id '%s' not found; falling back to first user",
service.get("config", {}).get("user_id"),
)
return client, user_id
def run_build(final_index_path: str | Path, staging_index_path: str | Path, service_id: str = "") -> int:
"""Run the media index build in a subprocess.""" """Run the media index build in a subprocess."""
settings = get_settings() settings = get_settings()
configure_logging(settings.log_level) configure_logging(settings.log_level)
logger.info("Media index worker starting: %s", describe_settings(settings)) logger.info("Media index worker starting: %s", describe_settings(settings))
client = get_jellyfin_client()
user_id = get_user_id()
libraries = client.libraries(user_id)
# Create the indexes BEFORE the try block so the except handler can write
# error state to the DB. If _resolve_jellyfin or client.libraries fails,
# the worker needs to record the error — otherwise the status stays
# "queued" forever with no feedback.
final_index = MediaIndex(final_index_path) final_index = MediaIndex(final_index_path)
staging_index = MediaIndex(staging_index_path) staging_index = MediaIndex(staging_index_path)
pid = os.getpid() pid = os.getpid()
@@ -104,10 +158,13 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
staging_path = Path(staging_index.db_path) staging_path = Path(staging_index.db_path)
staging_path.unlink(missing_ok=True) staging_path.unlink(missing_ok=True)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
try: try:
client, user_id = _resolve_jellyfin(service_id)
libraries = client.libraries(user_id)
logger.info("Media index worker pid=%s libraries=%s", pid, len(libraries))
_start_state(final_index, pid, len(libraries))
count = build_media_index( count = build_media_index(
client, client,
user_id, user_id,
@@ -117,6 +174,7 @@ def run_build(final_index_path: str | Path, staging_index_path: str | Path) -> i
fallback_prefix=settings.path_prefix, fallback_prefix=settings.path_prefix,
progress_callback=lambda state: _progress_callback(final_index, pid, state), progress_callback=lambda state: _progress_callback(final_index, pid, state),
should_cancel=lambda: _cancel_requested(final_index), should_cancel=lambda: _cancel_requested(final_index),
service_id=service_id,
) )
# Swap the staging database into place atomically. # Swap the staging database into place atomically.
os.replace(staging_index.db_path, final_index.db_path) os.replace(staging_index.db_path, final_index.db_path)
@@ -192,8 +250,9 @@ def main() -> int:
parser = argparse.ArgumentParser(description="Build the media index in a worker process") parser = argparse.ArgumentParser(description="Build the media index in a worker process")
parser.add_argument("--index-path", required=True) parser.add_argument("--index-path", required=True)
parser.add_argument("--staging-path", required=True) parser.add_argument("--staging-path", required=True)
parser.add_argument("--service-id", default="", help="Jellyfin service instance id")
args = parser.parse_args() args = parser.parse_args()
return run_build(args.index_path, args.staging_path) return run_build(args.index_path, args.staging_path, args.service_id)
if __name__ == "__main__": # pragma: no cover if __name__ == "__main__": # pragma: no cover
+24 -216
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.clients.ssh import CommandResult
from media_library_viewer_api.dependencies import ( from media_library_viewer_api.dependencies import (
get_jellyfin_client, get_jellyfin_client,
get_jellyseerr_client,
get_mail_queue,
get_settings_store, get_settings_store,
get_ssh_client, get_ssh_client,
get_user_id, get_user_id,
@@ -28,6 +26,7 @@ from media_library_viewer_api.widgets.sources import ServiceRecord
# Short alias for the monitoring router module under test. # Short alias for the monitoring router module under test.
_MON = "media_library_viewer_api.routers.monitoring" _MON = "media_library_viewer_api.routers.monitoring"
_SVC = "media_library_viewer_api.services.service_resolution"
# --- Fixtures --- # --- Fixtures ---
@@ -70,38 +69,6 @@ def mock_jellyfin():
return client 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 @pytest.fixture
def mock_ssh(): def mock_ssh():
"""Mock SSH client.""" """Mock SSH client."""
@@ -132,10 +99,9 @@ def mock_ssh():
@pytest.fixture @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.""" """FastAPI test client with mocked dependencies."""
app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin 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_ssh_client] = lambda: mock_ssh
app.dependency_overrides[get_user_id] = lambda: "user123" app.dependency_overrides[get_user_id] = lambda: "user123"
store = SettingsStore(tmp_path / "settings.sqlite") store = SettingsStore(tmp_path / "settings.sqlite")
@@ -293,122 +259,6 @@ class TestSettingsReset:
assert len(store.list_machines()) == 0 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 --- # --- Files ---
@@ -513,7 +363,7 @@ class TestMediaIndexApi:
assert data["status"] == "started" assert data["status"] == "started"
assert data["build_running"] is True assert data["build_running"] is True
assert data["build_stage"] == "queued" assert data["build_stage"] == "queued"
assert data["build_libraries_total"] == len(mock_jellyfin.libraries.return_value) assert data["build_libraries_total"] == 0
assert data["build_pid"] == 4321 assert data["build_pid"] == 4321
start_worker.assert_called_once() start_worker.assert_called_once()
finally: finally:
@@ -647,7 +497,7 @@ class TestMonitoring:
class TestResolveServiceRecord: class TestResolveServiceRecord:
"""Unit tests for _resolve_service_record (service_id + first-enabled paths).""" """Unit tests for resolve_service_record (service_id + first-enabled paths)."""
def _store(self, rows): def _store(self, rows):
store = MagicMock() store = MagicMock()
@@ -660,31 +510,31 @@ class TestResolveServiceRecord:
return store return store
def test_service_id_match_returns_record(self): def test_service_id_match_returns_record(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}} row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": True, "config": {}, "secrets": {}}
store = self._store([row]) store = self._store([row])
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build: with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
result = _resolve_service_record(store, "alertmanager", "am1") result = resolve_service_record(store, "alertmanager", "am1")
assert result == "RECORD" assert result == "RECORD"
mock_build.assert_called_once_with(store, row) mock_build.assert_called_once_with(store, row)
def test_service_id_type_mismatch_returns_none(self): def test_service_id_type_mismatch_returns_none(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "x1", "service_type": "grafana", "name": "G", "enabled": True, "config": {}, "secrets": {}} row = {"id": "x1", "service_type": "prometheus", "name": "P", "enabled": True, "config": {}, "secrets": {}}
store = self._store([row]) store = self._store([row])
assert _resolve_service_record(store, "alertmanager", "x1") is None assert resolve_service_record(store, "alertmanager", "x1") is None
def test_service_id_disabled_returns_none(self): def test_service_id_disabled_returns_none(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record from media_library_viewer_api.services.service_resolution import resolve_service_record
row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}} row = {"id": "am1", "service_type": "alertmanager", "name": "AM", "enabled": False, "config": {}, "secrets": {}}
store = self._store([row]) store = self._store([row])
assert _resolve_service_record(store, "alertmanager", "am1") is None assert resolve_service_record(store, "alertmanager", "am1") is None
def test_no_service_id_returns_first_enabled(self): def test_no_service_id_returns_first_enabled(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record from media_library_viewer_api.services.service_resolution import resolve_service_record
rows = [ rows = [
{ {
@@ -705,16 +555,16 @@ class TestResolveServiceRecord:
}, },
] ]
store = self._store(rows) store = self._store(rows)
with patch(f"{_MON}.build_service_record", return_value="RECORD") as mock_build: with patch(f"{_SVC}.build_service_record", return_value="RECORD") as mock_build:
result = _resolve_service_record(store, "alertmanager", None) result = resolve_service_record(store, "alertmanager", None)
assert result == "RECORD" assert result == "RECORD"
mock_build.assert_called_once_with(store, rows[1]) mock_build.assert_called_once_with(store, rows[1])
def test_no_service_id_and_none_enabled_returns_none(self): def test_no_service_id_and_none_enabled_returns_none(self):
from media_library_viewer_api.routers.monitoring import _resolve_service_record from media_library_viewer_api.services.service_resolution import resolve_service_record
store = self._store([]) store = self._store([])
assert _resolve_service_record(store, "alertmanager", None) is None assert resolve_service_record(store, "alertmanager", None) is None
class TestSettingsMachines: class TestSettingsMachines:
@@ -775,7 +625,7 @@ class TestAlertmanager:
def test_alerts_endpoint_when_unreachable(self, test_client): def test_alerts_endpoint_when_unreachable(self, test_client):
service = _am_service() service = _am_service()
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")), patch(f"{_MON}.requests.get", side_effect=Exception("connection refused")),
): ):
response = test_client.get("/api/monitoring/alerts") response = test_client.get("/api/monitoring/alerts")
@@ -802,7 +652,7 @@ class TestAlertmanager:
} }
resp.raise_for_status = MagicMock() resp.raise_for_status = MagicMock()
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp), patch(f"{_MON}.requests.get", return_value=resp),
): ):
response = test_client.get("/api/monitoring/alerts") response = test_client.get("/api/monitoring/alerts")
@@ -820,7 +670,7 @@ class TestAlertmanager:
resp.json.return_value = {"status": "success", "data": []} resp.json.return_value = {"status": "success", "data": []}
resp.raise_for_status = MagicMock() resp.raise_for_status = MagicMock()
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp) as mock_get, patch(f"{_MON}.requests.get", return_value=resp) as mock_get,
): ):
test_client.get("/api/monitoring/alerts") test_client.get("/api/monitoring/alerts")
@@ -838,7 +688,7 @@ class TestAlertmanager:
def test_alertmanager_status_when_unreachable(self, test_client): def test_alertmanager_status_when_unreachable(self, test_client):
service = _am_service() service = _am_service()
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")), patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
): ):
response = test_client.get("/api/monitoring/alertmanager-status") response = test_client.get("/api/monitoring/alertmanager-status")
@@ -858,7 +708,7 @@ class TestAlertmanager:
} }
resp.raise_for_status = MagicMock() resp.raise_for_status = MagicMock()
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp), patch(f"{_MON}.requests.get", return_value=resp),
): ):
response = test_client.get("/api/monitoring/alertmanager-status") response = test_client.get("/api/monitoring/alertmanager-status")
@@ -891,48 +741,6 @@ class TestAlertmanagerWebhook:
assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text assert "Received Alertmanager webhook with 1 alert(s)" in caplog.text
class TestGrafanaStatus:
def test_grafana_status_when_not_configured(self, test_client):
response = test_client.get("/api/monitoring/grafana-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "no_service_configured"
def test_grafana_status_when_unreachable(self, test_client):
service = ServiceRecord(
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
)
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
):
response = test_client.get("/api/monitoring/grafana-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is False
assert data["error"] == "grafana_unreachable"
assert data["name"] == "Grafana"
def test_grafana_status_returns_version(self, test_client):
service = ServiceRecord(
id="g1", service_type="grafana", name="Grafana", config={"base_url": "http://grafana:3000"}
)
resp = MagicMock()
resp.json.return_value = {"version": "11.3.1", "database": "ok"}
resp.raise_for_status = MagicMock()
with (
patch(f"{_MON}._resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", return_value=resp),
):
response = test_client.get("/api/monitoring/grafana-status")
assert response.status_code == 200
data = response.json()
assert data["up"] is True
assert data["version"] == "11.3.1"
assert data["service_id"] == "g1"
class TestPrometheusStatus: class TestPrometheusStatus:
def test_prometheus_status_when_not_configured(self, test_client): def test_prometheus_status_when_not_configured(self, test_client):
response = test_client.get("/api/monitoring/prometheus-status") response = test_client.get("/api/monitoring/prometheus-status")
@@ -946,7 +754,7 @@ class TestPrometheusStatus:
id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"} id="p1", service_type="prometheus", name="Prometheus", config={"base_url": "http://prometheus:9090"}
) )
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=Exception("refused")), patch(f"{_MON}.requests.get", side_effect=Exception("refused")),
): ):
response = test_client.get("/api/monitoring/prometheus-status") response = test_client.get("/api/monitoring/prometheus-status")
@@ -965,7 +773,7 @@ class TestPrometheusStatus:
build_info.raise_for_status = MagicMock() build_info.raise_for_status = MagicMock()
build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}} build_info.json.return_value = {"status": "success", "data": {"version": "2.55.1"}}
with ( with (
patch(f"{_MON}._resolve_service_record", return_value=service), patch(f"{_MON}.resolve_service_record", return_value=service),
patch(f"{_MON}.requests.get", side_effect=[health, build_info]), patch(f"{_MON}.requests.get", side_effect=[health, build_info]),
): ):
response = test_client.get("/api/monitoring/prometheus-status") response = test_client.get("/api/monitoring/prometheus-status")
+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()
+62 -19
View File
@@ -228,19 +228,70 @@ class TestMediaIndexQuery:
class TestMediaIndexReplace: class TestMediaIndexReplace:
def test_replace_clears_old(self, index): def test_replace_clears_old(self, index):
index.replace_items([ index.replace_items(
{"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"}, [
]) {"id": "x", "title": "Old", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]
)
status = index.status() status = index.status()
assert status.item_count == 1 assert status.item_count == 1
index.replace_items([ index.replace_items(
{"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"}, [
{"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"}, {"id": "y", "title": "New1", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]) {"id": "z", "title": "New2", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]
)
status = index.status() status = index.status()
assert status.item_count == 2 assert status.item_count == 2
def test_replace_scoped_by_service_id_preserves_other_services(self, index):
"""Regression test: building for one Jellyfin must not wipe another's rows.
Before the migration, ``replace_items`` did ``DELETE FROM media_items``
(global clear). This test locks in the fix: a scoped replace preserves
rows belonging to a different service_id.
"""
rows_a = [
{"id": "a1", "title": "Alpha Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
{"id": "a2", "title": "Alpha Show", "type": "Episode", "library_id": "l2", "library_name": "L2"},
]
rows_b = [
{"id": "b1", "title": "Beta Movie", "type": "Movie", "library_id": "l1", "library_name": "L1"},
]
index.replace_items(rows_a, service_id="svc-a")
assert index.status().item_count == 2
# Replacing for svc-b must NOT wipe svc-a's rows.
index.replace_items(rows_b, service_id="svc-b")
assert index.status().item_count == 3 # 2 from svc-a + 1 from svc-b
# Querying svc-a returns only its rows.
rows_a_result, total_a = index.query(
library_ids=["l1", "l2"],
media_types=["Movie", "Episode"],
service_id="svc-a",
)
assert total_a == 2
assert {r["id"] for r in rows_a_result} == {"a1", "a2"}
# Querying svc-b returns only its rows.
rows_b_result, total_b = index.query(
library_ids=["l1"],
media_types=["Movie"],
service_id="svc-b",
)
assert total_b == 1
assert rows_b_result[0]["id"] == "b1"
# Querying with no service_id returns all rows (backward-compat).
_, total_all = index.query(
library_ids=["l1", "l2"],
media_types=["Movie", "Episode"],
)
assert total_all == 3
class TestMediaIndexMetadata: class TestMediaIndexMetadata:
def test_set_and_read_metadata(self, index): def test_set_and_read_metadata(self, index):
@@ -288,15 +339,11 @@ class TestMediaIndexBuildPaths:
self.calls.append(kwargs.get("start_index", 0)) self.calls.append(kwargs.get("start_index", 0))
if kwargs.get("start_index", 0) == 0: if kwargs.get("start_index", 0) == 0:
return { return {
"Items": [ "Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
],
"TotalRecordCount": 2, "TotalRecordCount": 2,
} }
return { return {
"Items": [ "Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
],
"TotalRecordCount": 2, "TotalRecordCount": 2,
} }
@@ -332,15 +379,11 @@ class TestMediaIndexBuildPaths:
self.calls.append(kwargs.get("start_index", 0)) self.calls.append(kwargs.get("start_index", 0))
if kwargs.get("start_index", 0) == 0: if kwargs.get("start_index", 0) == 0:
return { return {
"Items": [ "Items": [{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}],
{"Id": "m1", "Name": "Movie One", "Type": "Movie", "Path": "/media/a.mkv"}
],
"TotalRecordCount": 2, "TotalRecordCount": 2,
} }
return { return {
"Items": [ "Items": [{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}],
{"Id": "m2", "Name": "Movie Two", "Type": "Movie", "Path": "/media/b.mkv"}
],
"TotalRecordCount": 2, "TotalRecordCount": 2,
} }
+106
View File
@@ -0,0 +1,106 @@
"""Unit tests for the shared Prometheus range-query helpers (SC-101..SC-104)."""
from __future__ import annotations
import pytest
from media_library_viewer_api.widgets.prometheus_range import (
WINDOW_PRESETS,
normalize_prometheus_matrix,
step_for_window,
)
class TestStepForWindow:
"""SC-104: every preset must yield 100300 points."""
@pytest.mark.parametrize("preset", sorted(WINDOW_PRESETS))
def test_presets_yield_in_band_point_counts(self, preset: str) -> None:
window = WINDOW_PRESETS[preset]
step = step_for_window(window)
# Clamped minimum.
assert step >= 15
point_count = window // step
assert 100 <= point_count <= 300, f"{preset}: {point_count} points (step={step})"
def test_floor_of_fifteen_seconds(self) -> None:
# A tiny window that would otherwise produce a sub-15s step is clamped.
assert step_for_window(60) == 15
def test_custom_target_points(self) -> None:
# Targeting 100 points for 1h yields step 36 (3600/100).
assert step_for_window(3_600, target_points=100) == 36
class TestNormalizePrometheusMatrix:
"""SC-102: label rule + null handling + dedup."""
def test_empty_matrix(self) -> None:
assert normalize_prometheus_matrix([]) == []
def test_drops_dunder_labels_and_joins(self) -> None:
result = [
{
"metric": {"__name__": "node_cpu_seconds_total", "instance": "host:9100", "mode": "idle"},
"values": [[1_700_000_000, "12.5"], [1_700_000_030, "13.0"]],
}
]
out = normalize_prometheus_matrix(result)
assert len(out) == 1
assert out[0]["label"] == "instance=host:9100 mode=idle"
assert out[0]["points"] == [
{"t": 1_700_000_000, "v": 12.5},
{"t": 1_700_000_030, "v": 13.0},
]
def test_falls_back_to_value_when_no_labels(self) -> None:
result = [{"metric": {}, "values": [[100, "1"]]}]
out = normalize_prometheus_matrix(result)
assert out[0]["label"] == "value"
def test_dedup_collisions_with_suffix(self) -> None:
# Two series with identical visible labels get a "(1)" suffix on the 2nd.
result = [
{"metric": {"job": "x"}, "values": [[1, "1"]]},
{"metric": {"job": "x"}, "values": [[1, "2"]]},
]
out = normalize_prometheus_matrix(result)
labels = [s["label"] for s in out]
assert labels == ["job=x", "job=x (1)"]
def test_non_numeric_sentinels_become_none(self) -> None:
result = [
{
"metric": {"job": "x"},
"values": [
[1, "NaN"],
[2, "+Inf"],
[3, "-Inf"],
[4, "3.5"],
],
}
]
out = normalize_prometheus_matrix(result)
assert out[0]["points"] == [
{"t": 1, "v": None},
{"t": 2, "v": None},
{"t": 3, "v": None},
{"t": 4, "v": 3.5},
]
def test_malformed_values_are_ignored_not_raised(self) -> None:
result = [
{
"metric": {"job": "x"},
"values": [
[1, "3.5"],
["not-a-ts", "9"], # unusable timestamp → dropped
[3, "junk-value"], # unparseable value → v: None
],
}
]
out = normalize_prometheus_matrix(result)
assert out[0]["points"] == [
{"t": 1, "v": 3.5},
{"t": 3, "v": None},
]
+134
View File
@@ -0,0 +1,134 @@
"""Unit tests for the QbittorrentClient."""
from __future__ import annotations
import unittest
from unittest.mock import MagicMock, patch
import requests
from media_library_viewer_api.clients.qbittorrent import QbittorrentClient
class QbittorrentClientTests(unittest.TestCase):
def setUp(self) -> None:
self.client = QbittorrentClient("https://qb.example.com", "admin", "secret", timeout=5)
self.session = MagicMock()
self.client._session = self.session
def _login_response(self, text: str = "Ok.") -> MagicMock:
resp = MagicMock()
resp.text = text
resp.raise_for_status.return_value = None
resp.status_code = 200
return resp
def _get_response(self, json_data: dict, status_code: int = 200) -> MagicMock:
resp = MagicMock()
resp.json.return_value = json_data
resp.raise_for_status.return_value = None
resp.status_code = status_code
resp.text = ""
return resp
def test_base_url_appends_api_v2(self) -> None:
c = QbittorrentClient("https://qb.example.com", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_base_url_keeps_existing_api_v2(self) -> None:
c = QbittorrentClient("https://qb.example.com/api/v2", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_base_url_strips_trailing_slash(self) -> None:
c = QbittorrentClient("https://qb.example.com/", "u", "p")
self.assertEqual(c.base_url, "https://qb.example.com/api/v2")
def test_empty_base_url_raises(self) -> None:
with self.assertRaises(ValueError):
QbittorrentClient("", "u", "p")
def test_empty_username_raises(self) -> None:
with self.assertRaises(ValueError):
QbittorrentClient("https://qb.example.com", "", "p")
def test_login_posts_credentials(self) -> None:
self.session.post.return_value = self._login_response("Ok.")
self.client._login()
self.session.post.assert_called_once()
call_args = self.session.post.call_args
self.assertIn("/auth/login", call_args.args[0])
self.assertEqual(call_args.kwargs["data"], {"username": "admin", "password": "secret"})
self.assertTrue(self.client._logged_in)
def test_login_failure_raises_runtime_error(self) -> None:
self.session.post.return_value = self._login_response("Fails.")
with self.assertRaises(RuntimeError):
self.client._login()
def test_get_auto_logs_in_on_first_call(self) -> None:
"""First _get triggers login, then fetches data."""
self.session.post.return_value = self._login_response("Ok.")
self.session.get.return_value = self._get_response({"server_state": {}, "torrents": {}})
result = self.client._get("/sync/maindata")
self.session.post.assert_called_once() # login happened
self.assertEqual(result, {"server_state": {}, "torrents": {}})
def test_cookie_reuse_does_not_re_login(self) -> None:
"""After login, subsequent _get calls do NOT re-login."""
self.client._logged_in = True # simulate already logged in
self.session.get.return_value = self._get_response({"data": 1})
self.client._get("/some/path")
self.session.post.assert_not_called() # no re-login
def test_403_triggers_re_login(self) -> None:
"""A 403 response triggers re-login and retries the GET."""
self.client._logged_in = True # already logged in from a prior call
forbidden = MagicMock()
forbidden.status_code = 403
ok = self._get_response({"server_state": {}, "torrents": {}})
self.session.get.side_effect = [forbidden, ok]
self.session.post.return_value = self._login_response("Ok.")
result = self.client._get("/sync/maindata")
self.assertEqual(self.session.get.call_count, 2) # initial + retry
self.session.post.assert_called_once() # re-login happened
self.assertEqual(result, {"server_state": {}, "torrents": {}})
def test_maindata_returns_full_payload(self) -> None:
self.client._logged_in = True
payload = {
"server_state": {"dl_info_speed": 12345, "up_info_speed": 6789},
"torrents": {
"abc": {"name": "Movie.mkv", "state": "downloading", "progress": 0.5},
"def": {"name": "Show.mkv", "state": "uploading", "progress": 1.0},
},
}
self.session.get.return_value = self._get_response(payload)
result = self.client.maindata()
self.assertEqual(result["server_state"]["dl_info_speed"], 12345)
self.assertEqual(len(result["torrents"]), 2)
@patch("media_library_viewer_api.clients.qbittorrent.requests.Session")
def test_login_http_error_propagates(self, mock_session_cls: MagicMock) -> None:
"""A network error during login propagates as requests exception."""
mock_session = MagicMock()
mock_session_cls.return_value = mock_session
bad_resp = MagicMock()
bad_resp.raise_for_status.side_effect = requests.ConnectionError("refused")
bad_resp.text = ""
mock_session.post.return_value = bad_resp
client = QbittorrentClient("https://qb.example.com", "u", "p")
with self.assertRaises(requests.ConnectionError):
client._login()
if __name__ == "__main__":
unittest.main()
+148
View File
@@ -0,0 +1,148 @@
"""Tests for ServiceDataHarness lifecycle and QbittorrentSampleStore operations."""
from __future__ import annotations
import pytest
from media_library_viewer_api.services.qbittorrent_store import (
MAX_SAMPLES,
QBITTORRENT_CONCERN,
QbittorrentSampleStore,
)
from media_library_viewer_api.services.service_data import (
ServiceDataHarness,
StorageConcern,
)
# A throwaway concern used to test harness lifecycle in isolation.
_TEST_CONCERN = StorageConcern(
concern_key="test",
db_filename="test.db",
migrations=[
"""
CREATE TABLE IF NOT EXISTS test_items (
id INTEGER PRIMARY KEY,
service_id TEXT NOT NULL,
value TEXT
);
CREATE INDEX IF NOT EXISTS idx_test_service ON test_items(service_id);
"""
],
tables=["test_items"],
)
class TestServiceDataHarnessMigrations:
def test_run_migrations_creates_tables(self, tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
harness.run_migrations()
with harness.connect("test") as conn:
tables = {row[0] for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'").fetchall()}
assert "test_items" in tables
def test_migrations_are_idempotent(self, tmp_path):
"""Re-running migrations on an already-migrated DB must not crash."""
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
harness.run_migrations()
harness.run_migrations() # should not raise
def test_alter_table_idempotency(self, tmp_path):
"""ALTER TABLE ADD COLUMN must be silently skipped on re-run."""
concern = StorageConcern(
concern_key="alter_test",
db_filename="alter.db",
migrations=[
"CREATE TABLE IF NOT EXISTS items (id INTEGER PRIMARY KEY)",
"ALTER TABLE items ADD COLUMN extra TEXT DEFAULT ''",
],
tables=["items"],
)
harness = ServiceDataHarness(tmp_path)
harness.register(concern)
harness.run_migrations()
harness.run_migrations() # second run: "duplicate column name" caught
with harness.connect("alter_test") as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(items)").fetchall()}
assert "extra" in cols
class TestServiceDataHarnessCascadeDelete:
def test_cascade_delete_removes_only_matching_service(self, tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
harness.run_migrations()
with harness.connect("test") as conn:
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (1, 'svc-a', 'a1')")
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (2, 'svc-a', 'a2')")
conn.execute("INSERT INTO test_items (id, service_id, value) VALUES (3, 'svc-b', 'b1')")
conn.commit()
harness.cascade_delete("svc-a")
with harness.connect("test") as conn:
remaining = conn.execute("SELECT service_id, value FROM test_items ORDER BY id").fetchall()
assert len(remaining) == 1
assert remaining[0][0] == "svc-b"
def test_cascade_delete_skips_missing_concern_db(self, tmp_path):
"""cascade_delete on a concern whose DB file doesn't exist should not crash."""
harness = ServiceDataHarness(tmp_path)
harness.register(_TEST_CONCERN)
# No run_migrations → DB file doesn't exist
harness.cascade_delete("svc-x") # should not raise
class TestQbittorrentSampleStore:
@pytest.fixture()
def store(self, tmp_path):
harness = ServiceDataHarness(tmp_path)
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
return QbittorrentSampleStore(harness=harness)
def test_append_and_window(self, store):
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
samples = store.window("svc-1")
assert len(samples) == 3
assert samples[0]["ts"] == 100
assert samples[2]["ts"] == 300
assert samples[1]["dl_speed"] == 600
def test_window_with_since_ts(self, store):
store.append("svc-1", ts=100, dl_speed=500, up_speed=50)
store.append("svc-1", ts=200, dl_speed=600, up_speed=60)
store.append("svc-1", ts=300, dl_speed=700, up_speed=70)
samples = store.window("svc-1", since_ts=200)
assert len(samples) == 2
assert samples[0]["ts"] == 200
def test_prune_enforces_max_samples(self, store):
for i in range(MAX_SAMPLES + 10):
store.append("svc-1", ts=i, dl_speed=i, up_speed=i)
samples = store.window("svc-1")
assert len(samples) == MAX_SAMPLES
# The oldest 10 should have been pruned
assert samples[0]["ts"] == 10
assert samples[-1]["ts"] == MAX_SAMPLES + 9
def test_two_services_do_not_cross_contaminate(self, store):
store.append("svc-a", ts=100, dl_speed=500, up_speed=50)
store.append("svc-b", ts=200, dl_speed=600, up_speed=60)
a_samples = store.window("svc-a")
b_samples = store.window("svc-b")
assert len(a_samples) == 1
assert a_samples[0]["dl_speed"] == 500
assert len(b_samples) == 1
assert b_samples[0]["dl_speed"] == 600
+250 -54
View File
@@ -57,35 +57,65 @@ def client(tmp_path):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def test_registry_contains_seven_service_types(): def test_registry_contains_eight_service_types():
assert set(SERVICE_DEFINITIONS) == { assert set(SERVICE_DEFINITIONS) == {
"grafana",
"prometheus", "prometheus",
"alertmanager", "alertmanager",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"ssh_tasks", "ssh_tasks",
"backups",
"authentik",
"qbittorrent",
} }
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(): 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", "chart", "gauge", "mean"}
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("alertmanager").widget_kinds} == {"active_alerts"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"} assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity", "now_playing"}
assert get_service_definition("nextcloud").widget_kinds == [] 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"} assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
def test_widget_kind_lookup(): def test_widget_kind_lookup():
assert get_widget_kind("grafana", "link") is not None assert get_widget_kind("prometheus", "metric") is not None
assert get_widget_kind("grafana", "missing") is None assert get_widget_kind("prometheus", "missing") is None
assert get_widget_kind("unknown", "link") is None assert get_widget_kind("unknown", "metric") is None
def test_service_config_schema_is_json_schema(): def test_service_config_schema_is_json_schema():
schema = get_service_definition("grafana").config_schema schema = get_service_definition("prometheus").config_schema
assert schema["type"] == "object" assert schema["type"] == "object"
assert "base_url" in schema["properties"] assert "base_url" in schema["properties"]
@@ -139,20 +169,21 @@ def test_list_service_types(client):
types = {item["service_type"] for item in response.json()} types = {item["service_type"] for item in response.json()}
assert types == { assert types == {
"alertmanager", "alertmanager",
"grafana", "authentik",
"backups",
"jellyfin", "jellyfin",
"jellyseerr",
"nextcloud", "nextcloud",
"prometheus", "prometheus",
"qbittorrent",
"ssh_tasks", "ssh_tasks",
} }
def test_service_type_includes_secret_and_widget_metadata(client): def test_service_type_includes_secret_and_widget_metadata(client):
response = client.get("/api/services/types") response = client.get("/api/services/types")
grafana = next(item for item in response.json() if item["service_type"] == "grafana") prom = next(item for item in response.json() if item["service_type"] == "prometheus")
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"] assert [sf["key"] for sf in prom["secret_fields"]] == ["api_key"]
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"] assert set(wk["kind"] for wk in prom["widget_kinds"]) == {"metric", "chart", "gauge", "mean"}
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -160,11 +191,11 @@ def test_service_type_includes_secret_and_widget_metadata(client):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _grafana_payload(**overrides): def _prometheus_payload(**overrides):
payload = { payload = {
"service_type": "grafana", "service_type": "prometheus",
"name": "Production Grafana", "name": "Production Prometheus",
"config": {"base_url": "https://grafana.example.com"}, "config": {"base_url": "https://prometheus.example.com"},
"secrets": {"api_key": "secret-token"}, "secrets": {"api_key": "secret-token"},
"enabled": True, "enabled": True,
} }
@@ -173,11 +204,11 @@ def _grafana_payload(**overrides):
def test_create_and_list_service(client): def test_create_and_list_service(client):
response = client.post("/api/services/instances", json=_grafana_payload()) response = client.post("/api/services/instances", json=_prometheus_payload())
assert response.status_code == 201 assert response.status_code == 201
created = response.json() created = response.json()
assert created["service_type"] == "grafana" assert created["service_type"] == "prometheus"
assert created["config"]["base_url"] == "https://grafana.example.com" assert created["config"]["base_url"] == "https://prometheus.example.com"
# Plaintext secrets are never returned. # Plaintext secrets are never returned.
assert "secrets" not in created assert "secrets" not in created
assert created["secrets_set"] == {"api_key": True} assert created["secrets_set"] == {"api_key": True}
@@ -188,44 +219,44 @@ def test_create_and_list_service(client):
def test_list_instances_filters_by_type(client): def test_list_instances_filters_by_type(client):
client.post("/api/services/instances", json=_grafana_payload()) client.post("/api/services/instances", json=_prometheus_payload())
client.post( client.post(
"/api/services/instances", "/api/services/instances",
json={ json={
"service_type": "prometheus", "service_type": "alertmanager",
"name": "Prom", "name": "AM",
"config": {"base_url": "http://prometheus:9090"}, "config": {"base_url": "http://am:9093"},
}, },
) )
response = client.get("/api/services/instances?service_type=grafana") response = client.get("/api/services/instances?service_type=prometheus")
assert response.status_code == 200 assert response.status_code == 200
assert len(response.json()) == 1 assert len(response.json()) == 1
assert response.json()[0]["service_type"] == "grafana" assert response.json()[0]["service_type"] == "prometheus"
def test_update_service_preserves_unsent_secrets(client): def test_update_service_preserves_unsent_secrets(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json() created = client.post("/api/services/instances", json=_prometheus_payload()).json()
# Update without sending secrets; the existing key should remain set. # Update without sending secrets; the existing key should remain set.
updated = client.put( updated = client.put(
f"/api/services/instances/{created['id']}", f"/api/services/instances/{created['id']}",
json={ json={
"service_type": "grafana", "service_type": "prometheus",
"name": "Renamed Grafana", "name": "Renamed Prometheus",
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10}, "config": {"base_url": "https://prometheus.example.com", "timeout_seconds": 10},
}, },
).json() ).json()
assert updated["name"] == "Renamed Grafana" assert updated["name"] == "Renamed Prometheus"
assert updated["secrets_set"] == {"api_key": True} assert updated["secrets_set"] == {"api_key": True}
def test_update_service_can_clear_secret(client): def test_update_service_can_clear_secret(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json() created = client.post("/api/services/instances", json=_prometheus_payload()).json()
updated = client.put( updated = client.put(
f"/api/services/instances/{created['id']}", f"/api/services/instances/{created['id']}",
json={ json={
"service_type": "grafana", "service_type": "prometheus",
"name": "Production Grafana", "name": "Production Prometheus",
"config": {"base_url": "https://grafana.example.com"}, "config": {"base_url": "https://prometheus.example.com"},
"secrets": {"api_key": ""}, "secrets": {"api_key": ""},
}, },
).json() ).json()
@@ -243,30 +274,28 @@ def test_unknown_service_type_rejected(client):
def test_invalid_config_rejected(client): def test_invalid_config_rejected(client):
response = client.post( response = client.post(
"/api/services/instances", "/api/services/instances",
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}}, json={"service_type": "prometheus", "name": "x", "config": {"base_url": ""}},
) )
assert response.status_code == 422 assert response.status_code == 422
# Force a real validation error via bad type. # Force a real validation error via bad type.
response = client.post( response = client.post(
"/api/services/instances", "/api/services/instances",
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}}, json={"service_type": "prometheus", "name": "x", "config": {"timeout_seconds": "fast"}},
) )
assert response.status_code == 422 assert response.status_code == 422
@pytest.mark.parametrize( @pytest.mark.parametrize(
"bad_url", ["grafana.example.com", "localhost:3000", "//grafana.example.com", "ftp://grafana.example.com"] "bad_url", ["prometheus.example.com", "localhost:3000", "//bad.example.com", "ftp://bad.example.com"]
) )
def test_service_base_url_requires_http_schema(bad_url): def test_service_base_url_requires_http_schema(bad_url):
"""Every service base_url must include an http:// or https:// schema.""" """Every service base_url must include an http:// or https:// schema."""
model = get_service_definition("grafana").config_model model = get_service_definition("prometheus").config_model
with pytest.raises(ValidationError): with pytest.raises(ValidationError):
model.model_validate({"base_url": bad_url, "timeout_seconds": 5}) model.model_validate({"base_url": bad_url, "timeout_seconds": 5})
@pytest.mark.parametrize( @pytest.mark.parametrize("service_type", ["prometheus", "alertmanager", "jellyfin", "authentik", "nextcloud"])
"service_type", ["grafana", "prometheus", "alertmanager", "jellyfin", "jellyseerr", "nextcloud"]
)
def test_service_base_url_accepts_absolute_urls(service_type): def test_service_base_url_accepts_absolute_urls(service_type):
model = get_service_definition(service_type).config_model model = get_service_definition(service_type).config_model
instance = model.model_validate({"base_url": "https://example.com"}) instance = model.model_validate({"base_url": "https://example.com"})
@@ -277,9 +306,9 @@ def test_unknown_secret_field_rejected(client):
response = client.post( response = client.post(
"/api/services/instances", "/api/services/instances",
json={ json={
"service_type": "grafana", "service_type": "prometheus",
"name": "x", "name": "x",
"config": {"base_url": "https://grafana.example.com"}, "config": {"base_url": "https://prometheus.example.com"},
"secrets": {"password": "leak"}, "secrets": {"password": "leak"},
}, },
) )
@@ -290,9 +319,9 @@ def test_credential_key_in_config_rejected(client):
response = client.post( response = client.post(
"/api/services/instances", "/api/services/instances",
json={ json={
"service_type": "grafana", "service_type": "prometheus",
"name": "x", "name": "x",
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"}, "config": {"base_url": "https://prometheus.example.com", "api_key": "leak"},
}, },
) )
assert response.status_code == 422 assert response.status_code == 422
@@ -301,22 +330,22 @@ def test_credential_key_in_config_rejected(client):
def test_update_nonexistent_returns_404(client): def test_update_nonexistent_returns_404(client):
response = client.put( response = client.put(
"/api/services/instances/missing", "/api/services/instances/missing",
json=_grafana_payload(id="missing"), json=_prometheus_payload(id="missing"),
) )
assert response.status_code == 404 assert response.status_code == 404
def test_update_id_mismatch_returns_400(client): def test_update_id_mismatch_returns_400(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json() created = client.post("/api/services/instances", json=_prometheus_payload()).json()
response = client.put( response = client.put(
f"/api/services/instances/{created['id']}", f"/api/services/instances/{created['id']}",
json=_grafana_payload(id="other-id"), json=_prometheus_payload(id="other-id"),
) )
assert response.status_code == 400 assert response.status_code == 400
def test_delete_service(client): def test_delete_service(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json() created = client.post("/api/services/instances", json=_prometheus_payload()).json()
response = client.delete(f"/api/services/instances/{created['id']}") response = client.delete(f"/api/services/instances/{created['id']}")
assert response.status_code == 200 assert response.status_code == 200
assert client.get("/api/services/instances").json() == [] assert client.get("/api/services/instances").json() == []
@@ -340,7 +369,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
""" """
store = app.dependency_overrides[get_settings_store]() store = app.dependency_overrides[get_settings_store]()
service = store.upsert_service( service = store.upsert_service(
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True} {"service_type": "prometheus", "name": "Prometheus", "config": {"base_url": "u"}, "enabled": True}
) )
# Ensure the service_id column exists and seed a referencing widget. # Ensure the service_id column exists and seed a referencing widget.
@@ -354,7 +383,7 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
enabled, sort_order, created_at, updated_at, service_id) enabled, sort_order, created_at, updated_at, service_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", """,
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]), ("w1", "prometheus", "prometheus.metric", "Link", "{}", 1, 0, 1, 1, service["id"]),
) )
store.delete_service(service["id"]) store.delete_service(service["id"])
@@ -367,6 +396,77 @@ def test_delete_service_cascades_to_widgets(client, tmp_path):
assert int(remaining[0]) == 0 assert int(remaining[0]) == 0
# ---------------------------------------------------------------------------
# Harness cascade-delete (Slice 4)
# ---------------------------------------------------------------------------
def test_cascade_delete_removes_harness_data_across_concerns(tmp_path, monkeypatch):
"""Deleting a service cascades to both qbittorrent samples and media items.
Proves end-to-end cascade across both harness-managed concerns, and that
deleting one service preserves another service's data (multi-instance).
"""
monkeypatch.setenv("BACKEND_CACHE_DIR", str(tmp_path))
from media_library_viewer_api.services.media_index_impl import MediaIndex
from media_library_viewer_api.services.qbittorrent_store import QbittorrentSampleStore
from media_library_viewer_api.services.service_data import (
get_service_data_harness,
reset_service_data_harness,
)
reset_service_data_harness()
harness = get_service_data_harness()
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
# --- qBit: create service, add samples, delete, verify gone ---
qbit_a = store.upsert_service(
{"service_type": "qbittorrent", "name": "qA", "config": {"base_url": "http://a"}, "enabled": True}
)
qbit_b = store.upsert_service(
{"service_type": "qbittorrent", "name": "qB", "config": {"base_url": "http://b"}, "enabled": True}
)
sample_store = QbittorrentSampleStore(harness)
sample_store.append(qbit_a["id"], ts=1000, dl_speed=500, up_speed=100)
sample_store.append(qbit_b["id"], ts=1000, dl_speed=200, up_speed=50)
assert len(sample_store.window(qbit_a["id"])) == 1
assert len(sample_store.window(qbit_b["id"])) == 1
store.delete_service(qbit_a["id"])
assert sample_store.window(qbit_a["id"]) == []
assert len(sample_store.window(qbit_b["id"])) == 1 # B survives
# --- MediaIndex: create services, add items, delete, verify scoped ---
jelly_a = store.upsert_service(
{"service_type": "jellyfin", "name": "jA", "config": {"base_url": "http://ja"}, "enabled": True}
)
jelly_b = store.upsert_service(
{"service_type": "jellyfin", "name": "jB", "config": {"base_url": "http://jb"}, "enabled": True}
)
index = MediaIndex(harness.db_path("media_index"))
index.init_schema()
index.replace_items([{"id": "m1", "title": "A1"}], service_id=jelly_a["id"])
index.replace_items([{"id": "m2", "title": "B1"}], service_id=jelly_b["id"])
rows_a, total_a = index.query(service_id=jelly_a["id"])
rows_b, total_b = index.query(service_id=jelly_b["id"])
assert total_a == 1 and total_b == 1
store.delete_service(jelly_a["id"])
rows_a_after, total_a_after = index.query(service_id=jelly_a["id"])
rows_b_after, total_b_after = index.query(service_id=jelly_b["id"])
assert total_a_after == 0 # deleted
assert total_b_after == 1 # survives
reset_service_data_harness()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Service task run history # Service task run history
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -390,3 +490,99 @@ def test_record_and_list_service_task_runs(client):
assert len(runs) == 1 assert len(runs) == 1
assert runs[0]["status"] == "success" assert runs[0]["status"] == "success"
assert runs[0]["stdout_tail"] == "ok" 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") == []
+797 -78
View File
@@ -15,7 +15,7 @@ from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.sources import ( from media_library_viewer_api.widgets.sources import (
AlertmanagerWidgetSource, AlertmanagerWidgetSource,
BackupsWidgetSource, BackupsWidgetSource,
GrafanaWidgetSource, JellyfinWidgetSource,
ServiceRecord, ServiceRecord,
StaticWidgetSource, StaticWidgetSource,
) )
@@ -41,12 +41,12 @@ def client(tmp_path):
app.dependency_overrides.clear() app.dependency_overrides.clear()
def _make_grafana_service(client, name="Production Grafana", **config_overrides): def _make_prometheus_service(client, name="Production Prometheus", **config_overrides):
config = {"base_url": "https://grafana.example.com"} config = {"base_url": "https://prometheus.example.com"}
config.update(config_overrides) config.update(config_overrides)
return client.post( return client.post(
"/api/services/instances", "/api/services/instances",
json={"service_type": "grafana", "name": name, "config": config, "enabled": True}, json={"service_type": "prometheus", "name": name, "config": config, "enabled": True},
).json() ).json()
@@ -90,6 +90,44 @@ def test_create_backups_widget(client):
assert response.status_code == 201 assert response.status_code == 201
def test_widget_filtering_by_service_id_and_scope(client):
"""Test ?service_id= and ?scope= query params on GET /api/widgets/instances."""
service = _make_prometheus_service(client)
# Create a dashboard-scoped (built-in) widget + a service-scoped widget.
client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hi"}},
)
client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "Dash",
"config": {"promql": "up"},
},
)
# No filter: both widgets.
all_widgets = client.get("/api/widgets/instances").json()
assert len(all_widgets) == 2
# Filter by service_id: only the service-scoped one.
by_service = client.get(f"/api/widgets/instances?service_id={service['id']}").json()
assert len(by_service) == 1
assert by_service[0]["service_id"] == service["id"]
# scope=dashboard: only the built-in (NULL service_id).
dashboard_scope = client.get("/api/widgets/instances?scope=dashboard").json()
assert len(dashboard_scope) == 1
assert dashboard_scope[0]["service_id"] is None
# scope=service: only the non-null service_id widget.
service_scope = client.get("/api/widgets/instances?scope=service").json()
assert len(service_scope) == 1
assert service_scope[0]["service_id"] == service["id"]
def test_unknown_builtin_kind_rejected(client): def test_unknown_builtin_kind_rejected(client):
response = client.post( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
@@ -112,29 +150,29 @@ def test_credential_key_in_config_rejected(client):
def test_create_service_bound_widget(client): def test_create_service_bound_widget(client):
service = _make_grafana_service(client) service = _make_prometheus_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dashboard",
"config": {"dashboard_uid": "overview"},
},
)
assert response.status_code == 201
created = response.json()
assert created["service_id"] == service["id"]
assert created["widget_kind"] == "link"
def test_service_bound_widget_unknown_kind_rejected(client):
service = _make_grafana_service(client)
response = client.post( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"service_id": service["id"], "service_id": service["id"],
"widget_kind": "metric", "widget_kind": "metric",
"title": "Metrics",
"config": {"promql": "up"},
},
)
assert response.status_code == 201
created = response.json()
assert created["service_id"] == service["id"]
assert created["widget_kind"] == "metric"
def test_service_bound_widget_unknown_kind_rejected(client):
service = _make_prometheus_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "nonexistent_kind",
"title": "x", "title": "x",
"config": {}, "config": {},
}, },
@@ -147,33 +185,23 @@ def test_service_bound_widget_service_not_found_rejected(client):
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"service_id": "missing", "service_id": "missing",
"widget_kind": "link", "widget_kind": "metric",
"title": "x", "title": "x",
"config": {"dashboard_uid": "u"}, "config": {"promql": "up"},
}, },
) )
assert response.status_code == 422 assert response.status_code == 422
def test_service_bound_widget_invalid_config_rejected(client): def test_service_bound_widget_invalid_config_rejected(client):
service = _make_grafana_service(client) service = _make_prometheus_service(client)
response = client.post( response = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"service_id": service["id"], "service_id": service["id"],
"widget_kind": "link", "widget_kind": "metric",
"title": "x", "title": "x",
"config": {"dashboard_uid": ""}, # empty still validates; use bad type "config": {"promql": 123}, # bad type: promql must be a string
},
)
# Empty string passes Pydantic; force a real failure with a bad type.
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": 123},
}, },
) )
assert response.status_code == 422 assert response.status_code == 422
@@ -241,31 +269,15 @@ def test_fetch_backups_widget_data(client):
assert "total_jobs" in response.json()["data"] assert "total_jobs" in response.json()["data"]
def test_fetch_grafana_link_widget_data(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "Dashboard",
"config": {"dashboard_uid": "overview", "panel_id": 2},
},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
assert response.json()["data"]["url"] == "https://grafana.example.com/d/overview?viewPanel=2"
def test_fetch_widget_service_not_found(client): def test_fetch_widget_service_not_found(client):
service = _make_grafana_service(client) service = _make_prometheus_service(client)
created = client.post( created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"service_id": service["id"], "service_id": service["id"],
"widget_kind": "link", "widget_kind": "metric",
"title": "x", "title": "x",
"config": {"dashboard_uid": "u"}, "config": {"promql": "up"},
}, },
).json() ).json()
# Deleting the service cascade-deletes its widgets, so the widget is gone. # Deleting the service cascade-deletes its widgets, so the widget is gone.
@@ -275,22 +287,22 @@ def test_fetch_widget_service_not_found(client):
def test_fetch_widget_service_disabled(client): def test_fetch_widget_service_disabled(client):
service = _make_grafana_service(client) service = _make_prometheus_service(client)
created = client.post( created = client.post(
"/api/widgets/instances", "/api/widgets/instances",
json={ json={
"service_id": service["id"], "service_id": service["id"],
"widget_kind": "link", "widget_kind": "metric",
"title": "x", "title": "x",
"config": {"dashboard_uid": "u"}, "config": {"promql": "up"},
}, },
).json() ).json()
client.put( client.put(
f"/api/services/instances/{service['id']}", f"/api/services/instances/{service['id']}",
json={ json={
"service_type": "grafana", "service_type": "prometheus",
"name": service["name"], "name": service["name"],
"config": {"base_url": "https://grafana.example.com"}, "config": {"base_url": "https://prometheus.example.com"},
"enabled": False, "enabled": False,
}, },
) )
@@ -308,23 +320,6 @@ def test_fetch_widget_not_found(client):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_grafana_adapter_builds_url():
adapter = GrafanaWidgetSource()
service = ServiceRecord(id="s", service_type="grafana", name="g", config={"base_url": "http://g:3000"})
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov"})
assert result["url"] == "http://g:3000/d/ov"
result = await adapter.fetch(service, "link", {"dashboard_uid": "ov", "panel_id": 4})
assert result["url"] == "http://g:3000/d/ov?viewPanel=4"
@pytest.mark.asyncio
async def test_grafana_adapter_missing_service():
adapter = GrafanaWidgetSource()
result = await adapter.fetch(None, "link", {"dashboard_uid": "ov"})
assert "error" in result
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_alertmanager_adapter_summarizes_alerts(): async def test_alertmanager_adapter_summarizes_alerts():
adapter = AlertmanagerWidgetSource() adapter = AlertmanagerWidgetSource()
@@ -456,3 +451,727 @@ async def test_ssh_task_adapter_records_history_on_run(client):
runs = store.list_service_task_runs(service_id=service["id"]) runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1 assert len(runs) == 1
assert runs[0]["status"] == "success" assert runs[0]["status"] == "success"
# ---------------------------------------------------------------------------
# New widget kind tests (jellyfin now_playing)
# ---------------------------------------------------------------------------
def test_jellyfin_definition_has_now_playing_widget():
from media_library_viewer_api.integrations.registry import get_service_definition
definition = get_service_definition("jellyfin")
kinds = {wk.kind for wk in definition.widget_kinds}
assert "now_playing" in kinds
assert "activity" in kinds
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_runs_range_query():
"""SC-101: chart kind hits /api/v1/query_range and returns {series}."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "up", "instance": "h:9100"},
"values": [[100, "1"], [130, "1"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
# query_range endpoint + window-derived start/end/step params.
call = mock_get.call_args
assert call.args[0].endswith("/api/v1/query_range")
params = call.kwargs["params"]
assert params["query"] == "up"
assert {"start", "end", "step"}.issubset(params)
# {series} shape with the shared normalization (label drops __name__).
assert "series" in result
assert result["series"][0]["label"] == "instance=h:9100"
assert result["series"][0]["points"] == [{"t": 100, "v": 1.0}, {"t": 130, "v": 1.0}]
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090"})
result = await adapter.fetch(service, "chart", {"promql": ""})
assert result == {"error": "promql is required"}
@pytest.mark.asyncio
async def test_prometheus_chart_adapter_degrades_on_http_error():
"""SC-103: a connection error returns {error} rather than raising."""
import requests as req_mod
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s", service_type="prometheus", name="p", config={"base_url": "http://p:9090", "timeout_seconds": 2}
)
with patch("media_library_viewer_api.widgets.sources.requests.get", side_effect=req_mod.ConnectionError("refused")):
result = await adapter.fetch(service, "chart", {"promql": "up", "window": "1h"})
assert "error" in result
assert "failed" in result["error"].lower()
@pytest.mark.asyncio
async def test_jellyfin_now_playing_filters_active_sessions():
"""now_playing should exclude idle (no NowPlayingItem) and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
playing_session = {
"UserName": "alice",
"NowPlayingItem": {"Name": "Movie", "Type": "Movie"},
"PlayState": {"IsPaused": False},
"DeviceName": "Web",
}
paused_session = {
"UserName": "bob",
"NowPlayingItem": {"Name": "Show", "Type": "Episode"},
"PlayState": {"IsPaused": True},
"DeviceName": "TV",
}
idle_session = {
"UserName": "carol",
"PlayState": {"IsPaused": False},
"DeviceName": "Phone",
}
mock_client = SimpleNamespace(sessions=lambda: [playing_session, paused_session, idle_session])
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "now_playing", {})
sessions = result["sessions"]
assert len(sessions) == 1
assert sessions[0]["user"] == "alice"
assert sessions[0]["state"] == "playing"
@pytest.mark.asyncio
async def test_jellyfin_activity_shows_all_sessions():
"""activity (default) should include idle and paused sessions."""
adapter = JellyfinWidgetSource()
service = ServiceRecord(
id="s",
service_type="jellyfin",
name="jf",
config={"base_url": "http://jf:8096"},
secrets={"api_key": "k"},
)
mock_client = SimpleNamespace(
sessions=lambda: [
{"UserName": "alice", "NowPlayingItem": {"Name": "M"}, "PlayState": {"IsPaused": False}},
{"UserName": "bob", "PlayState": {"IsPaused": False}},
]
)
with patch("media_library_viewer_api.widgets.sources.JellyfinClient", return_value=mock_client):
result = await adapter.fetch(service, "activity", {})
assert len(result["sessions"]) == 2
# ---------------------------------------------------------------------------
# Widget references (live-link widgets across dashboards)
# ---------------------------------------------------------------------------
@pytest.fixture
def widget_ref_client(monkeypatch):
"""TestClient with an isolated SettingsStore + encryption key."""
monkeypatch.setenv(
"MANAGE_ENCRYPTION_KEY",
Fernet.generate_key().decode(),
)
from media_library_viewer_api.services.secrets import reset_encryption_key_cache
reset_encryption_key_cache()
import tempfile
from pathlib import Path
store = SettingsStore(str(Path(tempfile.mkdtemp()) / "test.db"))
store.ensure_defaults()
def get_store_override():
return store
app.dependency_overrides[get_settings_store] = get_store_override
client = TestClient(app)
yield client, store
app.dependency_overrides.pop(get_settings_store, None)
def test_widget_reference_lifecycle(widget_ref_client):
"""Create a widget, reference it on 'main', verify it appears, delete reference."""
client, store = widget_ref_client
# Create a service-bound widget (simulating one on a Prometheus service).
store.upsert_service(
{
"service_type": "prometheus",
"name": "Prometheus",
"config": {"base_url": "https://prometheus.example.com"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
)
service = store.list_services("prometheus")[0]
widget = store.upsert_widget(
{
"service_id": service["id"],
"widget_kind": "chart",
"title": "CPU IOWait",
"config": {"promql": "rate(cpu[5m])", "window": "1h"},
"enabled": True,
"sort_order": 0,
}
)
# Reference it on "main" dashboard.
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "main",
"widget_id": widget["id"],
"sort_order": 5,
},
)
assert resp.status_code == 201
ref = resp.json()
assert ref["dashboard_scope"] == "main"
assert ref["widget_id"] == widget["id"]
ref_id = ref["id"]
# List references for "main" — should include our widget.
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
assert resp.status_code == 200
refs = resp.json()
assert len(refs) == 1
assert refs[0]["widget"]["title"] == "CPU IOWait"
# Delete the reference.
resp = client.delete(f"/api/widgets/references/{ref_id}")
assert resp.status_code == 200
assert resp.json()["status"] == "deleted"
# Reference is gone, original widget still exists.
resp = client.get("/api/widgets/references", params={"dashboard_scope": "main"})
assert len(resp.json()) == 0
assert store.get_widget(widget["id"]) is not None
def test_widget_reference_detach(widget_ref_client):
"""Detach clones the widget into a standalone instance and removes the reference."""
client, store = widget_ref_client
store.upsert_service(
{
"service_type": "prometheus",
"name": "Prometheus",
"config": {"base_url": "https://prometheus.example.com"},
"secrets": {"api_key": "tok"},
"enabled": True,
},
)
service = store.list_services("prometheus")[0]
widget = store.upsert_widget(
{
"service_id": service["id"],
"widget_kind": "chart",
"title": "Memory",
"config": {"promql": "mem", "window": "1h"},
"enabled": True,
"sort_order": 0,
}
)
# Reference on "main".
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "main",
"widget_id": widget["id"],
},
)
ref_id = resp.json()["id"]
# Detach.
resp = client.post(f"/api/widgets/references/{ref_id}/detach")
assert resp.status_code == 200
cloned = resp.json()
assert cloned["title"] == "Memory"
assert cloned["widget_kind"] == "chart"
assert cloned["service_id"] == service["id"] # Fix 2: preserves service binding
assert cloned["config"]["promql"] == "mem"
assert cloned["id"] != widget["id"] # new independent widget
# Reference is gone.
refs = client.get("/api/widgets/references", params={"dashboard_scope": "main"}).json()
assert len(refs) == 0
# Original still exists.
assert store.get_widget(widget["id"]) is not None
def test_widget_reference_update_sort_order(widget_ref_client):
"""PUT /references/{id} updates only the reference's sort_order (Fix 1)."""
client, store = widget_ref_client
widget_a = store.upsert_widget(
{
"service_id": None,
"widget_kind": "static",
"title": "A",
"config": {"text": "a"},
"enabled": True,
"sort_order": 0,
}
)
widget_b = store.upsert_widget(
{
"service_id": None,
"widget_kind": "static",
"title": "B",
"config": {"text": "b"},
"enabled": True,
"sort_order": 1,
}
)
# Two references on the same dashboard scope.
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "named:test",
"widget_id": widget_a["id"],
"sort_order": 0,
},
)
ref_a = resp.json()
resp = client.post(
"/api/widgets/references",
json={
"dashboard_scope": "named:test",
"widget_id": widget_b["id"],
"sort_order": 1,
},
)
ref_b = resp.json()
# Swap sort orders via PUT (per-dashboard reorder).
resp = client.put(f"/api/widgets/references/{ref_a['id']}?sort_order=1")
assert resp.status_code == 200
assert resp.json()["sort_order"] == 1
resp = client.put(f"/api/widgets/references/{ref_b['id']}?sort_order=0")
assert resp.status_code == 200
assert resp.json()["sort_order"] == 0
# Widget instances themselves are unchanged.
assert store.get_widget(widget_a["id"])["sort_order"] == 0
assert store.get_widget(widget_b["id"])["sort_order"] == 1
# ---------------------------------------------------------------------------
# Prometheus gauge + mean adapter tests (SC-109..SC-114)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_returns_scalar():
"""SC-109: gauge kind hits /api/v1/query and returns {value, thresholds}."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"__name__": "cpu"}, "value": [100, "0.75"]},
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload) as mock_get:
result = await adapter.fetch(
service,
"gauge",
{
"promql": "cpu_usage",
"warn_at": 0.8,
"crit_at": 0.95,
"unit": "%",
},
)
call = mock_get.call_args
assert call.args[0].endswith("/api/v1/query")
assert call.kwargs["params"]["query"] == "cpu_usage"
assert result["value"] == 0.75
assert result["warn_at"] == 0.8
assert result["crit_at"] == 0.95
assert result["unit"] == "%"
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_rejects_multi_series():
"""SC-111: gauge must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"instance": "a"}, "value": [100, "1"]},
{"metric": {"instance": "b"}, "value": [100, "2"]},
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "gauge", {"promql": "up"})
assert "error" in result
assert "single-series" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_gauge_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
result = await adapter.fetch(service, "gauge", {"promql": ""})
assert result == {"error": "promql is required"}
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_computes_average():
"""SC-112: mean kind averages non-null values over the window."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090", "timeout_seconds": 5},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {"__name__": "cpu"},
"values": [[100, "1.0"], [130, "2.0"], [160, "3.0"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "cpu", "window": "1h"})
assert result["value"] == 2.0
assert result["unit"] is None
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_rejects_multi_series():
"""SC-114: mean must be scalar-only; multi-series returns error."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{"metric": {"instance": "a"}, "values": [[100, "1"]]},
{"metric": {"instance": "b"}, "values": [[100, "2"]]},
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
assert "error" in result
assert "single-series" in result["error"].lower()
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_skips_nan_values():
"""SC-112: NaN / Inf values are excluded from the mean computation."""
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
payload = SimpleNamespace(
raise_for_status=lambda: None,
json=lambda: {
"data": {
"result": [
{
"metric": {},
"values": [[100, "2.0"], [130, "NaN"], [160, "4.0"]],
}
]
}
},
)
with patch("media_library_viewer_api.widgets.sources.requests.get", return_value=payload):
result = await adapter.fetch(service, "mean", {"promql": "up", "window": "1h"})
# (2.0 + 4.0) / 2 = 3.0 (NaN excluded)
assert result["value"] == 3.0
@pytest.mark.asyncio
async def test_prometheus_mean_adapter_requires_promql():
from media_library_viewer_api.widgets.sources import PrometheusWidgetSource
adapter = PrometheusWidgetSource()
service = ServiceRecord(
id="s",
service_type="prometheus",
name="p",
config={"base_url": "http://p:9090"},
)
result = await adapter.fetch(service, "mean", {"promql": ""})
assert result == {"error": "promql is required"}
# ---------------------------------------------------------------------------
# qBittorrent widget source adapter
# ---------------------------------------------------------------------------
def _fake_qbit_maindata():
"""Return a mock maindata response (server_state + torrents dict)."""
return {
"server_state": {"dl_info_speed": 500000, "up_info_speed": 100000},
"torrents": {
"h1": {
"name": "Movie.mkv",
"state": "downloading",
"size": 1000,
"progress": 0.5,
"dlspeed": 500,
"upspeed": 10,
},
"h2": {
"name": "Show.mkv",
"state": "uploading",
"size": 2000,
"progress": 1.0,
"dlspeed": 0,
"upspeed": 100,
},
"h3": {
"name": "Queued",
"state": "queuedDL",
"size": 3000,
"progress": 0.0,
"dlspeed": 0,
"upspeed": 0,
},
"h4": {
"name": "Paused",
"state": "pausedDL",
"size": 4000,
"progress": 0.3,
"dlspeed": 0,
"upspeed": 0,
},
},
}
@pytest.mark.asyncio
async def test_qbittorrent_totals_counts_all_torrents():
"""Totals kind returns count of all listed items + by_state breakdown."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "totals", {})
assert result["total"] == 4
assert result["by_state"]["downloading"] == 1
assert result["by_state"]["uploading"] == 1
assert result["by_state"]["queuedDL"] == 1
assert result["by_state"]["pausedDL"] == 1
@pytest.mark.asyncio
async def test_qbittorrent_active_filters_dl_ul_only():
"""Active kind returns only downloading/uploading torrents (Q3)."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-1",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
result = await adapter.fetch(service, "active", {})
active = result["torrents"]
assert len(active) == 2
names = [t["name"] for t in active]
assert "Movie.mkv" in names
assert "Show.mkv" in names
# Queued and paused are excluded
assert "Queued" not in names
assert "Paused" not in names
@pytest.mark.asyncio
async def test_qbittorrent_speed_appends_and_returns_series(tmp_path):
"""Speed kind appends a sample and returns {series} with two labeled series."""
from media_library_viewer_api.services.qbittorrent_store import QBITTORRENT_CONCERN, QbittorrentSampleStore
from media_library_viewer_api.services.service_data import ServiceDataHarness
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
# Isolated harness so we don't pollute the real DB
harness = ServiceDataHarness(base_dir=str(tmp_path))
harness.register(QBITTORRENT_CONCERN)
harness.run_migrations()
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="svc-speed",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 5},
secrets={"username": "admin", "password": "pass"},
)
with (
patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client,
patch("media_library_viewer_api.widgets.sources.QbittorrentSampleStore") as mock_store_cls,
):
mock_client.return_value.maindata.return_value = _fake_qbit_maindata()
# Wire the mock store to a real isolated store
real_store = QbittorrentSampleStore(harness)
mock_store_cls.return_value = real_store
result = await adapter.fetch(service, "speed", {})
assert "series" in result
labels = [s["label"] for s in result["series"]]
assert labels == ["download", "upload"]
# The sample just appended should be present
dl_points = result["series"][0]["points"]
assert len(dl_points) >= 1
# timestamps multiplied by 1000 for JS epoch
assert dl_points[-1]["v"] == 500000
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_service():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
result = await adapter.fetch(None, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_missing_credentials():
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080"},
secrets={"username": "", "password": ""},
)
result = await adapter.fetch(service, "totals", {})
assert "error" in result
@pytest.mark.asyncio
async def test_qbittorrent_adapter_timeout():
"""A timeout returns {error} rather than raising."""
from media_library_viewer_api.widgets.sources import QbittorrentWidgetSource
adapter = QbittorrentWidgetSource()
service = ServiceRecord(
id="s",
service_type="qbittorrent",
name="qbit",
config={"base_url": "http://qbit:8080", "timeout_seconds": 1},
secrets={"username": "admin", "password": "pass"},
)
with patch("media_library_viewer_api.widgets.sources.QbittorrentClient") as mock_client:
import asyncio as _asyncio
async def _slow(*a, **kw):
await _asyncio.sleep(10)
# Make to_thread hang so wait_for times out
mock_client.return_value.maindata.side_effect = lambda: (_ for _ in ()).throw(TimeoutError())
result = await adapter.fetch(service, "totals", {})
assert "error" in result
+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 - Backup tool uses auto-generated Bearer API key
- Frontend uses existing OIDC/JWT auth - 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.
+367 -1
View File
@@ -20,6 +20,7 @@
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1", "react-oidc-context": "^3.3.1",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"recharts": "^3.9.2",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
@@ -2997,6 +2998,32 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@reduxjs/toolkit": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.12.0.tgz",
"integrity": "sha512-KiT+RzZbp6mQET+Mg+h2c97+9j1sNflUxQkIHI7Yuzf6Peu+OYpmkn6nbHWmLLWj+1ZODUJFwGZ7gx3L9R9EOw==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@rolldown/binding-android-arm64": { "node_modules/@rolldown/binding-android-arm64": {
"version": "1.0.0-rc.17", "version": "1.0.0-rc.17",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
@@ -3283,7 +3310,12 @@
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true, "license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@tailwindcss/node": { "node_modules/@tailwindcss/node": {
@@ -3772,6 +3804,69 @@
"assertion-error": "^2.0.1" "assertion-error": "^2.0.1"
} }
}, },
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
"license": "MIT"
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
"license": "MIT",
"dependencies": {
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/deep-eql": { "node_modules/@types/deep-eql": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
@@ -3852,6 +3947,12 @@
"integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@types/validate-npm-package-name": { "node_modules/@types/validate-npm-package-name": {
"version": "4.0.2", "version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz", "resolved": "https://registry.npmjs.org/@types/validate-npm-package-name/-/validate-npm-package-name-4.0.2.tgz",
@@ -4990,6 +5091,127 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-ease": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
"license": "ISC",
"dependencies": {
"d3-color": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/data-uri-to-buffer": { "node_modules/data-uri-to-buffer": {
"version": "4.0.1", "version": "4.0.1",
"resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz",
@@ -5037,6 +5259,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/dedent": { "node_modules/dedent": {
"version": "1.7.2", "version": "1.7.2",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz", "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
@@ -5311,6 +5539,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es-toolkit": {
"version": "1.49.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.49.0.tgz",
"integrity": "sha512-G5iZ6Pc/FNRY/soKZHC+TxGDD83rHUDXxzaWhGCX44vAv/tMs56WMusnm/KMNK+luUPsgA9U28cGr4RDlSzL2g==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/escalade": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -5553,6 +5791,12 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/eventsource": { "node_modules/eventsource": {
"version": "3.0.7", "version": "3.0.7",
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
@@ -6291,6 +6535,16 @@
"node": ">= 4" "node": ">= 4"
} }
}, },
"node_modules/immer": {
"version": "11.1.11",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.11.tgz",
"integrity": "sha512-qzXuyXAkPySAGYkfsAwodDPWT8Zm7/Uo5BNt4BjhMhG5WlWyZZ4wQqnWwdS8kjlQ1Cwu6gjw3A6+0gTQwlyYtw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": { "node_modules/import-fresh": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -6333,6 +6587,15 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.2.0", "version": "10.2.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
@@ -8109,6 +8372,13 @@
"react": "^19.2.5" "react": "^19.2.5"
} }
}, },
"node_modules/react-is": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.7.tgz",
"integrity": "sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==",
"license": "MIT",
"peer": true
},
"node_modules/react-oidc-context": { "node_modules/react-oidc-context": {
"version": "3.3.1", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz", "resolved": "https://registry.npmjs.org/react-oidc-context/-/react-oidc-context-3.3.1.tgz",
@@ -8122,6 +8392,29 @@
"react": ">=16.14.0" "react": ">=16.14.0"
} }
}, },
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
"integrity": "sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-remove-scroll": { "node_modules/react-remove-scroll": {
"version": "2.7.2", "version": "2.7.2",
"resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz",
@@ -8254,6 +8547,36 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/recharts": {
"version": "3.9.2",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.9.2.tgz",
"integrity": "sha512-G4fy+Pk46RaXgwWMh+Nzhyo/lbFAVqXo9gtetlyehe6Ehge9CsgDuOTwQDD+i1+llaLktNBiNq4bhnGlDRXFtw==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^11.1.8",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.2.0",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redent": { "node_modules/redent": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -8268,6 +8591,21 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/require-directory": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -8286,6 +8624,12 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/reselect": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz",
"integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==",
"license": "MIT"
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.12", "version": "1.22.12",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz",
@@ -9377,6 +9721,28 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": { "node_modules/vite": {
"version": "8.0.10", "version": "8.0.10",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
+1
View File
@@ -25,6 +25,7 @@
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1", "react-oidc-context": "^3.3.1",
"react-router-dom": "^7.14.2", "react-router-dom": "^7.14.2",
"recharts": "^3.9.2",
"shadcn": "^4.7.0", "shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0", "tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
+77 -64
View File
@@ -5,7 +5,6 @@ import {
NavLink, NavLink,
useLocation, useLocation,
Outlet, Outlet,
Navigate,
} from "react-router-dom"; } from "react-router-dom";
import { import {
QueryClient, QueryClient,
@@ -13,21 +12,22 @@ import {
useQuery, useQuery,
} from "@tanstack/react-query"; } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { LucideIcon } from "lucide-react";
import { AuthProvider, useAuth } from "react-oidc-context"; import { AuthProvider, useAuth } from "react-oidc-context";
import { Dashboard } from "./pages/Dashboard"; import { Dashboard } from "./pages/Dashboard";
import { Applications } from "./pages/Applications"; import { NamedDashboardPage } from "./pages/NamedDashboardPage";
import { Settings } from "./pages/Settings"; 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 { ServicePage } from "./pages/ServicePage";
import { ServiceTypePage } from "./pages/ServiceTypePage";
import { ServicesPage } from "./pages/ServicesPage"; import { ServicesPage } from "./pages/ServicesPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth"; import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client"; import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version"; import { FRONTEND_VERSION_LABEL } from "./version";
import { usePersistentState } from "./hooks/usePersistentState"; 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 { Button } from "@/components/ui/button";
import { import {
Tooltip, Tooltip,
@@ -44,12 +44,6 @@ import {
} from "@/components/ui/sheet"; } from "@/components/ui/sheet";
import { import {
LayoutDashboard, LayoutDashboard,
Activity,
DatabaseBackup,
Monitor,
Users,
Zap,
FolderOpen,
Settings as SettingsIcon, Settings as SettingsIcon,
Menu, Menu,
Sun, Sun,
@@ -58,10 +52,17 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Boxes, Boxes,
LayoutTemplate,
} from "lucide-react"; } from "lucide-react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, refetchOnWindowFocus: false } }, defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
refetchIntervalInBackground: false,
},
},
}); });
function useDarkMode() { function useDarkMode() {
@@ -82,18 +83,40 @@ function useDarkMode() {
return [darkMode, () => setDarkMode((prev) => !prev)] as const; return [darkMode, () => setDarkMode((prev) => !prev)] as const;
} }
// Navigation items for sidebar // Navigation items are data-driven (spec R1). Built from configured services + dashboards.
const navItems = [ interface NavItem {
{ path: "/", label: "Dashboard", icon: LayoutDashboard }, path: string;
{ path: "/observability", label: "Observability", icon: Activity }, label: string;
{ path: "/media", label: "Media", icon: Monitor }, icon: LucideIcon;
{ path: "/files", label: "Files", icon: FolderOpen }, }
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users }, function useNavItems() {
{ path: "/actions", label: "Actions", icon: Zap }, const { data: services = [] } = useServiceInstances();
{ path: "/services", label: "Services", icon: Boxes }, const { data: dashboards = [] } = useDashboards();
{ path: "/settings", label: "Settings", icon: SettingsIcon },
]; 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({ function Sidebar({
collapsed, collapsed,
@@ -105,6 +128,7 @@ function Sidebar({
isMobile: boolean; isMobile: boolean;
}) { }) {
const location = useLocation(); const location = useLocation();
const navItems = useNavItems();
if (isMobile) return null; if (isMobile) return null;
@@ -189,6 +213,7 @@ function Sidebar({
function MobileDrawer() { function MobileDrawer() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const location = useLocation(); const location = useLocation();
const navItems = useNavItems();
return ( return (
<Sheet open={open} onOpenChange={setOpen}> <Sheet open={open} onOpenChange={setOpen}>
@@ -253,6 +278,7 @@ function TopBar({
}); });
const backendLabel = appVersion?.backend_label || "…"; const backendLabel = appVersion?.backend_label || "…";
const navItems = useNavItems();
const pageTitle = const pageTitle =
navItems.find((item) => item.path === location.pathname)?.label || navItems.find((item) => item.path === location.pathname)?.label ||
"Dashboard"; "Dashboard";
@@ -316,16 +342,7 @@ function ShellLayout({
onToggleDarkMode: () => void; onToggleDarkMode: () => void;
}) { }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState( const isMobile = useIsMobile();
() => 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 ( return (
<div className="min-h-screen bg-background"> <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() { function AppInner() {
const [darkMode, toggleDarkMode] = useDarkMode(); const [darkMode, toggleDarkMode] = useDarkMode();
@@ -438,26 +467,18 @@ function AppInner() {
<Routes> <Routes>
<Route element={<AuthenticatedApp />}> <Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route <Route path="/d/:slug" element={<NamedDashboardPage />} />
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="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route <Route
path="/services/:serviceType/:serviceId" path="/services/:serviceType/:serviceId"
element={<ServicePage />} element={<ServicePage />}
/> />
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -474,26 +495,18 @@ function AppInner() {
} }
> >
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route <Route path="/d/:slug" element={<NamedDashboardPage />} />
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="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} /> <Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType"
element={<ServiceTypePage />}
/>
<Route <Route
path="/services/:serviceType/:serviceId" path="/services/:serviceType/:serviceId"
element={<ServicePage />} element={<ServicePage />}
/> />
<Route path="*" element={<NotFoundPage />} />
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </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`,
);
}
-4
View File
@@ -32,7 +32,6 @@ import type {
DashboardShortcutInput, DashboardShortcutInput,
AlertmanagerAlertSummary, AlertmanagerAlertSummary,
AlertmanagerStatus, AlertmanagerStatus,
GrafanaStatus,
PrometheusStatus, PrometheusStatus,
PrometheusTarget, PrometheusTarget,
} from "../types"; } from "../types";
@@ -299,9 +298,6 @@ export const fetchAlertmanagerAlerts = () =>
export const fetchAlertmanagerStatus = () => export const fetchAlertmanagerStatus = () =>
get<AlertmanagerStatus>("/api/monitoring/alertmanager-status"); get<AlertmanagerStatus>("/api/monitoring/alertmanager-status");
export const fetchGrafanaStatus = () =>
get<GrafanaStatus>("/api/monitoring/grafana-status");
export const fetchPrometheusStatus = () => export const fetchPrometheusStatus = () =>
get<PrometheusStatus>("/api/monitoring/prometheus-status"); get<PrometheusStatus>("/api/monitoring/prometheus-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}`);
}
+59 -2
View File
@@ -6,14 +6,35 @@ import type {
WidgetInstanceInput, WidgetInstanceInput,
} from "../types"; } from "../types";
export interface WidgetReference {
id: string;
dashboard_scope: string;
widget_id: string;
sort_order: number;
created_at: number;
widget: WidgetInstance;
}
export interface WidgetReferenceInput {
dashboard_scope: string;
widget_id: string;
sort_order?: number;
}
export async function fetchBuiltinWidgetKinds(): Promise< export async function fetchBuiltinWidgetKinds(): Promise<
BuiltinWidgetKindInfo[] BuiltinWidgetKindInfo[]
> { > {
return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin"); return get<BuiltinWidgetKindInfo[]>("/api/widgets/builtin");
} }
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> { export async function fetchWidgetInstances(
return get<WidgetInstance[]>("/api/widgets/instances"); serviceId?: string,
scope?: "dashboard" | "service",
): Promise<WidgetInstance[]> {
const params: Record<string, string> = {};
if (serviceId) params.service_id = serviceId;
if (scope) params.scope = scope;
return get<WidgetInstance[]>("/api/widgets/instances", params);
} }
export async function createWidgetInstance( export async function createWidgetInstance(
@@ -40,3 +61,39 @@ export async function fetchWidgetData(
): Promise<WidgetDataResponse> { ): Promise<WidgetDataResponse> {
return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`); return get<WidgetDataResponse>(`/api/widgets/instances/${widgetId}/data`);
} }
export async function fetchWidgetReferences(
dashboardScope: string,
): Promise<WidgetReference[]> {
return get<WidgetReference[]>("/api/widgets/references", {
dashboard_scope: dashboardScope,
});
}
export async function createWidgetReference(
input: WidgetReferenceInput,
): Promise<WidgetReference> {
return post<WidgetReference>("/api/widgets/references", input);
}
export async function deleteWidgetReference(
referenceId: string,
): Promise<{ status: string }> {
return del<{ status: string }>(`/api/widgets/references/${referenceId}`);
}
export async function detachWidgetReference(
referenceId: string,
): Promise<WidgetInstance> {
return post<WidgetInstance>(`/api/widgets/references/${referenceId}/detach`);
}
export async function updateWidgetReference(
referenceId: string,
sortOrder: number,
): Promise<WidgetReference> {
return put<WidgetReference>(
`/api/widgets/references/${referenceId}?sort_order=${sortOrder}`,
{},
);
}
@@ -8,6 +8,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useIsMobile } from "../hooks/useIsMobile";
import type { BackupAlert } from "../types/backups"; import type { BackupAlert } from "../types/backups";
interface Props { interface Props {
@@ -29,7 +34,51 @@ function severityVariant(severity: string): SeverityVariant {
return severity === "critical" ? "destructive" : "warning"; 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) { 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 ( return (
<div className="overflow-hidden rounded-lg border border-border"> <div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup alerts"> <Table aria-label="Backup alerts">
@@ -7,6 +7,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } 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"; import type { BackupJob, BackupRun } from "../types/backups";
interface Props { interface Props {
@@ -41,7 +46,54 @@ function statusVariant(status: string): StatusVariant {
return "secondary"; 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) { 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 ( return (
<div className="overflow-hidden rounded-lg border border-border"> <div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup jobs"> <Table aria-label="Backup jobs">
+67 -27
View File
@@ -15,6 +15,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import {
MobileCardRow,
type MobileCardField,
} from "@/components/ui/mobile-card";
import { useIsMobile } from "../hooks/useIsMobile";
import type { BackupRun } from "../types/backups"; import type { BackupRun } from "../types/backups";
interface Props { interface Props {
@@ -55,8 +60,35 @@ function statusVariant(status: string): StatusVariant {
return "warning"; 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) { export default function BackupRunsTable({ runs }: Props) {
const [statusFilter, setStatusFilter] = useState<string>("all"); const [statusFilter, setStatusFilter] = useState<string>("all");
const isMobile = useIsMobile();
const filteredRuns = const filteredRuns =
statusFilter === "all" statusFilter === "all"
@@ -77,34 +109,42 @@ export default function BackupRunsTable({ runs }: Props) {
</SelectContent> </SelectContent>
</Select> </Select>
<div className="overflow-hidden rounded-lg border border-border"> {isMobile ? (
<Table aria-label="Backup runs"> <MobileCardRow
<TableHeader> rows={filteredRuns}
<TableRow className="bg-card hover:bg-card"> fields={runCardFields}
<TableHead>Job</TableHead> getRowId={(r) => r.id}
<TableHead>Status</TableHead> />
<TableHead>Duration</TableHead> ) : (
<TableHead>Size</TableHead> <div className="overflow-hidden rounded-lg border border-border">
<TableHead>Started</TableHead> <Table aria-label="Backup runs">
</TableRow> <TableHeader>
</TableHeader> <TableRow className="bg-card hover:bg-card">
<TableBody> <TableHead>Job</TableHead>
{filteredRuns.map((run) => ( <TableHead>Status</TableHead>
<TableRow key={run.id}> <TableHead>Duration</TableHead>
<TableCell>{run.job_id}</TableCell> <TableHead>Size</TableHead>
<TableCell> <TableHead>Started</TableHead>
<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> </TableRow>
))} </TableHeader>
</TableBody> <TableBody>
</Table> {filteredRuns.map((run) => (
</div> <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> </div>
); );
} }
+6 -1
View File
@@ -50,7 +50,11 @@ export function DialogFooter({
}: DialogFooterProps) { }: DialogFooterProps) {
return ( return (
<div className="flex flex-row flex-wrap items-center justify-end gap-2"> <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} {cancelLabel}
</Button> </Button>
{secondaryAction ? ( {secondaryAction ? (
@@ -59,6 +63,7 @@ export function DialogFooter({
</div> </div>
) : null} ) : null}
<Button <Button
className="mobile-touch-target"
variant={resolveConfirmVariant(confirmColor, confirmVariant)} variant={resolveConfirmVariant(confirmColor, confirmVariant)}
disabled={confirmDisabled} disabled={confirmDisabled}
onClick={onConfirm} onClick={onConfirm}
+27 -5
View File
@@ -4,26 +4,48 @@ import { Button } from "@/components/ui/button";
interface HoverEditButtonProps { interface HoverEditButtonProps {
onClick: () => void; onClick: () => void;
label?: string; 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 * existing hover-reveal rules in consuming pages (Actions, Settings) still
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate. * 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({ export function HoverEditButton({
onClick, onClick,
label = "Edit", label = "Edit",
mobile = "always",
}: HoverEditButtonProps) { }: 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 ( return (
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" 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} aria-label={label}
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
@@ -0,0 +1,95 @@
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
export interface SeriesPoint {
t: number;
v: number | null;
}
export interface ChartSeries {
label: string;
points: SeriesPoint[];
}
/** Merge multiple time-series into a single recharts-friendly array. */
function mergeSeries(series: ChartSeries[]): Record<string, unknown>[] {
const map = new Map<number, Record<string, unknown>>();
for (const s of series) {
for (const p of s.points) {
const existing = map.get(p.t) ?? { time: p.t };
existing[s.label] = p.v;
map.set(p.t, existing);
}
}
return [...map.values()].sort(
(a, b) => (a.time as number) - (b.time as number),
);
}
function formatTime(ms: number): string {
return new Date(ms).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
});
}
const CHART_COLORS = [
"var(--color-chart-1)",
"var(--color-chart-2)",
"var(--color-chart-3)",
"var(--color-chart-4)",
"var(--color-chart-5)",
];
interface LineSeriesChartProps {
series: ChartSeries[];
height?: number;
}
/** Shared recharts line-chart renderer used by PrometheusChart + qBit speed widgets. */
export function LineSeriesChart({
series,
height = 300,
}: LineSeriesChartProps) {
return (
<ResponsiveContainer width="100%" height={height}>
<LineChart data={mergeSeries(series)}>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="time"
tickFormatter={formatTime}
tick={{ fontSize: 11 }}
className="fill-muted-foreground"
/>
<YAxis tick={{ fontSize: 11 }} className="fill-muted-foreground" />
<Tooltip
labelFormatter={(label) => formatTime(Number(label))}
contentStyle={{
backgroundColor: "hsl(var(--popover))",
border: "1px solid hsl(var(--border))",
borderRadius: "0.5rem",
color: "hsl(var(--popover-foreground))",
}}
/>
{series.map((s, i) => (
<Line
key={s.label}
type="monotone"
dataKey={s.label}
stroke={CHART_COLORS[i % CHART_COLORS.length]}
dot={false}
strokeWidth={2}
connectNulls
/>
))}
</LineChart>
</ResponsiveContainer>
);
}
@@ -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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="mobile-touch-target"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onSelectSession(session); onSelectSession(session);
+484 -221
View File
@@ -1,4 +1,4 @@
import { useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -7,6 +7,7 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { import {
@@ -18,14 +19,29 @@ import {
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import { import {
ChevronDown,
ChevronUp,
Link2,
Pencil,
Plus,
Trash2,
Split,
} from "lucide-react";
import {
useCreateWidgetReference,
useDeleteWidgetInstance, useDeleteWidgetInstance,
useDeleteWidgetReference,
useDetachWidgetReference,
useSaveWidgetInstance, useSaveWidgetInstance,
useUpdateWidgetReference,
useWidgetInstances, useWidgetInstances,
useWidgetReferences,
} from "../hooks/useWidgets"; } from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices"; import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings"; import { useTasks } from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import type { WidgetInstance, WidgetInstanceInput } from "../types"; import type { WidgetInstance, WidgetInstanceInput } from "../types";
import { import {
BUILTIN_WIDGETS, BUILTIN_WIDGETS,
@@ -36,6 +52,12 @@ import {
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
/** When set, scope the dialog to a specific service instance's widgets. */
serviceId?: string;
/** When set, enable widget references ("Add existing") for this dashboard scope. */
dashboardScope?: string;
/** When set, auto-open in edit mode for this widget id (instead of the list view). */
editWidgetId?: string;
} }
interface Draft { interface Draft {
@@ -133,6 +155,12 @@ function WidgetConfigEditor({
const isNumber = const isNumber =
(schema as { type?: string }).type === "integer" || (schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number"; (schema as { type?: string }).type === "number";
// Use a multi-line textarea for fields that tend to hold complex
// multi-line values (PromQL, text blocks, etc.). The widget kind's
// config schema can opt in via `format: "textarea"`; the well-known
// `query` field is treated as textarea by default.
const schemaFormat = (schema as { format?: string }).format;
const isTextarea = schemaFormat === "textarea" || key === "query";
return ( return (
<Field <Field
key={key} key={key}
@@ -140,21 +168,31 @@ function WidgetConfigEditor({
htmlFor={`widget-cfg-${key}`} htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description} helper={(schema as { description?: string }).description}
> >
<Input {isTextarea ? (
id={`widget-cfg-${key}`} <Textarea
type={isNumber ? "number" : "text"} id={`widget-cfg-${key}`}
value={String(config[key] ?? "")} rows={4}
onChange={(e) => className="resize-y font-mono text-xs"
onChange({ value={String(config[key] ?? "")}
...config, onChange={(e) => onChange({ ...config, [key]: e.target.value })}
[key]: isNumber />
? e.target.value === "" ) : (
? undefined <Input
: Number(e.target.value) id={`widget-cfg-${key}`}
: e.target.value, type={isNumber ? "number" : "text"}
}) value={String(config[key] ?? "")}
} onChange={(e) =>
/> onChange({
...config,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
)}
</Field> </Field>
); );
})} })}
@@ -162,27 +200,56 @@ function WidgetConfigEditor({
); );
} }
export function WidgetConfigDialog({ open, onClose }: Props) { export function WidgetConfigDialog({
const { data: instances = [] } = useWidgetInstances(); open,
onClose,
serviceId,
dashboardScope,
editWidgetId,
}: Props) {
// When editing a dashboard (no serviceId), scope to dashboard-only widgets
// (service_id IS NULL) so service-scoped widgets don't leak into the list.
const { data: instances = [] } = useWidgetInstances(
serviceId,
!serviceId && dashboardScope ? "dashboard" : undefined,
);
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
const { data: tasks = [] } = useTasks(); const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance(); const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance(); const deleteWidget = useDeleteWidgetInstance();
const { data: references = [] } = useWidgetReferences(dashboardScope);
const createRef = useCreateWidgetReference();
const deleteRef = useDeleteWidgetReference();
const detachRef = useDetachWidgetReference();
const updateRef = useUpdateWidgetReference();
const { data: allWidgets = [] } = useWidgetInstances();
const [showExisting, setShowExisting] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
const [draft, setDraft] = useState<Draft | null>(null); const [draft, setDraft] = useState<Draft | null>(null);
const [draftBaseline, setDraftBaseline] = useState<Draft | null>(null);
// When opened via editWidgetId, closing the edit should close the dialog
// entirely (not fall back to the list view).
const directEdit = Boolean(editWidgetId);
const sortedInstances = useMemo( // When editWidgetId is set and the dialog opens, auto-enter edit mode for
() => // that widget (instead of showing the list view).
[...instances].sort( useEffect(() => {
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at, if (open && editWidgetId) {
), // Search both owned widgets and referenced widgets.
[instances], const target =
); instances.find((w) => w.id === editWidgetId) ??
references.find((r) => r.widget.id === editWidgetId)?.widget;
if (target) {
startEdit(target);
}
}
}, [open, editWidgetId, instances, references]);
function startAddBuiltIn(kind: string) { function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind]; const binding = BUILTIN_WIDGETS[kind];
setDraft({ setDraft({
serviceId: null, serviceId: serviceId ?? null,
widgetKind: kind, widgetKind: kind,
title: binding?.name ?? kind, title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) }, config: { ...(binding?.defaultConfig ?? {}) },
@@ -206,7 +273,7 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
} }
function startEdit(instance: WidgetInstance) { function startEdit(instance: WidgetInstance) {
setDraft({ const d: Draft = {
id: instance.id, id: instance.id,
serviceId: instance.service_id, serviceId: instance.service_id,
widgetKind: instance.widget_kind, widgetKind: instance.widget_kind,
@@ -214,11 +281,20 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
config: instance.config, config: instance.config,
enabled: instance.enabled, enabled: instance.enabled,
sortOrder: instance.sort_order, sortOrder: instance.sort_order,
}); };
setDraft(d);
setDraftBaseline(d);
} }
function reset() { function reset() {
// If we were opened via direct edit, closing should close the dialog
// entirely, not fall back to the list view.
if (directEdit) {
onClose();
return;
}
setDraft(null); setDraft(null);
setDraftBaseline(null);
} }
async function saveDraft() { async function saveDraft() {
@@ -250,19 +326,82 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
async function moveInstance(index: number, direction: -1 | 1) { async function moveInstance(index: number, direction: -1 | 1) {
const targetIndex = index + direction; const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return; if (targetIndex < 0 || targetIndex >= combinedWidgets.length) return;
const a = sortedInstances[index]; // Swap the two items in a copy, then renumber ALL items by their new
const b = sortedInstances[targetIndex]; // index position (index * 10). This guarantees the sort_order values
await Promise.all([ // change even when both items previously shared the same value (e.g. 0).
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }), const reordered = [...combinedWidgets];
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }), const tmp = reordered[index];
]); reordered[index] = reordered[targetIndex];
reordered[targetIndex] = tmp;
// Sequential (not Promise.all) to avoid cache-invalidation race.
for (let i = 0; i < reordered.length; i++) {
const item = reordered[i];
const newSortOrder = i * 10;
const refId = (item as { _ref_id?: string })._ref_id;
if (refId) {
await updateRef.mutateAsync({
referenceId: refId,
sortOrder: newSortOrder,
});
} else {
await saveWidget.mutateAsync({ ...item, sort_order: newSortOrder });
}
}
} }
async function removeInstance(instance: WidgetInstance) { async function removeInstance(instance: WidgetInstance) {
await deleteWidget.mutateAsync(instance.id); await deleteWidget.mutateAsync(instance.id);
} }
// Build a combined view of owned widgets + references for display.
const owned = [...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const refs = references.map((r) => ({
...r.widget,
_ref_id: r.id,
_is_reference: true as const,
}));
const combinedWidgets = [...owned, ...refs].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
);
const referencedWidgetIds = new Set(references.map((r) => r.widget_id));
// Available widgets for the "Add existing" picker: all widgets not already
// on this dashboard (owned or referenced).
const availableWidgets = useMemo(() => {
const onDashboard = new Set([
...instances.map((w) => w.id),
...referencedWidgetIds,
]);
const search = existingSearch.toLowerCase().trim();
return allWidgets
.filter((w) => !onDashboard.has(w.id))
.filter(
(w) =>
!search ||
w.title.toLowerCase().includes(search) ||
w.widget_kind.toLowerCase().includes(search),
);
}, [allWidgets, instances, referencedWidgetIds, existingSearch]);
async function handleAddReference(widgetId: string) {
await createRef.mutateAsync({
dashboard_scope: dashboardScope!,
widget_id: widgetId,
});
}
async function handleRemoveReference(refId: string) {
await deleteRef.mutateAsync(refId);
}
async function handleDetach(refId: string) {
await detachRef.mutateAsync(refId);
}
function handleClose(next: boolean) { function handleClose(next: boolean) {
if (!next) { if (!next) {
reset(); reset();
@@ -277,201 +416,325 @@ export function WidgetConfigDialog({ open, onClose }: Props) {
]?.widgets.find((w) => w.kind === draft.widgetKind) ]?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind] : BUILTIN_WIDGETS[draft.widgetKind]
: undefined; : undefined;
const isMobile = useIsMobile();
const isTaskOutput = const isTaskOutput =
draft?.serviceId !== null && draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type === services.find((s) => s.id === draft?.serviceId)?.service_type ===
"ssh_tasks"; "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">
{combinedWidgets.length === 0 ? (
<Alert>
<AlertDescription>No widgets yet. Add one below.</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{combinedWidgets.map((instance, index) => {
const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
const isRef =
(instance as { _is_reference?: boolean })._is_reference === true;
const refId = (instance as { _ref_id?: string })._ref_id;
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>
{isRef ? (
<Badge variant="secondary">
<Link2 className="mr-1 h-3 w-3" />
linked
</Badge>
) : null}
<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 === combinedWidgets.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
{!isRef ? (
<Switch
className="mobile-touch-target"
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
) : null}
{!isRef ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
) : null}
{isRef && refId ? (
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8"
title="Make an independent copy"
onClick={() => handleDetach(refId)}
>
<Split className="h-4 w-4" />
</Button>
) : null}
<Button
variant="ghost"
size="icon"
className="mobile-touch-target h-8 w-8 text-destructive"
title={
isRef ? "Remove from this dashboard" : "Delete widget"
}
onClick={() =>
isRef && refId
? handleRemoveReference(refId)
: removeInstance(instance)
}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
{dashboardScope ? (
<div className="flex flex-col gap-2">
<Button
variant="outline"
size="sm"
className="w-fit"
onClick={() => setShowExisting(!showExisting)}
>
<Link2 className="mr-1 h-3 w-3" />
{showExisting ? "Hide" : "Add existing widget"}
</Button>
{showExisting ? (
<div className="flex flex-col gap-2">
<Input
placeholder="Search widgets..."
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
{availableWidgets.length === 0 ? (
<p className="text-xs text-muted-foreground">
No widgets available to reuse.
</p>
) : (
<div className="flex flex-col gap-1">
{availableWidgets.map((w) => {
const owner = w.service_id
? services.find((s) => s.id === w.service_id)?.name
: "Dashboard";
return (
<div
key={w.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col">
<span className="text-sm font-medium">{w.title}</span>
<span className="text-xs text-muted-foreground">
{bindingLabel(w.service_id, w.widget_kind)} ·{" "}
{owner}
</span>
</div>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => handleAddReference(w.id)}
>
<Plus className="mr-1 h-3 w-3" />
Add
</Button>
</div>
);
})}
</div>
)}
</div>
) : null}
</div>
) : null}
<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)
// When scoped to a service Overview, only show widgets for THAT
// service instance's type (not all services' widgets).
.filter((s) => !serviceId || s.id === serviceId)
.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 && draftBaseline !== null
? JSON.stringify(draft) !== JSON.stringify(draftBaseline)
: draft !== null && draft?.id === undefined
}
>
<div className="flex flex-col gap-4">{draftBody}</div>
</SheetForm>
);
}
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl"> <DialogContent className="sm:max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{dialogTitle}</DialogTitle>
{draft
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
</DialogHeader> </DialogHeader>
{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"
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>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
+51 -11
View File
@@ -1,4 +1,6 @@
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Settings2, Copy } from "lucide-react";
import { useServiceInstances } from "../hooks/useServices"; import { useServiceInstances } from "../hooks/useServices";
import { resolveWidget } from "../integrations/registry"; import { resolveWidget } from "../integrations/registry";
import type { WidgetInstance } from "../types"; import type { WidgetInstance } from "../types";
@@ -6,31 +8,69 @@ import { SectionCard } from "./SectionCard";
interface Props { interface Props {
widget: WidgetInstance; widget: WidgetInstance;
/** When provided, an edit button appears in the top-right corner (hover on desktop, always on mobile). */
onEdit?: (widgetId: string) => void;
/** When provided, a copy/detach button appears next to edit (for referenced widgets). */
onCopy?: (widgetId: string) => void;
} }
export function WidgetInstanceCard({ widget }: Props) { export function WidgetInstanceCard({ widget, onEdit, onCopy }: Props) {
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
const resolved = resolveWidget(widget, services); const resolved = resolveWidget(widget, services);
// Edit + copy buttons: always visible on mobile (below md), hover-reveal on desktop.
const actionButtons = (
<div className="absolute right-2 top-2 z-10 flex items-center gap-1">
{onCopy ? (
<Button
variant="ghost"
size="icon-sm"
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 mobile-touch-target"
onClick={() => onCopy(widget.id)}
aria-label="Create independent copy"
>
<Copy className="h-4 w-4" />
</Button>
) : null}
{onEdit ? (
<Button
variant="ghost"
size="icon-sm"
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 mobile-touch-target"
onClick={() => onEdit(widget.id)}
aria-label="Edit widget"
>
<Settings2 className="h-4 w-4" />
</Button>
) : null}
</div>
);
if (!resolved) { if (!resolved) {
const label = widget.service_id const label = widget.service_id
? `Unknown widget: ${widget.widget_kind} (service-bound)` ? `Unknown widget: ${widget.widget_kind} (service-bound)`
: `Unknown widget: ${widget.widget_kind} (built-in)`; : `Unknown widget: ${widget.widget_kind} (built-in)`;
return ( return (
<SectionCard title={widget.title}> <div className="group relative">
<Alert> {onEdit || onCopy ? actionButtons : null}
<AlertDescription>{label}</AlertDescription> <SectionCard title={widget.title}>
</Alert> <Alert>
</SectionCard> <AlertDescription>{label}</AlertDescription>
</Alert>
</SectionCard>
</div>
); );
} }
const Component = resolved.component; const Component = resolved.component;
return ( return (
<Component <div className="group relative">
widget={widget} {onEdit || onCopy ? actionButtons : null}
refreshIntervalMs={resolved.refreshIntervalMs} <Component
description={resolved.description} widget={widget}
/> refreshIntervalMs={resolved.refreshIntervalMs}
description={resolved.description}
/>
</div>
); );
} }
@@ -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 { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import BackupAlertsTable from "../BackupAlertsTable"; import BackupAlertsTable from "../BackupAlertsTable";
@@ -61,3 +61,46 @@ describe("BackupAlertsTable", () => {
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull(); 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 { render, screen } from "@testing-library/react";
import BackupRunsTable from "../BackupRunsTable"; import BackupRunsTable from "../BackupRunsTable";
import type { BackupRun } from "../../types/backups"; import type { BackupRun } from "../../types/backups";
@@ -57,3 +57,29 @@ describe("BackupRunsTable", () => {
expect(screen.getByText("2.0 KB")).toBeInTheDocument(); 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" }), screen.getByRole("button", { name: "Rename machine" }),
).toBeInTheDocument(); ).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,34 @@
import { describe, it, expect } from "vitest";
import { render } from "@testing-library/react";
import { LineSeriesChart } from "../LineSeriesChart";
import type { ChartSeries } from "../LineSeriesChart";
describe("LineSeriesChart", () => {
it("renders without crashing with series data", () => {
const series: ChartSeries[] = [
{
label: "cpu",
points: [
{ t: 1000, v: 0.5 },
{ t: 2000, v: 0.8 },
],
},
];
const { container } = render(<LineSeriesChart series={series} />);
// ResponsiveContainer renders a wrapper div even in jsdom
expect(container.firstChild).not.toBeNull();
});
it("renders without crashing with empty series", () => {
const { container } = render(<LineSeriesChart series={[]} />);
expect(container.firstChild).not.toBeNull();
});
it("renders with custom height", () => {
const series: ChartSeries[] = [{ label: "dl", points: [{ t: 1, v: 1 }] }];
const { container } = render(
<LineSeriesChart series={series} height={200} />,
);
expect(container.firstChild).not.toBeNull();
});
});
@@ -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,72 @@
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: [] }),
useWidgetReferences: () => ({ data: [] }),
useSaveWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteWidgetInstance: () => ({ mutateAsync: vi.fn(), isPending: false }),
useCreateWidgetReference: () => ({ mutateAsync: vi.fn() }),
useDeleteWidgetReference: () => ({ mutateAsync: vi.fn() }),
useDetachWidgetReference: () => ({ mutateAsync: vi.fn() }),
useUpdateWidgetReference: () => ({ mutateAsync: vi.fn() }),
}));
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 OnChangeFn,
type PaginationState, type PaginationState,
type RowSelectionState, type RowSelectionState,
type Table as TableInstance,
type VisibilityState, type VisibilityState,
flexRender, flexRender,
getCoreRowModel, getCoreRowModel,
@@ -18,6 +17,7 @@ import { Columns3 } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox"; import { Checkbox } from "@/components/ui/checkbox";
import { TablePagination } from "@/components/ui/table-pagination";
import { import {
Table, Table,
TableBody, TableBody,
@@ -34,13 +34,6 @@ import {
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface DataTableProps<TData, TValue = unknown> { export interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[]; columns: ColumnDef<TData, TValue>[];
@@ -253,90 +246,19 @@ export function DataTable<TData, TValue = unknown>({
</div> </div>
{enablePagination && ( {enablePagination && (
<DataTablePagination <TablePagination
table={table} pageIndex={table.getState().pagination.pageIndex}
pageSize={table.getState().pagination.pageSize}
pageSizeOptions={pageSizeOptions} pageSizeOptions={pageSizeOptions}
totalRows={manualPagination ? (rowCount ?? 0) : table.getRowModel().rows.length}
pageCount={pageCount} pageCount={pageCount}
manual={manualPagination} onPaginationChange={table.setPagination}
rowCount={rowCount}
/> />
)} )}
</div> </div>
); );
} }
interface PaginationProps<TData> { // DataTablePagination was extracted into the shared TablePagination component
table: TableInstance<TData>; // (frontend/src/components/ui/table-pagination.tsx). Both the desktop DataTable
pageSizeOptions: number[]; // and the Media mobile card list consume it.
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>
);
}
+118 -122
View File
@@ -1,166 +1,162 @@
import * as React from "react" import * as React from "react";
import { Dialog as DialogPrimitive } from "radix-ui" import { Dialog as DialogPrimitive } from "radix-ui";
import { cn } from "@/lib/utils" import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button";
import { XIcon } from "lucide-react" import { XIcon } from "lucide-react";
function Dialog({ function Dialog({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) { }: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} /> return <DialogPrimitive.Root data-slot="dialog" {...props} />;
} }
function DialogTrigger({ function DialogTrigger({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) { }: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} /> return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
} }
function DialogPortal({ function DialogPortal({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) { }: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} /> return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
} }
function DialogClose({ function DialogClose({
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) { }: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} /> return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
} }
function DialogOverlay({ function DialogOverlay({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) { }: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return ( return (
<DialogPrimitive.Overlay <DialogPrimitive.Overlay
data-slot="dialog-overlay" data-slot="dialog-overlay"
className={cn( className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0", "fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
function DialogContent({ function DialogContent({
className, className,
children, children,
showCloseButton = true, showCloseButton = true,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & { }: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<DialogPortal> <DialogPortal>
<DialogOverlay /> <DialogOverlay />
<DialogPrimitive.Content <DialogPrimitive.Content
data-slot="dialog-content" data-slot="dialog-content"
className={cn( className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", "fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] max-h-[calc(100dvh-2rem)] overflow-y-auto -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild> <DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button <Button
variant="ghost" variant="ghost"
className="absolute top-2 right-2" className="absolute top-2 right-2"
size="icon-sm" size="icon-sm"
> >
<XIcon <XIcon />
/> <span className="sr-only">Close</span>
<span className="sr-only">Close</span> </Button>
</Button> </DialogPrimitive.Close>
</DialogPrimitive.Close> )}
)} </DialogPrimitive.Content>
</DialogPrimitive.Content> </DialogPortal>
</DialogPortal> );
)
} }
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) { function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div <div
data-slot="dialog-header" data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)} className={cn("flex flex-col gap-2", className)}
{...props} {...props}
/> />
) );
} }
function DialogFooter({ function DialogFooter({
className, className,
showCloseButton = false, showCloseButton = false,
children, children,
...props ...props
}: React.ComponentProps<"div"> & { }: React.ComponentProps<"div"> & {
showCloseButton?: boolean showCloseButton?: boolean;
}) { }) {
return ( return (
<div <div
data-slot="dialog-footer" data-slot="dialog-footer"
className={cn( className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end", "-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className className,
)} )}
{...props} {...props}
> >
{children} {children}
{showCloseButton && ( {showCloseButton && (
<DialogPrimitive.Close asChild> <DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button> <Button variant="outline">Close</Button>
</DialogPrimitive.Close> </DialogPrimitive.Close>
)} )}
</div> </div>
) );
} }
function DialogTitle({ function DialogTitle({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) { }: React.ComponentProps<typeof DialogPrimitive.Title>) {
return ( return (
<DialogPrimitive.Title <DialogPrimitive.Title
data-slot="dialog-title" data-slot="dialog-title"
className={cn( className={cn("text-base leading-none font-medium", className)}
"text-base leading-none font-medium", {...props}
className />
)} );
{...props}
/>
)
} }
function DialogDescription({ function DialogDescription({
className, className,
...props ...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) { }: React.ComponentProps<typeof DialogPrimitive.Description>) {
return ( return (
<DialogPrimitive.Description <DialogPrimitive.Description
data-slot="dialog-description" data-slot="dialog-description"
className={cn( className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground", "text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className className,
)} )}
{...props} {...props}
/> />
) );
} }
export { export {
Dialog, Dialog,
DialogClose, DialogClose,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogOverlay, DialogOverlay,
DialogPortal, DialogPortal,
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} };
+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 data-[side=bottom]:h-[100dvh] 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>
);
}
+4 -4
View File
@@ -14,7 +14,7 @@ function Tabs({
data-slot="tabs" data-slot="tabs"
data-orientation={orientation} data-orientation={orientation}
className={cn( className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col", "group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className className
)} )}
{...props} {...props}
@@ -23,7 +23,7 @@ function Tabs({
} }
const tabsListVariants = cva( const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none", "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-8 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{ {
variants: { variants: {
variant: { variant: {
@@ -61,10 +61,10 @@ function TabsTrigger({
<TabsPrimitive.Trigger <TabsPrimitive.Trigger
data-slot="tabs-trigger" data-slot="tabs-trigger"
className={cn( className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent", "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground", "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100", "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className className
)} )}
{...props} {...props}
+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;
}
+8 -1
View File
@@ -14,7 +14,10 @@ export function useMediaStatus(jellyfinServiceId?: string) {
staleTime: 5_000, staleTime: 5_000,
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false, 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).
}); });
} }
@@ -51,6 +54,10 @@ export function useBuildIndex(jellyfinServiceId?: string) {
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
onError: () => {
// Invalidate status so the UI reflects the current (non-building) state.
invalidateMedia(queryClient);
},
}); });
} }
-11
View File
@@ -2,7 +2,6 @@ import { useQuery } from "@tanstack/react-query";
import { import {
fetchAlertmanagerAlerts, fetchAlertmanagerAlerts,
fetchAlertmanagerStatus, fetchAlertmanagerStatus,
fetchGrafanaStatus,
fetchPrometheusStatus, fetchPrometheusStatus,
fetchPrometheusTargets, fetchPrometheusTargets,
fetchMonitoringMachines, fetchMonitoringMachines,
@@ -28,16 +27,6 @@ export function useAlertmanagerStatus() {
}); });
} }
export function useGrafanaStatus() {
return useQuery({
queryKey: ["observability", "grafana-status"],
queryFn: fetchGrafanaStatus,
retry: 2,
staleTime: 10_000,
refetchInterval: 30_000,
});
}
export function usePrometheusStatus() { export function usePrometheusStatus() {
return useQuery({ return useQuery({
queryKey: ["observability", "prometheus-status"], queryKey: ["observability", "prometheus-status"],
-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,
});
}
+66 -3
View File
@@ -1,18 +1,26 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
createWidgetInstance, createWidgetInstance,
createWidgetReference,
deleteWidgetInstance, deleteWidgetInstance,
deleteWidgetReference,
detachWidgetReference,
fetchBuiltinWidgetKinds, fetchBuiltinWidgetKinds,
fetchWidgetData, fetchWidgetData,
fetchWidgetInstances, fetchWidgetInstances,
fetchWidgetReferences,
updateWidgetInstance, updateWidgetInstance,
updateWidgetReference,
} from "../api/widgets"; } from "../api/widgets";
import type { WidgetInstanceInput } from "../types"; import type { WidgetInstanceInput } from "../types";
export function useWidgetInstances() { export function useWidgetInstances(
serviceId?: string,
scope?: "dashboard" | "service",
) {
return useQuery({ return useQuery({
queryKey: ["widgets", "instances"], queryKey: ["widgets", "instances", serviceId ?? null, scope ?? null],
queryFn: fetchWidgetInstances, queryFn: () => fetchWidgetInstances(serviceId, scope),
refetchInterval: 60_000, refetchInterval: 60_000,
}); });
} }
@@ -55,3 +63,58 @@ export function useBuiltinWidgetKinds() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useWidgetReferences(dashboardScope: string | undefined) {
return useQuery({
queryKey: ["widgets", "references", dashboardScope ?? null],
queryFn: () => fetchWidgetReferences(dashboardScope!),
enabled: !!dashboardScope,
refetchInterval: 60_000,
});
}
export function useCreateWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createWidgetReference,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
},
});
}
export function useDeleteWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteWidgetReference,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
},
});
}
export function useDetachWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: detachWidgetReference,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets"] });
},
});
}
export function useUpdateWidgetReference() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({
referenceId,
sortOrder,
}: {
referenceId: string;
sortOrder: number;
}) => updateWidgetReference(referenceId, sortOrder),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "references"] });
},
});
}
+18 -2
View File
@@ -23,7 +23,7 @@
--color-border: #e2e8f0; --color-border: #e2e8f0;
--color-input: #e2e8f0; --color-input: #e2e8f0;
--color-ring: #4f8cff; --color-ring: #4f8cff;
/* Status / Grafana-link semantic cues single source of truth for Badges. /* Status semantic cues single source of truth for Badges.
chart-1=info/brand, chart-2=success, chart-3=warning, chart-1=info/brand, chart-2=success, chart-3=warning,
chart-4=destructive, chart-5=neutral-accent. */ chart-4=destructive, chart-5=neutral-accent. */
--color-chart-1: #4f8cff; --color-chart-1: #4f8cff;
@@ -62,7 +62,7 @@
--color-border: #334155; --color-border: #334155;
--color-input: #334155; --color-input: #334155;
--color-ring: #4f8cff; --color-ring: #4f8cff;
/* Status / Grafana-link semantic cues single source of truth for Badges. /* Status semantic cues single source of truth for Badges.
chart-1=info/brand, chart-2=success, chart-3=warning, chart-1=info/brand, chart-2=success, chart-3=warning,
chart-4=destructive, chart-5=neutral-accent. */ chart-4=destructive, chart-5=neutral-accent. */
--color-chart-1: #4f8cff; --color-chart-1: #4f8cff;
@@ -100,3 +100,19 @@ body,
* { * {
box-sizing: border-box; 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,50 @@
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 Jellyfin when jellyfin is configured", () => {
const entries = configuredNavEntries(new Set(["jellyfin"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("Jellyfin");
expect(entries[0].path).toBe("/services/jellyfin");
});
it("returns one SSH Tasks entry when ssh_tasks is configured", () => {
const entries = configuredNavEntries(new Set(["ssh_tasks"]));
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe("SSH Tasks");
});
it("returns all observability entries", () => {
const entries = configuredNavEntries(
new Set(["alertmanager", "prometheus"]),
);
expect(entries.map((e) => e.label)).toEqual(["Alertmanager", "Prometheus"]);
});
it("returns Backups + Authentik when configured", () => {
const entries = configuredNavEntries(new Set(["backups", "authentik"]));
expect(entries.map((e) => e.label)).toEqual(["Backups", "Authentik"]);
});
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([
"Jellyfin",
"SSH Tasks",
"Authentik",
]);
});
});
+79
View File
@@ -0,0 +1,79 @@
/**
* Service-type conditional nav-entry map.
*
* Each configured service type contributes ONE top-level nav entry that
* appears only when at least one enabled instance of that type exists. The
* label is the service TYPE name (Jellyfin, SSH Tasks), not a conceptual
* name (Media, Files) the service page's content tabs surface the concepts.
*/
import {
Activity,
DatabaseBackup,
GanttChartSquare,
Monitor,
Server,
Users,
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 entry.
* Uses the service type's display name. One entry per type.
* `nextcloud` has no entry (no operational content tabs).
*/
export const SERVICE_TYPE_NAV_ENTRIES: NavEntry[] = [
{
serviceType: "jellyfin",
label: "Jellyfin",
icon: Monitor,
path: "/services/jellyfin",
},
{
serviceType: "ssh_tasks",
label: "SSH Tasks",
icon: Server,
path: "/services/ssh_tasks",
},
{
serviceType: "alertmanager",
label: "Alertmanager",
icon: Activity,
path: "/services/alertmanager",
},
{
serviceType: "prometheus",
label: "Prometheus",
icon: GanttChartSquare,
path: "/services/prometheus",
},
{
serviceType: "backups",
label: "Backups",
icon: DatabaseBackup,
path: "/services/backups",
},
{
serviceType: "authentik",
label: "Authentik",
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),
);
}
+13 -10
View File
@@ -12,17 +12,20 @@ describe("service registry", () => {
it("registers the backend service types", () => { it("registers the backend service types", () => {
expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([ expect(Object.keys(SERVICE_REGISTRY).sort()).toEqual([
"alertmanager", "alertmanager",
"grafana",
"jellyfin", "jellyfin",
"nextcloud", "nextcloud",
"prometheus", "prometheus",
"qbittorrent",
"ssh_tasks", "ssh_tasks",
]); ]);
}); });
it("binds widget kinds per service", () => { it("binds widget kinds per service", () => {
expect(SERVICE_REGISTRY.grafana.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.prometheus.widgets.map((w) => w.kind)).toEqual([
"link", "metric",
"chart",
"gauge",
"mean",
]); ]);
expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([ expect(SERVICE_REGISTRY.alertmanager.widgets.map((w) => w.kind)).toEqual([
"active_alerts", "active_alerts",
@@ -37,12 +40,12 @@ describe("service registry", () => {
expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]); expect(Object.keys(BUILTIN_WIDGETS).sort()).toEqual(["backups", "static"]);
}); });
it("resolves a service-bound widget via the services list", () => { it("resolves a prometheus metric widget via the services list", () => {
const widget: WidgetInstance = { const widget: WidgetInstance = {
id: "w1", id: "w1",
service_id: "s1", service_id: "s1",
widget_kind: "link", widget_kind: "metric",
title: "Dashboard", title: "Metric",
config: {}, config: {},
enabled: true, enabled: true,
sort_order: 0, sort_order: 0,
@@ -52,9 +55,9 @@ describe("service registry", () => {
const services: ServiceInstance[] = [ const services: ServiceInstance[] = [
{ {
id: "s1", id: "s1",
service_type: "grafana", service_type: "prometheus",
name: "Grafana", name: "Prometheus",
config: { base_url: "https://grafana.example.com" }, config: { base_url: "https://prometheus.example.com" },
secrets_set: { api_key: true }, secrets_set: { api_key: true },
enabled: true, enabled: true,
created_at: 0, created_at: 0,
@@ -63,7 +66,7 @@ describe("service registry", () => {
]; ];
const resolved = resolveWidget(widget, services); const resolved = resolveWidget(widget, services);
expect(resolved).toBeDefined(); expect(resolved).toBeDefined();
expect(resolved?.refreshIntervalMs).toBe(0); expect(resolved?.refreshIntervalMs).toBe(30_000);
}); });
it("resolves an alertmanager active_alerts widget", () => { it("resolves an alertmanager active_alerts widget", () => {
+119 -24
View File
@@ -1,9 +1,15 @@
import type { ComponentType } from "react"; import type { ComponentType } from "react";
import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget"; import { AlertmanagerAlertsWidget } from "../widgets/AlertmanagerAlertsWidget";
import { BackupsWidget } from "../widgets/BackupsWidget"; import { BackupsWidget } from "../widgets/BackupsWidget";
import { GrafanaLinkWidget } from "../widgets/GrafanaLinkWidget"; import { PrometheusChartWidget } from "../widgets/PrometheusChartWidget";
import { PrometheusGaugeWidget } from "../widgets/PrometheusGaugeWidget";
import { PrometheusMeanWidget } from "../widgets/PrometheusMeanWidget";
import { JellyfinWidget } from "../widgets/JellyfinWidget"; import { JellyfinWidget } from "../widgets/JellyfinWidget";
import { JellyfinNowPlayingWidget } from "../widgets/JellyfinNowPlayingWidget";
import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget"; import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget"; import { StaticWidget } from "../widgets/StaticWidget";
import type { import type {
@@ -65,29 +71,6 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
], ],
}, },
grafana: {
serviceType: "grafana",
name: "Grafana",
description: "Dashboards, metrics, and logs.",
widgets: [
{
kind: "link",
name: "Dashboard link",
description: "Deep-link to a Grafana dashboard or panel.",
refreshIntervalMs: 0,
defaultConfig: { dashboard_uid: "" },
configSchema: {
type: "object",
properties: {
dashboard_uid: { type: "string" },
panel_id: { type: "integer" },
},
required: ["dashboard_uid"],
},
component: GrafanaLinkWidget,
},
],
},
prometheus: { prometheus: {
serviceType: "prometheus", serviceType: "prometheus",
name: "Prometheus", name: "Prometheus",
@@ -106,6 +89,109 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
}, },
component: PrometheusMetricWidget, component: PrometheusMetricWidget,
}, },
{
kind: "chart",
name: "Chart",
description: "Multi-series line chart from a PromQL range query.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query expression",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
},
required: ["promql"],
},
component: PrometheusChartWidget,
},
{
kind: "gauge",
name: "Gauge",
description:
"Instant query rendered as a gauge with optional threshold bands.",
refreshIntervalMs: 30_000,
defaultConfig: { promql: "" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL instant query (must return a single scalar)",
},
warn_at: { type: "number", description: "Warning threshold" },
crit_at: { type: "number", description: "Critical threshold" },
min: { type: "number" },
max: { type: "number" },
unit: { type: "string" },
},
required: ["promql"],
},
component: PrometheusGaugeWidget,
},
{
kind: "mean",
name: "Mean",
description: "Average value of a PromQL query over a time window.",
refreshIntervalMs: 60_000,
defaultConfig: { promql: "", window: "1h" },
configSchema: {
type: "object",
properties: {
promql: {
type: "string",
description: "PromQL range query (must return a single series)",
},
window: {
type: "string",
description: "Time window preset (1h, 6h, 24h, 7d)",
},
unit: { type: "string" },
},
required: ["promql"],
},
component: PrometheusMeanWidget,
},
],
},
qbittorrent: {
serviceType: "qbittorrent",
name: "qBittorrent",
description: "Torrent client activity, speeds, and item counts.",
widgets: [
{
kind: "totals",
name: "Totals",
description: "Count of all listed torrents, broken down by state.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentTotalsWidget,
},
{
kind: "active",
name: "Active torrents",
description: "Torrents currently downloading or uploading.",
refreshIntervalMs: 15_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentActiveTorrentsWidget,
},
{
kind: "speed",
name: "Speed chart",
description: "Live download/upload speed over a short window.",
refreshIntervalMs: 5_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: QbittorrentSpeedWidget,
},
], ],
}, },
jellyfin: { jellyfin: {
@@ -122,6 +208,15 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
configSchema: { type: "object", properties: {}, required: [] }, configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinWidget, component: JellyfinWidget,
}, },
{
kind: "now_playing",
name: "Now Playing",
description: "Only sessions actively playing media.",
refreshIntervalMs: 30_000,
defaultConfig: {},
configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinNowPlayingWidget,
},
], ],
}, },
nextcloud: { nextcloud: {
-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>
);
}
+217 -13
View File
@@ -1,5 +1,11 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import {
Activity,
DatabaseBackup,
LayoutDashboard,
Monitor,
} from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -25,14 +31,134 @@ import {
useDeleteDashboardShortcut, useDeleteDashboardShortcut,
useSaveDashboardShortcut, useSaveDashboardShortcut,
} from "../hooks/useDashboard"; } from "../hooks/useDashboard";
import { useWidgetInstances } from "../hooks/useWidgets"; import {
import type { DashboardShortcut, DashboardShortcutInput } from "../types"; useDetachWidgetReference,
useWidgetInstances,
useWidgetReferences,
} from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices";
import { useIsMobile } from "../hooks/useIsMobile";
import type {
DashboardShortcut,
DashboardShortcutInput,
ServiceInstance,
WidgetInstance,
} from "../types";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { WidgetInstanceCard } from "../components/WidgetInstance"; import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog"; 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"]);
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,
onEditWidget,
onCopyWidget,
}: {
sections: { id: SectionId; widgets: WidgetInstance[] }[];
onEditWidget?: (widgetId: string) => void;
onCopyWidget?: (widgetId: string) => void;
}) {
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}
onEdit={onEditWidget ? (id) => onEditWidget(id) : undefined}
onCopy={onCopyWidget ? (id) => onCopyWidget(id) : undefined}
/>
))}
</section>
))}
</div>
</>
);
}
function emptyShortcut(): DashboardShortcutInput { function emptyShortcut(): DashboardShortcutInput {
return { return {
id: null, id: null,
@@ -335,14 +461,34 @@ export function Dashboard() {
); );
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null); const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false); const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances(); const [editWidgetId, setEditWidgetId] = useState<string | undefined>();
const { data: widgetInstances = [] } = useWidgetInstances(
undefined,
"dashboard",
);
const { data: widgetReferences = [] } = useWidgetReferences("main");
const { data: services = [] } = useServiceInstances();
const isMobile = useIsMobile();
const visibleWidgets = useMemo( const visibleWidgets = useMemo(() => {
() => const refs = widgetReferences
widgetInstances .filter((r) => r.widget.enabled)
.filter((w) => w.enabled) .map((r) => r.widget);
.sort((a, b) => a.sort_order - b.sort_order), return [...widgetInstances, ...refs]
[widgetInstances], .filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order);
}, [widgetInstances, widgetReferences]);
// Track which visible widgets are references (for the copy/detach button).
const referencedWidgetIds = useMemo(
() => new Set(widgetReferences.map((r) => r.widget.id)),
[widgetReferences],
);
const detachRef = useDetachWidgetReference();
const mobileSections = useMemo(
() => groupWidgetsBySection(visibleWidgets, services),
[visibleWidgets, services],
); );
const openCreateShortcut = () => { const openCreateShortcut = () => {
@@ -374,6 +520,27 @@ export function Dashboard() {
return ( return (
<div className="flex flex-col gap-4"> <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 <SectionCard
title="Shortcuts" title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later." description="Quick links to websites today, with room for action and user shortcuts later."
@@ -417,9 +584,41 @@ export function Dashboard() {
)} )}
</SectionCard> </SectionCard>
{visibleWidgets.map((widget) => ( {isMobile && mobileSections.length > 0 ? (
<WidgetInstanceCard key={widget.id} widget={widget} /> <MobileWidgetSections
))} sections={mobileSections}
onEditWidget={(id) => {
setEditWidgetId(id);
setWidgetDialogOpen(true);
}}
onCopyWidget={(id) => {
const ref = widgetReferences.find((r) => r.widget.id === id);
if (ref) detachRef.mutate(ref.id);
}}
/>
) : (
visibleWidgets.map((widget) => (
<WidgetInstanceCard
key={widget.id}
widget={widget}
onEdit={(id) => {
setEditWidgetId(id);
setWidgetDialogOpen(true);
}}
onCopy={
referencedWidgetIds.has(widget.id)
? () => {
// Detach: find the reference and clone it.
const ref = widgetReferences.find(
(r) => r.widget.id === widget.id,
);
if (ref) detachRef.mutate(ref.id);
}
: undefined
}
/>
))
)}
<ShortcutDialog <ShortcutDialog
open={shortcutDialogOpen} open={shortcutDialogOpen}
@@ -443,7 +642,12 @@ export function Dashboard() {
/> />
<WidgetConfigDialog <WidgetConfigDialog
open={widgetDialogOpen} open={widgetDialogOpen}
onClose={() => setWidgetDialogOpen(false)} onClose={() => {
setWidgetDialogOpen(false);
setEditWidgetId(undefined);
}}
dashboardScope="main"
editWidgetId={editWidgetId}
/> />
</div> </div>
); );
-1
View File
@@ -1 +0,0 @@
export { FileBrowser } from "./FileBrowser.impl";
+144
View File
@@ -0,0 +1,144 @@
import { useMemo, useState } from "react";
import { useParams } from "react-router-dom";
import { Boxes, Settings2 } from "lucide-react";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useDashboardBySlug } from "../hooks/useDashboards";
import { useWidgetReferences } from "../hooks/useWidgets";
import { PinnedServiceLink } from "../components/PinnedServiceLink";
import { WidgetInstanceCard } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
/**
* 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 dashboardScope = `named:${slug}`;
const { data: widgetRefs = [] } = useWidgetReferences(dashboardScope);
const [configOpen, setConfigOpen] = useState(false);
const [editWidgetId, setEditWidgetId] = useState<string | undefined>();
const items = useMemo(
() => parseItems(dashboard?.payload ?? {}),
[dashboard?.payload],
);
const visibleWidgets = useMemo(
() =>
widgetRefs
.filter((r) => r.widget.enabled)
.map((r) => r.widget)
.sort((a, b) => a.sort_order - b.sort_order),
[widgetRefs],
);
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 className="flex items-center justify-between">
<h2 className="text-xl font-semibold">{dashboard.label}</h2>
<Button
variant="outline"
size="sm"
className="mobile-touch-target"
onClick={() => setConfigOpen(true)}
>
<Settings2 className="size-4" />
Edit widgets
</Button>
</div>
{visibleWidgets.length > 0 ? (
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
{visibleWidgets.map((widget) => (
<WidgetInstanceCard
key={widget.id}
widget={widget}
onEdit={(id) => {
setEditWidgetId(id);
setConfigOpen(true);
}}
/>
))}
</div>
) : null}
{items.length === 0 && visibleWidgets.length === 0 ? (
<Alert>
<AlertDescription>
This dashboard is empty. Add widgets via "Edit widgets" or pinned
service links from the dashboard management panel on the Services
page.
</AlertDescription>
</Alert>
) : items.length > 0 ? (
<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>
) : null}
<WidgetConfigDialog
open={configOpen}
onClose={() => {
setConfigOpen(false);
setEditWidgetId(undefined);
}}
dashboardScope={dashboardScope}
editWidgetId={editWidgetId}
/>
</div>
);
}
+94 -275
View File
@@ -1,47 +1,17 @@
import { useMemo, useState } from "react"; import { useMemo } from "react";
import { useParams } from "react-router-dom"; import { useParams, useNavigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Input } from "@/components/ui/input"; import { useServiceInstances } from "../hooks/useServices";
import { Label } from "@/components/ui/label"; import type { ServiceInstance } from "../types";
import { Switch } from "@/components/ui/switch";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
} from "../hooks/useServices";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { getServiceBinding } from "../integrations/registry"; import { getServiceBinding } from "../integrations/registry";
import {
function Field({ OVERVIEW_TAB,
label, serviceContentTabs,
htmlFor, type ContentTab,
helper, } from "./service-tabs";
children,
}: {
label: string;
htmlFor: string;
helper?: string;
children: React.ReactNode;
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={htmlFor}>{label}</Label>
{children}
{helper ? (
<p className="text-xs text-muted-foreground">{helper}</p>
) : null}
</div>
);
}
export function ServicePage() { export function ServicePage() {
const { serviceType = "", serviceId = "" } = useParams<{ const { serviceType = "", serviceId = "" } = useParams<{
@@ -49,33 +19,24 @@ export function ServicePage() {
serviceId: string; serviceId: string;
}>(); }>();
const { data: services = [] } = useServiceInstances(serviceType || undefined); const { data: services = [] } = useServiceInstances(serviceType || undefined);
const { data: types = [] } = useServiceTypes(); const navigate = useNavigate();
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const instance = useMemo( const instance = useMemo(
() => services.find((s) => s.id === serviceId), () => services.find((s) => s.id === serviceId),
[services, serviceId], [services, serviceId],
); );
const binding = getServiceBinding(serviceType); const binding = getServiceBinding(serviceType);
const typeInfo = useMemo( const contentTabs = useMemo(
() => types.find((t) => t.service_type === serviceType), () => serviceContentTabs(serviceType),
[types, serviceType], [serviceType],
); );
const siblings = useMemo(
() => services.filter((s) => s.service_type === serviceType && s.enabled),
[services, serviceType],
);
const showInstanceTabs = siblings.length > 1;
const [name, setName] = useState(""); const allTabs: ContentTab[] = [OVERVIEW_TAB, ...contentTabs];
const [enabled, setEnabled] = useState(true);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const [hydrated, setHydrated] = useState(false);
// Hydrate local form state once the instance loads.
if (instance && !hydrated) {
setName(instance.name);
setEnabled(instance.enabled);
setDraftConfig({ ...instance.config });
setHydrated(true);
}
if (!binding) { if (!binding) {
return ( return (
@@ -93,235 +54,93 @@ export function ServicePage() {
); );
} }
function buildInput(): ServiceInstanceInput { const widgetsContent =
return { binding.widgets.length > 0 ? (
id: instance!.id, <div className="flex flex-col gap-2">
service_type: instance!.service_type, {binding.widgets.map((w) => (
name, <div
config: draftConfig, key={w.kind}
secrets: {}, // secrets are managed via the dedicated inputs below className="flex items-center justify-between rounded border p-2"
enabled, >
}; <div>
} <div className="font-medium">{w.name}</div>
<div className="text-xs text-muted-foreground">
async function save() { {w.description}
await saveService.mutateAsync(buildInput()); </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>
);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex items-center justify-between"> {/* Header */}
<div> <div className="flex items-center justify-between gap-4">
<div className="min-w-0">
<h2 className="text-xl font-semibold">{instance.name}</h2> <h2 className="text-xl font-semibold">{instance.name}</h2>
<p className="text-sm text-muted-foreground">{binding.description}</p> <p className="text-sm text-muted-foreground">{binding.description}</p>
</div> </div>
<Badge variant="outline">{binding.name}</Badge> <Badge variant="outline">{binding.name}</Badge>
</div> </div>
<SectionCard title="General"> {/* Instance tabs (only when >1 enabled sibling) */}
<div className="flex flex-col gap-3"> {showInstanceTabs ? (
<Field label="Name" htmlFor="service-name"> <Tabs value={instance.id}>
<Input <TabsList>
id="service-name" {siblings.map((sibling: ServiceInstance) => (
value={name} <TabsTrigger
onChange={(e) => setName(e.target.value)} key={sibling.id}
/> value={sibling.id}
</Field> onClick={() =>
<div className="flex items-center gap-2"> navigate(`/services/${serviceType}/${sibling.id}`)
<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>
<ServiceConnectionCard
instance={instance}
typeInfo={typeInfo}
draftConfig={draftConfig}
onConfigChange={setDraftConfig}
/>
{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> {sibling.name}
<div className="font-medium">{w.name}</div> </TabsTrigger>
<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"> </TabsList>
Add these to the dashboard from the dashboard's edit dialog. </Tabs>
</p>
</div>
</SectionCard>
) : null} ) : null}
<ConfirmDialog {/* Content tabs */}
open={deleteOpen} <Tabs defaultValue="Overview">
title="Delete service?" <TabsList>
message="This removes the service and any widgets that reference it. This cannot be undone." <TabsTrigger value="Overview">Overview</TabsTrigger>
confirmLabel="Delete" {contentTabs.map((tab) => (
onCancel={() => setDeleteOpen(false)} <TabsTrigger key={tab.label} value={tab.label}>
onConfirm={() => { {tab.label}
deleteService.mutate(instance.id); </TabsTrigger>
setDeleteOpen(false); ))}
}} <TabsTrigger value="Widgets">Widgets</TabsTrigger>
/> </TabsList>
{allTabs.map((tab) => {
const TabComponent = tab.Component;
return (
<TabsContent key={tab.label} value={tab.label}>
<TabComponent instance={instance} />
</TabsContent>
);
})}
<TabsContent value="Widgets">
<SectionCard
title="Widgets"
description="Widget kinds this service provides."
>
{widgetsContent}
</SectionCard>
</TabsContent>
</Tabs>
</div> </div>
); );
} }
function ServiceConnectionCard({
instance,
typeInfo,
draftConfig,
onConfigChange,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
draftConfig: Record<string, unknown>;
onConfigChange: (config: Record<string, unknown>) => 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 {
properties?: Record<
string,
{ type?: string; description?: string; default?: unknown }
>;
}
).properties ?? {};
const configEntries: Array<
[string, { type?: string; description?: string }]
> =
Object.keys(properties).length > 0
? Object.entries(properties).map(([key, schema]) => [
key,
{ type: schema?.type, description: schema?.description },
])
: Object.entries(instance.config).map(([key, value]) => [
key,
{ type: typeof value === "number" ? "integer" : "string" },
]);
return (
<SectionCard
title="Connection"
description="Edit non-secret connection config and secret values."
>
<div className="flex flex-col gap-3">
{configEntries.length === 0 ? (
<p className="text-sm text-muted-foreground">No connection config.</p>
) : (
<div className="flex flex-col gap-3">
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`cfg-${key}`}
helper={schema.description}
>
<Input
id={`cfg-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
onConfigChange({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</Field>
);
})}
</div>
)}
{Object.keys(instance.secrets_set).length === 0 ? (
<p className="text-sm text-muted-foreground">No secret fields.</p>
) : (
<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">
<Field
label={key}
htmlFor={`secret-${key}`}
helper="Leave blank to keep the current value."
>
<Input
id={`secret-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</Field>
{isSet ? <Badge variant="secondary">set</Badge> : null}
</div>
))}
</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>
</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>
);
}
+258 -3
View File
@@ -12,13 +12,31 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } 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 { import {
useDeleteServiceInstance, useDeleteServiceInstance,
useSaveServiceInstance, useSaveServiceInstance,
useServiceInstances, useServiceInstances,
} from "../hooks/useServices"; } from "../hooks/useServices";
import { useServiceTypes } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices";
import {
useDashboards,
useDeleteDashboard,
useSaveDashboard,
} from "../hooks/useDashboards";
import type { import type {
SecretFieldInfo, SecretFieldInfo,
ServiceInstance, ServiceInstance,
@@ -29,6 +47,8 @@ import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog"; import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { getServiceBinding } from "../integrations/registry"; import { getServiceBinding } from "../integrations/registry";
import { serviceLinkTarget } from "../components/PinnedServiceLink";
import type { NamedDashboardInput } from "../api/dashboards";
interface CreateDraft { interface CreateDraft {
serviceType: string; 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() { export function ServicesPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const { data: services = [] } = useServiceInstances(); const { data: services = [] } = useServiceInstances();
@@ -295,8 +548,8 @@ export function ServicesPage() {
{services.length === 0 ? ( {services.length === 0 ? (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
No services yet. Add a Grafana, Prometheus, Jellyfin, Nextcloud, No services yet. Add a Prometheus, Jellyfin, Nextcloud, or SSH
or SSH task runner. task runner.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : ( ) : (
@@ -352,6 +605,8 @@ export function ServicesPage() {
)} )}
</SectionCard> </SectionCard>
<DashboardManagementCard />
<CreateServiceDialog <CreateServiceDialog
open={createOpen} open={createOpen}
onClose={() => setCreateOpen(false)} onClose={() => setCreateOpen(false)}
+427 -46
View File
@@ -17,6 +17,8 @@ import {
useSaveSSHKey, useSaveSSHKey,
useTestMonitoringMachineSSH, useTestMonitoringMachineSSH,
} from "../hooks/useSettings"; } from "../hooks/useSettings";
import { useIsMobile } from "../hooks/useIsMobile";
import { SheetForm } from "@/components/ui/sheet-form";
import { DialogFooter } from "../components/DialogFooter"; import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton"; import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard"; import { SectionCard } from "../components/SectionCard";
@@ -48,6 +50,17 @@ import {
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { TabsTrigger } from "@/components/ui/tabs"; import { TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import {
useDeleteServiceInstance,
useSaveServiceInstance,
useServiceInstances,
useServiceTypes,
} from "../hooks/useServices";
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
const SERVICE_OPTIONS = [ const SERVICE_OPTIONS = [
{ value: "monitoring", label: "Monitoring" }, { value: "monitoring", label: "Monitoring" },
@@ -59,7 +72,7 @@ const SERVICE_OPTIONS = [
// maps to this sentinel and converts back to "" at the draft boundary. // maps to this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__"; const NONE = "__none__";
type SettingsTab = "machines" | "ssh-keys" | "danger"; type SettingsTab = "machines" | "ssh-keys" | "services" | "danger";
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */ /** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({ function FormField({
@@ -126,6 +139,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({ function MachineEditor({
title, title,
hint, hint,
@@ -230,6 +267,7 @@ function MachineEditor({
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Switch <Switch
id="machine-enabled" id="machine-enabled"
className="mobile-touch-target"
checked={draft.enabled} checked={draft.enabled}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setDraft((current) => ({ ...current, enabled: checked })) setDraft((current) => ({ ...current, enabled: checked }))
@@ -439,6 +477,7 @@ function MachineEditor({
</Alert> </Alert>
<div className="flex flex-row flex-wrap items-center gap-2"> <div className="flex flex-row flex-wrap items-center gap-2">
<Button <Button
className="mobile-touch-target"
variant="outline" variant="outline"
onClick={onValidateSSH} onClick={onValidateSSH}
disabled={ disabled={
@@ -532,7 +571,7 @@ function SSHKeyManager({
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full" className="mobile-touch-target w-full"
onClick={() => { onClick={() => {
clear(); clear();
}} }}
@@ -651,6 +690,7 @@ function SSHKeyManager({
</div> </div>
<div className="flex flex-row flex-wrap items-center gap-2"> <div className="flex flex-row flex-wrap items-center gap-2">
<Button <Button
className="mobile-touch-target"
disabled={saveKey.isPending} disabled={saveKey.isPending}
onClick={async () => { onClick={async () => {
await saveKey.mutateAsync(draft); await saveKey.mutateAsync(draft);
@@ -660,6 +700,7 @@ function SSHKeyManager({
{editing ? "Update key" : "Save key"} {editing ? "Update key" : "Save key"}
</Button> </Button>
<Button <Button
className="mobile-touch-target"
variant="outline" variant="outline"
disabled={generateKey.isPending} disabled={generateKey.isPending}
onClick={async () => { onClick={async () => {
@@ -683,11 +724,16 @@ function SSHKeyManager({
> >
{generateKey.isPending ? "Generating..." : "Generate key"} {generateKey.isPending ? "Generating..." : "Generate key"}
</Button> </Button>
<Button variant="outline" onClick={clear}> <Button
variant="outline"
onClick={clear}
className="mobile-touch-target"
>
Clear Clear
</Button> </Button>
{selectedKey && ( {selectedKey && (
<Button <Button
className="mobile-touch-target"
variant="destructive" variant="destructive"
onClick={() => deleteKey.mutate(selectedKey.id)} onClick={() => deleteKey.mutate(selectedKey.id)}
> >
@@ -753,7 +799,11 @@ function ResetLocalDatabaseCard() {
Reset the local SQLite settings/media index databases after Reset the local SQLite settings/media index databases after
acknowledging the data loss. acknowledging the data loss.
</p> </p>
<Button variant="destructive" onClick={() => setOpen(true)}> <Button
variant="destructive"
onClick={() => setOpen(true)}
className="mobile-touch-target"
>
Reset local database Reset local database
</Button> </Button>
{resetDatabase.error && ( {resetDatabase.error && (
@@ -780,6 +830,7 @@ function ResetLocalDatabaseCard() {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox
className="mobile-touch-target"
checked={ackSettings} checked={ackSettings}
onCheckedChange={(checked) => setAckSettings(Boolean(checked))} onCheckedChange={(checked) => setAckSettings(Boolean(checked))}
/> />
@@ -787,6 +838,7 @@ function ResetLocalDatabaseCard() {
</label> </label>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox
className="mobile-touch-target"
checked={ackIndex} checked={ackIndex}
onCheckedChange={(checked) => setAckIndex(Boolean(checked))} onCheckedChange={(checked) => setAckIndex(Boolean(checked))}
/> />
@@ -794,6 +846,7 @@ function ResetLocalDatabaseCard() {
</label> </label>
<label className="flex items-center gap-2 text-sm"> <label className="flex items-center gap-2 text-sm">
<Checkbox <Checkbox
className="mobile-touch-target"
checked={ackIrreversible} checked={ackIrreversible}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setAckIrreversible(Boolean(checked)) setAckIrreversible(Boolean(checked))
@@ -850,6 +903,7 @@ export function Settings() {
const [editingMachine, setEditingMachine] = const [editingMachine, setEditingMachine] =
useState<MonitoringMachine | null>(null); useState<MonitoringMachine | null>(null);
const [selectedMachineId, setSelectedMachineId] = useState(""); const [selectedMachineId, setSelectedMachineId] = useState("");
const isMobile = useIsMobile();
const orderedMachines = useMemo(() => machines ?? [], [machines]); const orderedMachines = useMemo(() => machines ?? [], [machines]);
const selectedMachine = useMemo( const selectedMachine = useMemo(
() => () =>
@@ -952,6 +1006,9 @@ export function Settings() {
<TabsTrigger key="ssh-keys" value="ssh-keys"> <TabsTrigger key="ssh-keys" value="ssh-keys">
SSH Keys SSH Keys
</TabsTrigger>, </TabsTrigger>,
<TabsTrigger key="services" value="services">
Services
</TabsTrigger>,
<TabsTrigger key="danger" value="danger"> <TabsTrigger key="danger" value="danger">
Danger Zone Danger Zone
</TabsTrigger>, </TabsTrigger>,
@@ -970,7 +1027,7 @@ export function Settings() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="w-full" className="mobile-touch-target w-full"
onClick={() => { onClick={() => {
clearSSHValidation(); clearSSHValidation();
setMachineDraft(emptyMachine("local")); setMachineDraft(emptyMachine("local"));
@@ -1086,6 +1143,7 @@ export function Settings() {
</div> </div>
<div className="flex flex-row flex-wrap items-center gap-2"> <div className="flex flex-row flex-wrap items-center gap-2">
<Button <Button
className="mobile-touch-target"
variant="outline" variant="outline"
onClick={() => onClick={() =>
openEditMachine( openEditMachine(
@@ -1113,6 +1171,7 @@ export function Settings() {
Edit Edit
</Button> </Button>
<Button <Button
className="mobile-touch-target"
variant="destructive" variant="destructive"
onClick={() => setDeleteMachineId(selectedMachine.id)} onClick={() => setDeleteMachineId(selectedMachine.id)}
> >
@@ -1133,23 +1192,28 @@ export function Settings() {
onSelectKeyId={setSelectedSSHKeyId} onSelectKeyId={setSelectedSSHKeyId}
/> />
)} )}
{tab === "services" && <ServicesAdminCard />}
{tab === "danger" && <ResetLocalDatabaseCard />} {tab === "danger" && <ResetLocalDatabaseCard />}
</TabbedCard> </TabbedCard>
<Dialog {isMobile ? (
open={machineDialogOpen} <SheetForm
onOpenChange={(open) => { open={machineDialogOpen}
if (!open) closeMachineDialog(); onOpenChange={(open) => {
}} if (!open) closeMachineDialog();
> }}
<DialogContent className="sm:max-w-4xl"> title={machineDraft.id ? "Edit machine" : "Create machine"}
<DialogHeader> onSave={() => {
<DialogTitle> void saveMachineDraft(machineDraft);
{machineDraft.id ? "Edit machine" : "Create machine"} }}
</DialogTitle> onCancel={closeMachineDialog}
<DialogDescription> isPending={saveMachine.isPending}
{machineDraft.mode === "local" ? "Local API host" : "SSH target"} saveDisabled={
</DialogDescription> !machineDraft.name ||
</DialogHeader> (machineDraft.mode === "ssh" && !machineDraft.host.trim())
}
saveLabel={machineDraft.id ? "Save machine" : "Create machine"}
isDirty={isMachineDraftDirty(machineDraft, editingMachine)}
>
<MachineEditor <MachineEditor
key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`} key={`${machineDraft.id ?? machineDraft.mode}-${machineDraft.mode}`}
title={ title={
@@ -1170,32 +1234,82 @@ export function Settings() {
sshValidationError={sshValidationError} sshValidationError={sshValidationError}
sshValidationStatus={sshValidationStatus} sshValidationStatus={sshValidationStatus}
/> />
<DialogFooter {machineDraft.id ? (
onCancel={closeMachineDialog} <Button
cancelLabel="Cancel" className="mobile-touch-target"
onConfirm={() => { variant="destructive"
void saveMachineDraft(machineDraft); onClick={() => setDeleteMachineId(machineDraft.id as string)}
}} >
confirmLabel={machineDraft.id ? "Save machine" : "Create machine"} Delete machine
confirmDisabled={ </Button>
!machineDraft.name || ) : null}
(machineDraft.mode === "ssh" && !machineDraft.host.trim()) </SheetForm>
} ) : (
secondaryAction={ <Dialog
machineDraft.id ? ( open={machineDialogOpen}
<Button onOpenChange={(open) => {
variant="destructive" if (!open) closeMachineDialog();
onClick={() => { }}
setDeleteMachineId(machineDraft.id as string); >
}} <DialogContent className="sm:max-w-4xl">
> <DialogHeader>
Delete <DialogTitle>
</Button> {machineDraft.id ? "Edit machine" : "Create machine"}
) : undefined </DialogTitle>
} <DialogDescription>
/> {machineDraft.mode === "local"
</DialogContent> ? "Local API host"
</Dialog> : "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 <ConfirmDialog
open={Boolean(deleteMachineId)} open={Boolean(deleteMachineId)}
title="Delete machine?" title="Delete machine?"
@@ -1212,3 +1326,270 @@ export function Settings() {
</div> </div>
); );
} }
/**
* Services admin card for the Settings > Services tab.
*
* Lists all service instances grouped by type with inline config editing
* (enable/disable, config fields, secrets, save, delete). Lifted from the
* old ServicePage ConfigBody the service page is now a pure operational
* view; all administration lives here.
*/
function ServicesAdminCard() {
const { data: services = [] } = useServiceInstances();
const { data: types = [] } = useServiceTypes();
const [selectedServiceId, setSelectedServiceId] = useState("");
const sortedServices = useMemo(
() =>
[...services].sort((a, b) =>
`${a.service_type}:${a.name}`.localeCompare(
`${b.service_type}:${b.name}`,
),
),
[services],
);
const selectedService = useMemo(
() =>
sortedServices.find((s) => s.id === selectedServiceId) ??
sortedServices[0] ??
null,
[sortedServices, selectedServiceId],
);
const selectedTypeInfo = selectedService
? types.find((t) => t.service_type === selectedService.service_type)
: undefined;
if (sortedServices.length === 0) {
return (
<p className="text-sm text-muted-foreground">
No service instances configured. Create one from the Services page.
</p>
);
}
return (
<div className="grid grid-cols-1 gap-4 md:grid-cols-[320px_minmax(0,1fr)]">
<SelectionRailCard
title="Services"
description="Select a service to edit its configuration."
minHeight={420}
>
{sortedServices.map((svc) => {
const active = svc.id === (selectedService?.id ?? "");
const typeName =
types.find((t) => t.service_type === svc.service_type)?.name ??
svc.service_type;
return (
<div
key={svc.id}
onClick={() => setSelectedServiceId(svc.id)}
className={cn(
"group grid w-full cursor-pointer grid-cols-[minmax(0,1fr)_auto] gap-2 border-t border-border px-3 py-2.5",
active ? "bg-muted" : "bg-card hover:bg-muted/50",
)}
>
<div className="min-w-0">
<p className="truncate font-semibold">{svc.name}</p>
<p className="text-xs text-muted-foreground">
{typeName} · {svc.enabled ? "Enabled" : "Disabled"}
</p>
</div>
<Badge variant={svc.enabled ? "default" : "secondary"}>
{svc.enabled ? "on" : "off"}
</Badge>
</div>
);
})}
</SelectionRailCard>
<SectionCard
title={selectedService?.name ?? "No service selected"}
description={
selectedTypeInfo?.description ??
"Select a service on the left to edit its configuration."
}
>
{selectedService ? (
<ServiceConfigEditor
instance={selectedService}
typeInfo={selectedTypeInfo}
/>
) : (
<p className="text-sm text-muted-foreground">
Select a service on the left.
</p>
)}
</SectionCard>
</div>
);
}
function ServiceConfigEditor({
instance,
typeInfo,
}: {
instance: ServiceInstance;
typeInfo: ServiceTypeInfo | undefined;
}) {
const saveService = useSaveServiceInstance();
const deleteService = useDeleteServiceInstance();
const [name, setName] = useState(instance.name);
const [enabled, setEnabled] = useState(instance.enabled);
const [draftConfig, setDraftConfig] = useState<Record<string, unknown>>({
...instance.config,
});
const [draftSecrets, setDraftSecrets] = useState<Record<string, string>>({});
const [deleteOpen, setDeleteOpen] = useState(false);
const properties =
(
(typeInfo?.config_schema ?? {}) as {
properties?: Record<string, { type?: string; description?: string }>;
}
).properties ?? {};
const configEntries: Array<
[string, { type?: string; description?: string }]
> =
Object.keys(properties).length > 0
? Object.entries(properties).map(([key, schema]) => [
key,
{ type: schema?.type, description: schema?.description },
])
: Object.entries(instance.config).map(([key, value]) => [
key,
{ type: typeof value === "number" ? "integer" : "string" },
]);
function buildInput(): ServiceInstanceInput {
const onlyChangedSecrets = Object.fromEntries(
Object.entries(draftSecrets).filter(([, v]) => v !== ""),
);
return {
id: instance.id,
service_type: instance.service_type,
name,
config: draftConfig,
secrets: onlyChangedSecrets,
enabled,
};
}
async function handleSave() {
await saveService.mutateAsync(buildInput());
setDraftSecrets({});
}
return (
<>
<div className="rounded-lg border p-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium">{instance.name}</span>
<Badge variant={enabled ? "default" : "secondary"}>
{enabled ? "enabled" : "disabled"}
</Badge>
</div>
<div className="flex flex-col gap-3">
<FormField label="Name" htmlFor={`svc-name-${instance.id}`}>
<Input
id={`svc-name-${instance.id}`}
value={name}
onChange={(e) => setName(e.target.value)}
/>
</FormField>
<div className="flex items-center gap-2">
<Switch
id={`svc-enabled-${instance.id}`}
checked={enabled}
onCheckedChange={setEnabled}
/>
<Label htmlFor={`svc-enabled-${instance.id}`}>Enabled</Label>
</div>
{configEntries.map(([key, schema]) => {
const isNumber =
schema.type === "integer" || schema.type === "number";
return (
<FormField
key={key}
label={key}
htmlFor={`svc-cfg-${instance.id}-${key}`}
helperText={schema.description}
>
<Input
id={`svc-cfg-${instance.id}-${key}`}
type={isNumber ? "number" : "text"}
value={String(draftConfig[key] ?? "")}
onChange={(e) =>
setDraftConfig({
...draftConfig,
[key]: isNumber
? e.target.value === ""
? undefined
: Number(e.target.value)
: e.target.value,
})
}
/>
</FormField>
);
})}
{Object.keys(instance.secrets_set).length === 0
? null
: Object.entries(instance.secrets_set).map(([key, isSet]) => (
<FormField
key={key}
label={key}
htmlFor={`svc-secret-${instance.id}-${key}`}
helperText="Leave blank to keep the current value."
>
<Input
id={`svc-secret-${instance.id}-${key}`}
type="password"
placeholder={isSet ? "•••••• (set)" : "Not set"}
value={draftSecrets[key] ?? ""}
onChange={(e) =>
setDraftSecrets({
...draftSecrets,
[key]: e.target.value,
})
}
/>
</FormField>
))}
<div className="flex justify-between">
<Button
onClick={handleSave}
disabled={saveService.isPending}
className="mobile-touch-target"
>
Save
</Button>
<Button
variant="destructive"
onClick={() => setDeleteOpen(true)}
className="mobile-touch-target"
>
Delete
</Button>
</div>
</div>
</div>
<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);
}}
/>
</>
);
}
-1
View File
@@ -1 +0,0 @@
export { UsersPage } from "./UsersPage.impl";

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