Compare commits

..

55 Commits

Author SHA1 Message Date
Developer 8c69911252 docs(unify-tasks): SDD proposal, design, and tasks
Design-only artifacts for unifying saved tasks on ssh_tasks services.
No implementation yet.

- proposal: two-path problem (Actions→machine vs widget→service), goals,
  non-goals, grilling decisions (SSH-only, service_task_runs only, keep override)
- design: shared run_saved_task helper, column rename, saved_task_runs dropped,
  API + frontend changes, 2-slice plan
- tasks: backend (shared runner + router) + frontend (Actions page)
2026-06-23 13:05:52 +00:00
Developer 7b3e2ebace Merge pull request 'chore: remove dead machine-level Jellyfin/Jellyseerr fields' (#13) from chore/remove-dead-machine-jellyfin-fields into main 2026-06-23 12:55:32 +00:00
Developer cfb9977532 chore: remove dead machine-level Jellyfin/Jellyseerr fields
Follow-up #1 to the service-registry change. Jellyfin/Jellyseerr now resolve
from the service registry, so the machine-level app fields are dead config.

- dependencies.py: drop dead _jellyseerr_client_for; simplify _resolve_machine
  to SSH-only.
- settings_store.py + routers/settings.py: remove jellyfin_*/jellyseerr_* from
  machine default config, get_machine_config, normalization, row mappers, and
  MachineInput.
- frontend types + Settings.tsx: drop the fields and the Jellyfin/Jellyseerr
  form sections + service options.
- Update frontend test fixtures.

Existing DB rows may still carry these keys in config_json; they are inert and
drop on the next machine save. Verification: backend ruff clean, pytest 222;
frontend lint 0 errors, build success, 70 tests.
2026-06-23 12:54:27 +00:00
Developer 802a9202e9 Merge pull request 'feat(services): select Jellyfin via jellyfin_service_id on the frontend' (#12) from feat/service-registry-jellyfin-services-frontend into main 2026-06-23 12:28:53 +00:00
Developer 7ab9b1ac59 style(tests): apply formatter to Applications and Media tests 2026-06-23 12:28:53 +00:00
Developer cbb703341e feat(services): select Jellyfin via jellyfin_service_id on the frontend
Slice 4b frontend half. Jellyfin-touching pages now select a Jellyfin service
instance instead of a machine.

- api/client.ts: Jellyfin-backed calls (counts/libraries/activity/users, media
  status/build/stop/force-stop, queryMedia) send jellyfin_service_id.
- hooks/useDashboard, useUsers, useMedia: selector param renamed to
  jellyfinServiceId.
- pages/Media + Applications: list jellyfin service instances and persist
  jellyfin_service_id in the URL.
- Dashboard (widgets) and Users (default instance) need no selector change.
- Update Applications + Media tests for the new hook/param.

Files/SSH transport keeps machine_id. Verification: frontend lint 0 errors,
build success, 70 tests; backend ruff clean, 222 tests.
2026-06-23 12:10:27 +00:00
Developer a13f560df2 Merge pull request 'feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)' (#11) from feat/service-registry-jellyfin-services-backend into main 2026-06-23 11:48:25 +00:00
Developer 5eb49be697 style(dependencies): apply formatter to dependencies rewrite 2026-06-23 11:48:25 +00:00
Developer 8ff735d644 feat(services): resolve Jellyfin/Jellyseerr from the service registry (backend)
Slice 4b backend half. Jellyfin and Jellyseerr clients are now resolved from
service instances instead of machine-level app config.

- Add jellyseerr service definition (6 service types total); add user_id to
  the Jellyfin service config.
- dependencies.py: jellyfin_service_id query param + _service_record
  (decrypt-on-read); get_jellyfin_client / get_jellyseerr_client / get_user_id
  resolve against the service registry (first enabled instance as fallback).
- SSH/Files transport (get_ssh_client) unchanged; still uses machine_id.
- Update service-registry tests for 6 types.

Selection model: split params — ?jellyfin_service_id= for Jellyfin/Jellyseerr,
?machine_id= for SSH/Files. Frontend threading follows in the next PR.

Verification: backend ruff clean, pytest 222 passed; frontend green (unchanged).
2026-06-23 11:43:33 +00:00
Developer d998e6ab0c Merge pull request 'feat(services): cleanup, services admin UI, docs' (#10) from feat/service-registry-cleanup-services-ui into main 2026-06-23 11:07:20 +00:00
Developer 9a6cbfae68 style(services): apply formatter to App and ServicesPage 2026-06-23 11:07:19 +00:00
Developer c9c72be0b6 feat(services): cleanup, services admin UI, docs
PR 4a of the runtime service registry change.

- Remove addon pages (/addons/:addonId, AddonPage, addons/*) superseded by
  service pages.
- Remove grafana_url/prometheus_url from backend config, compose, .env.example,
  and README (URLs now live on service records; VITE_ frontend deep-link vars
  retained).
- Add Services page (/services) with create/list/delete + sidebar nav, so
  services are configurable in the tool itself and service pages are reachable.
- Update docs/REQUIREMENTS.md service-registry section; add CHANGELOG.md with
  the breaking-upgrade note (MANAGE_ENCRYPTION_KEY required; grafana/prometheus
  env vars removed; default widget seeding removed).

Verification: backend ruff clean, pytest 222 passed; frontend lint 0 errors,
build success, 70 tests passed.
2026-06-23 10:57:30 +00:00
Developer 5ec35b4849 Merge pull request 'feat(services): frontend services runtime and widget rebind' (#9) from feat/service-registry-frontend-runtime into main 2026-06-22 19:13:59 +00:00
Developer 739ad38e29 style(services): apply formatter to frontend services runtime 2026-06-22 19:13:59 +00:00
Developer 1da67f38c7 feat(services): frontend services runtime and widget rebind
PR 3 of 4 for the runtime service registry change.

- Add service + new-shape widget TypeScript types; widgets carry service_id
  + widget_kind (service-bound) or null (built-in).
- Add services API client + TanStack Query hooks; reconcile the widget API
  client/hooks to the new endpoints (remove sources/types; add builtin kinds).
- Add closed frontend service registry (integrations/registry.ts) mirroring the
  backend, with resolveWidget(widget, services) mapping a widget to its
  component + refresh interval.
- Add ServicePage at /services/:serviceType/:serviceId with config view,
  empty-on-edit secret inputs + 'set' badges, enable toggle, delete, and the
  service's widget-kind list.
- Register /services/:serviceType/:serviceId in App.tsx.
- Reconcile the six widget components to refreshIntervalMs + description props;
  rewrite WidgetConfigDialog around a service -> widget-kind picker.
- Update Dashboard test; add integrations/registry.test.ts.

Verification: frontend lint 0 errors, build success, 70 tests passed; backend
ruff clean, 222 tests passed.
2026-06-22 18:59:41 +00:00
Developer 41dddbccc0 Merge pull request 'feat(widgets): rebind widgets to the service registry' (#8) from feat/service-registry-widget-rebind into main 2026-06-22 18:22:18 +00:00
Developer f6a86310cc style(widgets): apply formatter to widget rebind files 2026-06-22 18:22:18 +00:00
Developer 10fd4ead4a feat(widgets): rebind widgets to the service registry
PR 2 of 4 for the runtime service registry change.

- dashboard_widgets gains service_id + widget_kind columns (legacy
  addon_id/widget_type kept but unused).
- Source adapters take (service: ServiceRecord | None, widget_kind, config).
  SERVICE_ADAPTERS keyed by service_type; BUILTIN_ADAPTERS for backups/static.
- Backups and static stay as service-less built-ins (service_id nullable),
  exposed via GET /api/widgets/builtin.
- SSH task adapter resolves the task + instance, runs over SSH, and appends a
  service_task_runs history row on success/failure/timeout/error.
- Retire widgets/registry.py; widget metadata now comes from the integrations
  registry + widgets/builtin. Remove /api/widgets/types and /api/widgets/sources.
- Stop default widget seeding (fresh install = empty dashboard).
- Rewrite widget tests around the service-bound + built-in model (26 tests).

Backend-only breaking change; frontend is reconciled in Slice 3. Build/lint
stay green; pytest 222 passed.
2026-06-22 16:42:56 +00:00
Developer 2452e2e1e4 Merge pull request 'feat(services): backend service registry foundation' (#7) from feat/service-registry-backend-foundation into main 2026-06-22 14:00:07 +00:00
Developer fd534a816b style(services): apply formatter to service registry files 2026-06-22 14:00:07 +00:00
Developer 8cdeadd6dd feat(services): backend service registry foundation (encryption, definitions, CRUD)
PR 1 of 4 for the runtime service registry change.

- Add Fernet encryption helper (services/secrets.py) with a required
  MANAGE_ENCRYPTION_KEY; validate it on startup.
- Add closed integrations/ registry with Pydantic config + widget-config
  definitions for grafana, prometheus, jellyfin, nextcloud, and ssh_tasks.
- Add services + service_task_runs tables and SettingsStore CRUD with
  cascade-delete (defensive until widgets carry service_id).
- Add /api/services/types and /api/services/instances CRUD (encrypted secrets,
  secrets_set flags only; never plaintext).
- Declare cryptography as a direct dependency.
- Require MANAGE_ENCRYPTION_KEY in compose + .env.example + README.
- Add 25 backend tests (registry, encryption, CRUD, cascade, task-run history).

Verification: ruff clean; pytest 225 passed; frontend lint/build green.
2026-06-22 12:56:03 +00:00
Developer d1819c0186 Merge pull request 'docs(service-registry): SDD artifacts' from docs/service-registry-sdd into main 2026-06-22 11:14:01 +00:00
Developer 9459de5c07 docs(service-registry): lock decisions (cascade delete, required key, SSH runner model)
- §11 decisions: cascade-delete services with widgets; MANAGE_ENCRYPTION_KEY
  always required; SSH task runner is multi-instance with reusable tasks.
- §12 SSH task runner model: instances absorb SSH task transport, tasks stay
  global/reusable with default_service_id, service_task_runs logs history,
  widget config { task_id, service_id? }.
- tasks.md: add service_task_runs table + cascade-delete tests to Slice 1,
  SSH run-logging to Slice 2, follow-ups (Actions rebuild, machine unification).
2026-06-22 11:14:01 +00:00
Developer 9782280a03 docs(service-registry): SDD proposal, design, and tasks
Design-only artifacts for the runtime service registry change. No
implementation yet.

- proposal: motivation, goals, non-goals, grilling decisions, risks
- design: data model, Pydantic service definitions, encryption, API,
  frontend structure, migration/breaking changes, 4-PR slice plan
- tasks: backend foundation, backend widget rebind, frontend services
  runtime, dashboard + settings rework + docs
2026-06-22 10:07:25 +00:00
Developer 0ad6a04053 Merge pull request 'docs(deploy): update README and .env.example for Docker deployment' (#6) from docs/update-readme-env-deployment into main 2026-06-22 09:28:06 +00:00
Developer 75636c00d4 docs(deploy): update README and .env.example for Docker deployment
- Refresh README feature list and remove references to the legacy
  in-app monitoring charts / backend poller.
- Document configurable dashboard widgets, addon pages, and widget env vars.
- Add VITE_PROMETHEUS_URL support to frontend Dockerfile and both compose files.
- Add header comment to .env.example explaining shell-export workflow.
- Update remote server requirements to match current capabilities.
2026-06-22 09:28:06 +00:00
Developer f4b16b5844 Merge pull request 'feat(widgets): dashboard loop, widget config UI, and addon pages' (#5) from feat/dashboard-widgets-ui-pages into main 2026-06-22 08:05:45 +00:00
Developer 09eb76bf0f style(widgets): apply formatter to dashboard and addon files 2026-06-22 08:05:44 +00:00
Developer ed7a7a5ce0 feat(widgets): dashboard loop, widget config UI, and addon pages
PR 4 of 4 for configurable dashboard widgets.

- Replace hard-coded Jellyfin/Backups dashboard sections with a loop that
  renders enabled widget instances by sort_order.
- Add WidgetInstance renderer and WidgetConfigDialog for adding, editing,
  enabling/disabling, deleting, and reordering widgets.
- Add addon pages for grafana, prometheus, and ssh-tasks at /addons/:addonId.
- Register /addons/:addonId route in App.tsx.
- Update docs/REQUIREMENTS.md with the widget system design and API.

Verification:
- backend ruff clean; pytest 200 passed
- frontend npm run lint: 0 errors
- frontend npm run build: success
- frontend npm run test -- src/widgets/registry.test.ts: 3 passed
2026-06-21 20:45:42 +00:00
Developer e4e879d1c8 Merge pull request 'feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)' (#4) from feat/dashboard-widgets-frontend-runtime into main 2026-06-21 16:55:13 +00:00
Developer 2557185fb7 style(widgets): apply formatter to widget runtime files 2026-06-21 16:55:12 +00:00
Developer e1356b20f1 feat(widgets): add frontend widget runtime (types, API, hooks, registry, components)
PR 3 of 4 for configurable dashboard widgets.

- Add TypeScript widget interfaces (WidgetInstance, WidgetInstanceInput,
  WidgetTypeInfo, WidgetDataResponse).
- Create widget API client for CRUD, registry metadata, and per-widget data.
- Create TanStack Query hooks for instances, data, sources, types, and mutations.
- Create closed frontend widget registry with metadata, source type, refresh
  intervals, and config fields.
- Add six shadcn/ui-based widget components: Jellyfin, Backups, Grafana link,
  Prometheus metric, SSH task output, and static text.
- Add Vitest unit tests for registry metadata.

Verification:
- backend ruff clean; pytest 200 passed
- frontend npm run lint: 0 errors
- frontend npm run build: success
- frontend npm run test -- src/widgets/registry.test.ts: 3 passed
2026-06-21 16:24:44 +00:00
Developer e6d333ef7b Merge pull request 'feat(widgets): add backend source adapters and per-widget data endpoint' (#3) from feat/dashboard-widgets-backend-adapters into main 2026-06-21 16:01:44 +00:00
Developer 1cd8e926de feat(widgets): add backend source adapters and per-widget data endpoint
PR 2 of 4 for configurable dashboard widgets.

- Add grafana_url and prometheus_url settings (config.py + compose/env).
- Create WidgetSource protocol and adapters for jellyfin, backups, grafana,
  prometheus, ssh_task, and static sources.
- Add GET /api/widgets/instances/{id}/data endpoint.
- Extract shared dashboard helpers into domain/dashboard.py so widgets and
  the dashboard router reuse the same logic.
- Add adapter and data-endpoint tests.
- Update apply-progress.md.

Verification: ruff clean; backend pytest 200 passed; frontend lint/build green.
2026-06-21 10:09:45 +00:00
alex 1a52dfb087 Merge pull request 'feat(widgets): add backend CRUD, registry, and default seeding' (#2) from feat/dashboard-widgets-backend-crud into main
Reviewed-on: #2
2026-06-19 22:15:58 +02:00
alex 9dfe62eb6f Merge pull request 'chore(config): remove dead GRAFANA_URL and wire VITE_GRAFANA_URL' (#1) from chore/wire-grafana-url-envs into main
Reviewed-on: #1
2026-06-19 22:15:46 +02:00
Developer 200d319fb0 feat(widgets): add backend CRUD, registry, and default seeding
Introduce a closed, compile-time widget registry and backend CRUD for
dashboard widget instances.

- Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and
  default seeding (Jellyfin + Backups) on first install.
- Add Pydantic models with credential-key and secret-value rejection.
- Add widgets router: /api/widgets/sources, /types, /instances CRUD.
- Call ensure_defaults() in app lifespan so fresh installs seed defaults.
- Add backend tests covering registry, CRUD, validation, and seeding.
- Include SDD artifacts: exploration, proposal, spec, design, tasks.
2026-06-19 20:07:47 +00:00
Developer 24427b4869 chore(config): remove dead GRAFANA_URL and wire VITE_GRAFANA_URL
- Remove GRAFANA_URL from backend environment (backend never consumed it).
- Add VITE_GRAFANA_URL to frontend build-args (prod), dev environment, and
  frontend/Dockerfile ARG/ENV so Grafana deep-links resolve correctly.
- Add ALERTMANAGER_WEBHOOK_URL to backend environment so the documented
  alert-forwarding feature is reachable from compose.
- Document VITE_GRAFANA_URL in .env.example.
2026-06-19 20:07:47 +00:00
Developer bb8b040657 docs(monitoring): record legacy poller decommission (slice 3)
Update docs to reflect that Manage no longer scrapes its own system
metrics (slices 1-2). AGENTS.md, REQUIREMENTS.md (decision log +
observability section), monitoring-logging-design.md, MIGRATION_PLAN.md.

Gate: docs only; backend pytest (173) + frontend build/lint/test (22/63)
remain green from slices 1-2.
2026-06-17 20:55:24 +00:00
Developer a8eb751322 refactor(monitoring): remove orphaned frontend DiskSpaceCard (slice 2)
After slice 1 removed /api/monitoring/disk, the frontend DiskSpaceCard
component (and its DiskSpace type) had zero importers — it fed nothing.
Delete them.

Removed (frontend):
- components/DiskSpaceCard.tsx
- components/__tests__/DiskSpaceCard.test.tsx
- types/index.ts: DiskSpace interface

Gate: build + lint + vitest (22 files / 63 tests) green.
2026-06-17 20:51:54 +00:00
Developer 08a3b616f6 refactor(monitoring): decommission legacy SSH-scraping poller (slice 1)
The 2026-06-16/17 observability update externalised metrics to
Prometheus + node_exporter + Grafana, but the legacy Manage-side
SSH-scraping monitor was never removed. It duplicated the new stack,
ran SSH df on every machine every 300s, and fed nothing (its UI was
deleted in e2ad731). This slice decommissions the duplication.

Removed (backend):
- services/monitoring_poller.py (MonitoringPoller) — entire file
- services/monitoring_actions.py (disk_space, run_machine_operation,
  poll_machine_snapshot, build_machine_client) — entire file;
  run_machine_operation had only 2 callers (the poller + /disk), both gone
- tests/test_monitoring_actions.py
- endpoints: POST /api/monitoring/poller, GET /machines/{id}/actions,
  GET /disk (and the now-dead _resolve_machine helper)
- lifespan wiring (main.py), dependency wrapper (dependencies.py),
  poller.start()/kick() from machine save (routers/settings.py)
- SettingsStore: monitoring_machine_actions table CREATE + 2 indexes +
  record/list/prune_machine_actions methods; DROP TABLE IF EXISTS on
  startup cleans existing DBs (user-approved)
- config knobs: monitoring_poll_interval_seconds,
  monitoring_poll_initial_delay_seconds, monitoring_action_retention_days
- test_api.py: TestMonitoring._ensure_machine + test_disk

Kept (fits the new model): /machines, /prometheus-targets, /alerts,
/alertmanager-status, /alertmanager-webhook; the disk_usage JOB template
(manual on-demand, not monitoring); node_exporter_* machine fields
(they point Prometheus at the right host).

Gate: backend pytest 173 passed; ruff clean.
2026-06-17 20:48:56 +00:00
Developer 1c29299e8c chore(openspec): archive web-ui-rework change
Move the completed, verified, synced change folder to the dated archive:
openspec/changes/web-ui-rework/ -> openspec/changes/archive/2026-06-17-web-ui-rework/

Canonical spec stays in place at openspec/specs/web-ui/spec.md (not moved).
This is the physical archive step deferred (documented-pending-manual) in
archive-report.md ec46e26; user-approved.
2026-06-17 19:44:57 +00:00
Developer 0ec2a8806b style(frontend): wrap long ResizeObserver assignment in test setup
Background linter/runner keeps re-applying this single-line wrap; commit it
so the working tree stays stable. No behavior change.
2026-06-17 19:44:57 +00:00
Developer 9cae5fc98c chore(openspec): archive report — web-ui-rework lifecycle complete
Disposition: documented-pending-manual (archive-move deferred to avoid guessing
the OpenSpec archive convention destructively). Remaining manual step recorded:
move openspec/changes/web-ui-rework/ -> openspec/changes/archive/2026-06-17-web-ui-rework/
(canonical openspec/specs/web-ui/spec.md stays in place).

All 8 lifecycle phases done: proposal/spec/design/tasks/apply/verify/sync/archive.
71/71 tasks complete; gates green (build + lint + vitest 23/64 + node 5/5).

Carry-over follow-ups recorded for future cleanup:
- slice-5 shipped without the prescribed 5a/5b sub-split (correct + tested)
- tasks.md 'node --test tests' cross-check is a pre-existing broken command
  (correct: 'node --test'); recommend docs/npm-script follow-up
- no visual/browser smoke performed (component tests are structural)
2026-06-17 19:36:23 +00:00
Developer 7646f3236f chore(openspec): verify + sync reports, canonical web-ui spec (rework PASS)
Web UI rework lifecycle: verify + sync phases.
- verify-report.md: PASS verdict (23/64 vitest, 5/5 node, zero @mui in src,
  71/71 tasks, all spec scenarios green). Non-blocking findings recorded:
  slice-5 review-budget overage (5a/5b not split); node --test tests cmd typo.
- sync-report.md + openspec/specs/web-ui/spec.md: canonical domain spec
  distilled from the verified change (design system, thin-dashboard model,
  TanStack visibility-only tables, reconciled IA, removed deps, Vitest harness).
- Change-side delta spec under openspec/changes/web-ui-rework/specs/.
2026-06-17 19:15:15 +00:00
Developer 9de2d5b8d2 feat(frontend): slice 8 — remove MUI/@emotion deps + update REQUIREMENTS
Web UI rework. Final slice.
- Remove from package.json: @mui/material, @mui/icons-material,
  @mui/x-data-grid, @emotion/react, @emotion/styled (zero consumers
  remain in src after slices 1-7b; grep-verified).
- Update docs/REQUIREMENTS.md (+63 lines): single design system
  (shadcn/ui + Tailwind v4 + lucide-react), thin-dashboard observability
  model (no in-app charts; Grafana deep-links), TanStack tables
  (visibility-only parity), reconciled IA (Backups top-level nav;
  Media at /media with /applications redirect), repurposed chart-*
  status cues, removed deps list.
- Note: AGENTS.md 'node --test tests' invocation is a pre-existing
  broken command (treats tests/ as a module); correct form is
  'node --test' (auto-discover, 5/5 pass) — verified identical at
  pre-rework baseline ef5311b.

Gate: build + lint + vitest (23/64) + node --test (5/5) green.
Web UI rework complete.
2026-06-17 18:52:27 +00:00
Developer 3dc1b31fc3 feat(frontend): slice 7b — Media on TanStack Table (server pagination)
Web UI rework. Completes the DataGrid migration (7a + 7b):
- pages/Media.tsx off @mui/x-data-grid + @mui/material onto DataTable:
  15 locked columns (title/series/season/episode/type/year/runtime_min/
  size/bitrate/hdr/video/resolution/date_added/library/path);
  enablePagination + manualPagination + rowCount from queryResult.total;
  page state (pageIndex/pageSize) -> offset/limit into useMediaQuery;
  onRowClick -> navigate('/files?path=...') preserved; stable path-derived
  getRowId so selection survives server paging; column-visibility toggle.
  Hard rule honored: NO sorting, NO resizing (visibility-only).
- Migrate Media shell (Select/Input/Progress/Card/grid/Typography/Tabs).
- Media component tests (column set + row-click nav).
- Harness fix: polyfill ResizeObserver in test/setup.ts — jsdom lacks it
  and Radix primitives (Select/ScrollArea/etc.) reference it; was causing
  cross-test failures once Media pulled shadcn Select into the pool.

Gate: build + lint + test green (23 files / 64 tests).
2026-06-17 18:33:59 +00:00
Developer e8b0f1144b feat(frontend): slice 7a — DataTable wrapper + FileBrowser (TanStack Table)
Web UI rework. Highest-risk slice, part 1 of 2:
- New components/ui/data-table.tsx: generic TanStack Table wrapper on the
  shadcn Table primitive. Controlled rowSelection/columnVisibility/
  pagination, optional selection column (stopPropagation on cell click),
  row-click, column-visibility dropdown, manual-pagination support.
  Hard rule honored: NO getSortedRowModel, NO column resizing/sizing.
- Migrate pages/FileBrowser.impl.tsx off @mui/x-data-grid + @mui/material
  onto DataTable: 5 columns (type/name/ext/size/modified), row-click ->
  ffprobe preview preserved, column-visibility toggle, no pagination.
- DataTable + FileBrowser component tests (RED->GREEN).

Gate: build + lint + test green (22 files / 58 tests).
2026-06-17 18:02:47 +00:00
Developer 04f2e59c92 feat(frontend): slice 6b — Users compose dialog + 9 icons (finish Users)
Web UI rework. Completes the Users migration (6a directory + 6b compose):
- Compose dialog off @mui: shadcn Dialog family + Input (subject) +
  Textarea (html body) + Separator + Label; IconButton -> Button ghost
- 9 @mui/icons-material -> lucide-react: Close->X, AttachFile->Paperclip,
  FormatBold->Bold, FormatItalic->Italic, Link->Link,
  FormatListBulleted->List, MailOutlined->Mail, Send->Send,
  DeleteOutlined->Trash2
- Rich-text compose parity: markup insertion, attachments (FormData),
  queue-status polling, send via useSendUserMessage
- UsersPage.impl.tsx now has ZERO @mui imports

Gate: build + lint + test green (20 files / 46 tests).
2026-06-17 16:18:49 +00:00
Developer 1e23c07a20 feat(frontend): slice 6a — Users directory surface + drawer (shadcn)
Web UI rework. Slice 6a (force-split; 6b = compose dialog next):
- UsersPage.impl.tsx directory surface off @mui: shadcn Table family +
  Checkbox + Badge (status: success=chart-2/destructive/secondary) +
  Avatar + Tooltip + Progress + Alert/Button/Stack/Typography
- MUI Drawer -> shadcn Sheet side="right" for user detail drawer
  (buildUserDrawerModel rendering preserved)
- Selection-across-pagination + search/filter parity preserved
- Compose-dialog MUI subset (Dialog/TextField/Divider/IconButton +
  9 icons) intentionally LEFT for slice 6b

Gate: build + lint + test green.
2026-06-17 14:36:07 +00:00
Developer b6da7df7f9 feat(frontend): slice 5 — migrate Settings + Actions to shadcn/Tailwind
Web UI rework. Form-heavy pair (controlled useState parity, no form lib):
- pages/Settings.tsx off @mui: monitoring-machine CRUD, SSH-key mgmt,
  SSH test/validation feedback, danger-zone reset (ConfirmDialog), tabs
- pages/Actions.tsx off @mui: saved-task editor, machine selection,
  run history, tabs
- Both reuse migrated shared components (SectionCard/SelectionRailCard/
  TabbedCard/HoverEditButton/ConfirmDialog/DialogFooter) as before
- Behavioral tests added (mocked hooks; no live SSH)

Gate: build + lint + test green (19 files / 39 tests).
2026-06-17 13:47:57 +00:00
Developer c721f0dece feat(frontend): slice 4 — migrate Dashboard + Applications to shadcn/Tailwind
Web UI rework.
- Migrate pages/Dashboard.tsx off @mui (shortcut CRUD dialogs, machine
  switcher, BackupDashboardWidget mount; shortcuts reuse ConfirmDialog/
  DialogFooter from slice 2)
- Migrate pages/Applications.tsx shell off @mui (tabs + library counts);
  keeps <Media/> child intact (Media.tsx still MUI, deferred to slice 7
  with its DataGrid)
- Behavioral tests for both pages

Gate: build + lint + test green.
2026-06-17 13:15:51 +00:00
Developer 77c6b62ee2 feat(frontend): slice 3 — Backups cluster migration + nav/IA
Web UI rework.
- Migrate BackupAlertsTable, BackupJobsTable, BackupRunsTable,
  BackupsPage, BackupDashboardWidget off @mui (shadcn Table + Badge
  severity variants: success=chart-2, warning=chart-3, destructive)
- App.tsx IA: Backups now top-level nav (DatabaseBackup icon);
  Media surface primary at /media; /applications -> /media redirect
  in both route trees (mirrors /monitoring -> /observability)

Gate: build + lint + test green.
2026-06-17 12:53:36 +00:00
Developer 109e74db41 feat(frontend): slice 2 — migrate 11 shared components to shadcn/Tailwind
Web UI rework. Shared-components slice (drift prevention):
- Migrate SectionCard, SelectionRailCard, TabbedCard, MetricCard,
  DiskSpaceCard, HoverEditButton, DialogFooter, ConfirmDialog,
  LibraryOverview, NowPlaying, SessionActivityPanel off @mui
- HoverEditButton: MUI IconButton + EditOutlined -> Button + lucide Pencil
- Status mapping uses the success Badge variant (chart-2) for healthy
- Exported APIs preserved so consuming pages still compile (no page edits)
- 11 behavioral Vitest component tests added

Gate: build + lint + test green.
2026-06-17 12:33:47 +00:00
Developer dd778d8850 feat(frontend): slice 1 — foundation for MUI->shadcn migration
Web UI rework (openspec/changes/web-ui-rework). Foundation slice:
- Add @tanstack/react-table; remove orphaned recharts, d3
- Vendor shadcn primitives: tabs table dialog input label checkbox
  switch progress separator avatar textarea dropdown-menu scroll-area
- Add Vitest + @testing-library/react + jsdom harness; npm test script
- Delete no-op theme.ts shim; remove getAppTheme references
- Smoke test proves the harness

Gate: build + lint + test green.
2026-06-17 12:11:05 +00:00
156 changed files with 20727 additions and 6662 deletions
+9
View File
@@ -1,3 +1,7 @@
# Manage environment template
# Copy this file to .env, fill in the required values, and export them in your shell
# before running docker compose. Compose files use interpolation, not env_file.
# App # App
APP_VERSION=0.1.0 APP_VERSION=0.1.0
APP_BUILD_INFO=dev APP_BUILD_INFO=dev
@@ -23,6 +27,9 @@ PROMETHEUS_ENABLED=true
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093 ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL= ALERTMANAGER_WEBHOOK_URL=
# Required: master key for encrypting service secrets (API keys/tokens) at rest.
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
BACKEND_CACHE_DIR=./backend-cache BACKEND_CACHE_DIR=./backend-cache
# Auth # Auth
@@ -41,6 +48,8 @@ VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
VITE_DEV_API_PROXY_TARGET=http://backend:8000 VITE_DEV_API_PROXY_TARGET=http://backend:8000
VITE_GRAFANA_URL=https://grafana.example.com
VITE_PROMETHEUS_URL=https://prometheus.example.com
# SMTP # SMTP
SMTP_HOST=smtp.example.com SMTP_HOST=smtp.example.com
+4 -1
View File
@@ -1,12 +1,14 @@
# AGENTS.md # AGENTS.md
## Layout ## Layout
- Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`. - Current app is `backend/` (FastAPI) plus `frontend/` (Vite React); ignore Streamlit-era commands in `CONTRIBUTING.md`.
- Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`). - Backend entrypoint: `backend/src/media_library_viewer_api/main.py` (`media_library_viewer_api.main:app`).
- Frontend entrypoint: `frontend/src/main.tsx`. - Frontend entrypoint: `frontend/src/main.tsx`.
- Backend uses a `src/` layout; tests live in `backend/tests/`. - Backend uses a `src/` layout; tests live in `backend/tests/`.
## Commands ## Commands
- Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'` - Backend setup: `cd backend && python -m venv .venv && source .venv/bin/activate && pip install -e '.[dev]'`
- Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`. - Backend run: `uvicorn media_library_viewer_api.main:app --reload --port 8000`; if not installed, use `PYTHONPATH=src uvicorn media_library_viewer_api.main:app --reload --port 8000`.
- Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`. - Backend tests: run `pytest` from `backend/`; focused checks can use `pytest tests/test_api.py` or `pytest -k <expr>`; if the package is not installed, use `PYTHONPATH=src pytest`.
@@ -17,12 +19,13 @@
- Production stack: `docker compose up --build` - Production stack: `docker compose up --build`
## Repo-Specific Gotchas ## Repo-Specific Gotchas
- Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them. - Root compose files rely on environment-variable interpolation, not `env_file`; export required values before running them.
- Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars). - Production compose needs the host/cert and OIDC variables from `docker-compose.yml` (`BACKEND_APP_HOST`, `FRONTEND_APP_HOST`, `CERT_RESOLVER`, and the frontend OIDC vars).
- Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines. - Dev compose runs with auth off and does not need SSH key material unless you add remote SSH machines.
- `backend_cache` persists the media index and the managed `known_hosts` file. - `backend_cache` persists the media index and the managed `known_hosts` file.
- SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`. - SSH host-key checking is strict, but the first successful connect records the host key into backend-managed `known_hosts`.
- Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and monitoring poller. - Backend startup validates auth settings, rebuilds managed `known_hosts`, and starts the mail queue and backup alert poller.
- Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically. - Machine-level settings now own Jellyfin/Jellyseerr/SSH config; the backend seeds a local machine automatically.
- Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact. - Remote job templates live in `backend/src/media_library_viewer_api/jobs.py`; keep shell quoting intact.
- Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11. - Backend Ruff config is in `backend/pyproject.toml` and uses line length 120 with Python 3.11.
+66
View File
@@ -0,0 +1,66 @@
# Changelog
All notable changes to Manage. Breaking changes are marked with **BREAKING**.
## [Unreleased]
### Added — Service registry
- Runtime **service registry** persisted in the backend SQLite database. External
services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are now
configured in the app instead of via environment variables.
- Services page (`/services`) to create, list, and delete service instances.
- Service detail pages (`/services/:serviceType/:serviceId`) to edit name/enabled
state, rotate secrets, and view the widgets a service provides.
- Service definitions live as Pydantic modules in `backend/.../integrations/`,
each declaring its config schema, secret fields, and widget kinds.
- Multi-instance support: multiple Grafana/Jellyfin/etc. instances per type.
- SSH task runner service records run history in a new `service_task_runs`
table, shown on the runner's service page.
### Changed
- Dashboard widgets are now **service-bound** (reference a service instance +
widget kind) or **built-in** (backups, static text). The "Add widget" flow is
pick-service → pick-widget-kind → configure.
- Deleting a service cascade-deletes widgets that reference it.
### Security
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
Fernet.
### **BREAKING**
- **`MANAGE_ENCRYPTION_KEY` is now required** to start the backend. Generate one
with:
```bash
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
```
- The `GRAFANA_URL` and `PROMETHEUS_URL` backend environment variables were
removed; Grafana/Prometheus URLs now live on service records configured in the
UI. Re-create them on the Services page after upgrading.
- The legacy widget/addon-pages model (`/addons/:addonId`,
`/api/widgets/types`, `/api/widgets/sources`) was removed in favor of the
service registry.
- Default dashboard widget seeding was removed; a fresh install starts with an
empty dashboard. Add widgets from the dashboard's edit dialog after
configuring services.
### Notes / follow-ups
- Machine-level Jellyfin/Jellyseerr app config still powers the Media/Users/Files
pages. Migrating those onto the service registry is a separate follow-up change
(see `openspec/changes/service-registry/design.md` §12.5).
## Follow-up #1 — remove dead machine Jellyfin/Jellyseerr fields
With Jellyfin/Jellyseerr now resolved from the service registry, the machine-level
Jellyfin/Jellyseerr fields are dead config. Removed from `dependencies.py` (dead
`_jellyseerr_client_for`; `_resolve_machine` simplified to SSH-only),
`services/settings_store.py`, `routers/settings.py` (`MachineInput`), frontend
types, the `Settings.tsx` form, and frontend test fixtures. Existing DB rows may
still carry these keys in `config_json`; they are inert and get dropped on the
next machine save. No data migration required.
+37 -21
View File
@@ -20,14 +20,15 @@ The project consists of two subprojects:
## Features ## Features
- Dashboard with now-playing sessions, server monitoring overview, and per-library media counts - Configurable dashboard with persisted widgets (Jellyfin activity, backups summary, Grafana deep-links, Prometheus metrics, SSH task output, static text) and shortcuts
- Server monitoring with CPU, IO wait, RAM, network, and disk I/O charts plus a sortable dashboard table covering all configured machines - Thin-dashboard observability: Alertmanager alerts, Prometheus target health, machine status, and Grafana deep-links (no in-app charting)
- Per-machine monitoring settings with local and remote targets managed in the UI, plus backend-collected recent action history per machine - Per-machine settings for Jellyfin, Jellyseerr, SSH, and monitoring targets
- SQLite-indexed media table with full-library sort/filter - SQLite-indexed media table with full-library sort/filter
- Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment - Read-only Users tab with Jellyfin as the base source and optional Jellyseerr enrichment
- Remote file browser with ffprobe preview and job execution - Remote file browser with ffprobe preview and job execution
- Jellyfin API integration for library metadata and user identity data - Jellyfin API integration for library metadata and user identity data
- SSH-based file inspection and remote job templates - SSH-based file inspection and safe remote job templates
- Addon pages for Grafana, Prometheus, and SSH tasks at `/addons/:addonId`
## Quick Start ## Quick Start
@@ -39,7 +40,9 @@ Production-style deployment with the frontend serving the SPA and proxying `/api
docker compose up --build docker compose up --build
``` ```
Open the app at http://localhost:8080. Open the app at <http://localhost:8080>.
The production Compose file requires OIDC and Traefik variables; see [Configuration](#configuration) below. Copy `.env.example` to `.env`, fill in the required values, and export them in your shell before running `docker compose up`.
Local development with hot reload: Local development with hot reload:
@@ -47,9 +50,9 @@ Local development with hot reload:
docker compose -f docker-compose.dev.yml up --build docker compose -f docker-compose.dev.yml up --build
``` ```
Frontend runs on http://localhost:5173 and the backend on http://localhost:8000. Frontend runs on <http://localhost:5173> and the backend on <http://localhost:8000>. Dev compose disables OIDC by default (`AUTH_ENABLED=false`), so you can open it directly without an identity provider.
The backend media index is persisted in a Docker volume (`backend_cache`) so rebuilds and container restarts do not force a full re-index.
Monitoring machine definitions and recent machine activity are stored in the backend so the UI can show one section per configured machine and preserve history across restarts. The backend media index and settings database (including monitoring machines, SSH keys, saved tasks, and dashboard widgets) are persisted in Docker volumes so rebuilds and container restarts do not reset state.
### Manual backend/frontend development ### Manual backend/frontend development
@@ -76,13 +79,17 @@ The Compose files use environment-variable interpolation. Export the required va
Production-style example with shell exports: Production-style example with shell exports:
```bash ```bash
export BACKEND_APP_HOST=manage.example.com export BACKEND_APP_HOST=api.manage.example.com
export FRONTEND_APP_HOST=manage.example.com export FRONTEND_APP_HOST=manage.example.com
export GRAFANA_APP_HOST=grafana.manage.example.com
export CERT_RESOLVER=letsencrypt export CERT_RESOLVER=letsencrypt
export VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ export VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
export VITE_OIDC_CLIENT_ID=manage export VITE_OIDC_CLIENT_ID=manage
export VITE_OIDC_REDIRECT_URI=https://manage.example.com/ export VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ export VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
export VITE_GRAFANA_URL=https://grafana.manage.example.com
export VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
export MANAGE_ENCRYPTION_KEY=$(python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())")
docker compose up --build docker compose up --build
``` ```
@@ -90,7 +97,7 @@ docker compose up --build
Inline one-liner example: Inline one-liner example:
```bash ```bash
BACKEND_APP_HOST=manage.example.com FRONTEND_APP_HOST=manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://authentik.example/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ docker compose up --build BACKEND_APP_HOST=api.manage.example.com FRONTEND_APP_HOST=manage.example.com GRAFANA_APP_HOST=grafana.manage.example.com CERT_RESOLVER=letsencrypt VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/ VITE_OIDC_CLIENT_ID=manage VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/ VITE_GRAFANA_URL=https://grafana.manage.example.com VITE_PROMETHEUS_URL=https://prometheus.manage.example.com docker compose up --build
``` ```
For local development, no SSH key is required unless you want to connect to remote SSH machines later: For local development, no SSH key is required unless you want to connect to remote SSH machines later:
@@ -124,27 +131,35 @@ SMTP_TIMEOUT=30
# Authentik / OIDC # Authentik / OIDC
AUTH_ENABLED=true AUTH_ENABLED=true
OIDC_ISSUER_URL=https://authentik.example/application/o/media-library-viewer/ OIDC_ISSUER_URL=https://auth.example.com/application/o/manage/
OIDC_AUDIENCE=media-library-viewer OIDC_AUDIENCE=manage
OIDC_JWKS_URL= OIDC_JWKS_URL=
OIDC_CLOCK_SKEW_SECONDS=30 OIDC_CLOCK_SKEW_SECONDS=30
# Frontend OIDC settings # Frontend OIDC settings
VITE_OIDC_ENABLED=true VITE_OIDC_ENABLED=true
VITE_OIDC_ISSUER=https://authentik.example/application/o/media-library-viewer/ VITE_OIDC_ISSUER=https://auth.example.com/application/o/manage/
VITE_OIDC_CLIENT_ID=media-library-viewer VITE_OIDC_CLIENT_ID=manage
VITE_OIDC_SCOPE=openid profile email VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=http://localhost:8080/ VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
# Grafana / Prometheus public URLs for frontend deep-links (service adapters read URLs from service records)
VITE_GRAFANA_URL=https://grafana.manage.example.com
VITE_PROMETHEUS_URL=https://prometheus.manage.example.com
# Required: master key encrypting service secrets (API keys/tokens) at rest.
# Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
MANAGE_ENCRYPTION_KEY=replace-with-a-fernet-key
``` ```
## Remote server requirements ## Remote server requirements
The remote server needs: The remote server needs:
- Linux `/proc` and `/sys/block` for monitoring
- `/bin/sh` (POSIX shell) - `/bin/sh` (POSIX shell)
- `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` - `python3`, `ffprobe`, `find`, `stat`, `df`, `awk` for file inspection and job templates
- SSH access with a key configured in the app's Settings tab
The SSH client rejects unknown host keys. Connect manually once first: The SSH client rejects unknown host keys. Connect manually once first:
@@ -167,5 +182,6 @@ cd frontend && npx tsc --noEmit && npm run build
- Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively. - Jellyfin server root URL required (not `/web`). The client strips trailing `/web` defensively.
- SSH commands run through `/bin/sh -c` regardless of remote login shell. - SSH commands run through `/bin/sh -c` regardless of remote login shell.
- Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`. - Job templates are shell-quoted. Add new templates in `backend/src/media_library_viewer_api/jobs.py`.
- Monitoring collector uses JSONL in `/tmp`, pruned to 7 days / 70k lines.
- Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries. - Root-level Docker Compose files are provided for production (`docker-compose.yml`) and local development (`docker-compose.dev.yml`), and both rely on Compose interpolation rather than `env_file` entries.
- The configurable dashboard stores widget instances in the backend SQLite settings database. New installs seed default Jellyfin activity and Backups widgets automatically.
- Grafana and Prometheus widget adapters resolve URLs from service records configured in the app; `VITE_GRAFANA_URL` / `VITE_PROMETHEUS_URL` are only used for frontend deep-links. No credentials are stored in widget config; service API keys are encrypted at rest with `MANAGE_ENCRYPTION_KEY`.
+1
View File
@@ -15,6 +15,7 @@ dependencies = [
"python-multipart>=0.0.9", "python-multipart>=0.0.9",
"prometheus-client>=0.21", "prometheus-client>=0.21",
"python-json-logger>=2.0", "python-json-logger>=2.0",
"cryptography>=42.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
@@ -52,11 +52,6 @@ class Settings(BaseSettings):
ssh_password: str = "" ssh_password: str = ""
ssh_known_hosts_path: str = "" ssh_known_hosts_path: str = ""
# Monitoring poller
monitoring_poll_interval_seconds: int = 300
monitoring_poll_initial_delay_seconds: int = 20
monitoring_action_retention_days: int = 30
# Observability # Observability
prometheus_enabled: bool = True prometheus_enabled: bool = True
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd" prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
@@ -1,9 +1,12 @@
"""Dependency injection for FastAPI. """Dependency injection for FastAPI.
Provides access to machine-specific Jellyfin/SSH clients via FastAPI's request Provides access to service-specific Jellyfin/Jellyseerr clients and
context. The selected machine can be chosen with a ``machine_id`` query machine-specific SSH clients via FastAPI's request context.
parameter; otherwise the backend falls back to the first enabled machine that
matches the requested service. - Jellyfin/Jellyseerr are selected with a ``jellyfin_service_id`` query
parameter (resolved against the service registry); the backend falls back to
the first enabled ``jellyfin``/``jellyseerr`` service instance.
- SSH/Files transport is selected with ``machine_id`` as before.
""" """
from __future__ import annotations from __future__ import annotations
@@ -21,12 +24,6 @@ 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
from media_library_viewer_api.services.mail_queue import MailQueue from media_library_viewer_api.services.mail_queue import MailQueue
from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue from media_library_viewer_api.services.mail_queue import get_mail_queue as _get_mail_queue
from media_library_viewer_api.services.monitoring_poller import (
MonitoringPoller,
)
from media_library_viewer_api.services.monitoring_poller import (
get_monitoring_poller as _get_monitoring_poller,
)
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.settings_store import get_settings_store as _get_settings_store from media_library_viewer_api.services.settings_store import get_settings_store as _get_settings_store
@@ -40,6 +37,41 @@ def _request_machine_id(request: Request | None) -> str | None:
return machine_id or None return machine_id or None
def _request_jellyfin_service_id(request: Request | None) -> str | None:
if request is None:
return None
service_id = request.query_params.get("jellyfin_service_id")
return service_id or None
def _service_record(store: SettingsStore, service_type: str, service_id: str | None) -> dict[str, Any] | None:
"""Return a service row for a type, preferring the requested id.
The row carries an in-memory decrypted ``secrets`` dict. Returns None if no
enabled instance of the type exists.
"""
from media_library_viewer_api.services.secrets import decrypt_secrets
row = None
if service_id:
candidate = store.get_service(service_id)
if candidate and candidate.get("service_type") == service_type and candidate.get("enabled", True):
row = candidate
if row is None:
instances = [s for s in store.list_services(service_type) if s.get("enabled", True)]
row = instances[0] if instances else None
if row is None:
return None
decrypted = {}
blob = row.get("secrets") or {}
if blob:
try:
decrypted = decrypt_secrets(blob)
except Exception:
logger.exception("Failed to decrypt service secrets service_id=%s", row.get("id"))
return {**row, "secrets": decrypted}
@lru_cache(maxsize=32) @lru_cache(maxsize=32)
def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient: def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
machine_id, url, api_key = cache_key machine_id, url, api_key = cache_key
@@ -49,21 +81,6 @@ def _jellyfin_client_for(cache_key: tuple[str, str, str]) -> JellyfinClient:
return JellyfinClient(url, api_key) return JellyfinClient(url, api_key)
@lru_cache(maxsize=32)
def _jellyseerr_client_for(cache_key: tuple[str, str]) -> JellyseerrClient | None:
machine_id, url = cache_key
if not url:
return None
settings = get_settings_store().get_machine_config(machine_id) if machine_id else None
api_key = (settings or {}).get("jellyseerr_api_key") if settings else ""
if not api_key:
return None
logger.info(
"Creating Jellyseerr client machine_id=%s url=%s", machine_id or "<default>", url.rstrip("/") or "<unset>"
)
return JellyseerrClient(url, api_key)
@lru_cache(maxsize=32) @lru_cache(maxsize=32)
def _ssh_client_for( def _ssh_client_for(
cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None], cache_key: tuple[str, str, str, int, str, str | None, str | None, str | None, str | None],
@@ -122,6 +139,10 @@ def _ssh_client_for(
def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None: def _resolve_machine(service: str, request: Request | None = None) -> dict[str, Any] | None:
"""Resolve an SSH/Files machine for the given transport service.
Jellyfin/Jellyseerr are resolved against the service registry, not here.
"""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) machine_id = _request_machine_id(request)
if machine_id: if machine_id:
@@ -129,11 +150,7 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
if machine and (service in machine.get("services", []) or service == "ssh"): if machine and (service in machine.get("services", []) or service == "ssh"):
return machine return machine
return machine return machine
if service == "jellyfin": if service == "ssh":
machines = store.list_machines_for_service("jellyfin")
elif service == "jellyseerr":
machines = [m for m in store.list_machines_for_service("jellyfin") if m.get("jellyseerr_url")]
elif service == "ssh":
machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring") machines = store.list_machines_for_service("files") or store.list_machines_for_service("monitoring")
else: else:
machines = store.list_machines_for_service(service) machines = store.list_machines_for_service(service)
@@ -141,37 +158,34 @@ def _resolve_machine(service: str, request: Request | None = None) -> dict[str,
def get_jellyfin_client(request: Request = None) -> JellyfinClient: def get_jellyfin_client(request: Request = None) -> JellyfinClient:
"""Return a Jellyfin client for the selected machine.""" """Return a Jellyfin client for the selected Jellyfin service instance."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyfin", service_id)
if machine is None: if service is None:
resolved = _resolve_machine("jellyfin", request) raise RuntimeError("No Jellyfin service is configured. Add a Jellyfin service on the Services page.")
if resolved: base_url = str(service.get("config", {}).get("base_url") or "")
machine = store.get_machine_config(resolved["id"]) api_key = str(service.get("secrets", {}).get("api_key") or "")
if machine and machine.get("jellyfin_url") and machine.get("jellyfin_api_key"): if not base_url or not api_key:
cache_key = (machine["id"], machine["jellyfin_url"], machine.get("jellyfin_api_key") or "") raise RuntimeError("Jellyfin service is missing base_url or api_key. Edit it on the Services page.")
return _jellyfin_client_for(cache_key) cache_key = (service["id"], base_url, api_key)
return _jellyfin_client_for(cache_key)
raise RuntimeError(
"No Jellyfin machine is configured. Add a machine with jellyfin_url and jellyfin_api_key in Settings."
)
def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None:
"""Return a cached Jellyseerr client when configured, otherwise None.""" """Return a cached Jellyseerr client when configured, otherwise None."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyseerr", service_id)
if machine is None: if service is None:
resolved = _resolve_machine("jellyseerr", request) logger.info("Jellyseerr client not configured (no jellyseerr service)")
if resolved: return None
machine = store.get_machine_config(resolved["id"]) base_url = str(service.get("config", {}).get("base_url") or "")
if machine and machine.get("jellyseerr_url") and machine.get("jellyseerr_api_key"): api_key = str(service.get("secrets", {}).get("api_key") or "")
return JellyseerrClient(machine["jellyseerr_url"], machine.get("jellyseerr_api_key") or "") if not base_url or not api_key:
logger.info("Jellyseerr service is missing base_url or api_key")
logger.info("Jellyseerr client not configured (no machine with jellyseerr_url and jellyseerr_api_key)") return None
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:
@@ -251,11 +265,6 @@ def get_mail_queue() -> MailQueue:
return _get_mail_queue() return _get_mail_queue()
def get_monitoring_poller() -> MonitoringPoller:
"""Return the singleton background monitoring poller."""
return _get_monitoring_poller()
def get_settings_store() -> SettingsStore: def get_settings_store() -> SettingsStore:
"""Return the singleton persistent settings store.""" """Return the singleton persistent settings store."""
return _get_settings_store() return _get_settings_store()
@@ -264,16 +273,12 @@ def get_settings_store() -> SettingsStore:
def get_user_id(request: Request = None) -> str: def get_user_id(request: Request = None) -> str:
"""Return the configured Jellyfin user ID or discover the first available one.""" """Return the configured Jellyfin user ID or discover the first available one."""
store = get_settings_store() store = get_settings_store()
machine_id = _request_machine_id(request) service_id = _request_jellyfin_service_id(request)
machine = store.get_machine_config(machine_id) if machine_id else None service = _service_record(store, "jellyfin", service_id)
if machine is None: if service and service.get("config", {}).get("user_id"):
resolved = _resolve_machine("jellyfin", request) return str(service["config"]["user_id"])
if resolved:
machine = store.get_machine_config(resolved["id"])
if machine and machine.get("jellyfin_user_id"):
return str(machine["jellyfin_user_id"])
client = get_jellyfin_client(request) client = get_jellyfin_client(request)
users = client.users() users = client.users()
if not users: if not users:
raise RuntimeError("No Jellyfin users found and no machine/user id configured") raise RuntimeError("No Jellyfin users found and no user_id configured on the service")
return users[0]["Id"] return users[0]["Id"]
@@ -0,0 +1,91 @@
"""Dashboard domain helpers shared between routers and widget adapters."""
from __future__ import annotations
import time
from typing import Any
from media_library_viewer_api.models.backups import BackupDashboardSummary
from media_library_viewer_api.services.settings_store import SettingsStore
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize Jellyfin sessions into dashboard activity rows."""
results: list[dict[str, Any]] = []
for session in sessions:
item = session.get("NowPlayingItem") or {}
play_state = session.get("PlayState") or {}
transcoding = session.get("TranscodingInfo") or {}
has_item = bool(item)
series = item.get("SeriesName") or ""
title = (
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown"))
if has_item
else "(idle)"
)
if not has_item:
state_label = "idle"
else:
state_label = "paused" if play_state.get("IsPaused") else "playing"
is_transcoding = bool(transcoding)
transcode_type: list[str] = []
if is_transcoding:
if transcoding.get("IsVideoDirect") is False:
transcode_type.append("video")
if transcoding.get("IsAudioDirect") is False:
transcode_type.append("audio")
if not transcode_type:
transcode_type.append("active")
results.append(
{
"user": session.get("UserName") or "Unknown",
"title": title,
"type": item.get("Type", "") if has_item else "",
"state": state_label,
"transcoding": "yes" if is_transcoding else "no",
"transcoding_type": ", ".join(transcode_type),
"device": session.get("DeviceName") or session.get("Client") or "",
"session_id": session.get("Id") or "",
}
)
return results
def build_backup_dashboard_summary(store: SettingsStore) -> BackupDashboardSummary:
"""Compute the backup summary shown on the dashboard."""
jobs = store.list_backup_jobs()
total_jobs = len(jobs)
cutoff = int(time.time()) - (24 * 60 * 60)
recent_runs = []
for job in jobs:
runs = store.list_backup_runs(job_id=job["id"], limit=1)
if runs and runs[0]["started_at"] >= cutoff:
recent_runs.append(runs[0])
successful = sum(1 for r in recent_runs if r["status"] == "success")
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
alerts = store.list_backup_alerts(acknowledged=False)
active_alerts = len(alerts)
failed_runs = []
for job in jobs:
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
if runs:
failed_runs.append(runs[0])
last_failed_at = None
if failed_runs:
last_failed_at = max(r["started_at"] for r in failed_runs)
return BackupDashboardSummary(
total_jobs=total_jobs,
success_rate_24h=round(success_rate, 1),
active_alerts=active_alerts,
last_failed_at=last_failed_at,
)
@@ -0,0 +1 @@
"""Closed registry of service integrations."""
@@ -0,0 +1,119 @@
"""Base classes for service integrations.
A *service definition* is a closed, compile-time description of an external service
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
* its non-secret ``config_schema`` (derived from a Pydantic model),
* the secret fields it accepts (API keys / tokens),
* the widget kinds it can contribute to the dashboard (each with its own
Pydantic-derived config schema).
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
map. There is no runtime plugin loading.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel
class ServiceConfigBase(BaseModel):
"""Base for per-service non-secret config models.
Subclass this in each integration module and declare the connection fields.
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
"""
class WidgetConfigBase(BaseModel):
"""Base for per-widget config models.
Subclass this for each widget kind a service provides. Widget configs never
hold secrets; credentials live on the parent service record.
"""
model_config = {"extra": "forbid"}
@dataclass(frozen=True)
class SecretField:
"""A secret field stored encrypted on the service record."""
key: str
label: str
required: bool = False
helper: str | None = None
@dataclass(frozen=True)
class WidgetKind:
"""A widget kind contributed by a service definition."""
kind: str
name: str
description: str
config_schema: dict[str, Any]
default_config: dict[str, Any] = field(default_factory=dict)
refresh_interval_ms: int = 0
config_model: type[WidgetConfigBase] | None = None
@dataclass(frozen=True)
class ServiceDefinition:
"""Closed description of an external service type."""
service_type: str
name: str
description: str
config_model: type[ServiceConfigBase]
secret_fields: list[SecretField]
widget_kinds: list[WidgetKind]
@property
def config_schema(self) -> dict[str, Any]:
"""JSON schema for the service's non-secret config."""
return self.config_model.model_json_schema()
@property
def secret_keys(self) -> set[str]:
return {sf.key for sf in self.secret_fields}
def widget_kind(self, kind: str) -> WidgetKind | None:
for wk in self.widget_kinds:
if wk.kind == kind:
return wk
return None
def widget_kind(
kind: str,
name: str,
description: str,
model_cls: type[WidgetConfigBase],
*,
default_config: dict[str, Any] | None = None,
refresh_interval_ms: int = 0,
) -> WidgetKind:
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
schema = model_cls.model_json_schema()
# Strip Pydantic's title noise so the exposed schema stays clean.
schema.pop("title", None)
return WidgetKind(
kind=kind,
name=name,
description=description,
config_schema=schema,
default_config=dict(default_config or {}),
refresh_interval_ms=refresh_interval_ms,
config_model=model_cls,
)
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
instance = model_cls.model_validate(config or {})
return instance.model_dump(exclude_none=True)
@@ -0,0 +1,46 @@
"""Grafana service definition."""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class GrafanaConfig(ServiceConfigBase):
"""Non-secret Grafana connection config."""
base_url: str
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,
),
],
)
@@ -0,0 +1,47 @@
"""Jellyfin service definition."""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class JellyfinConfig(ServiceConfigBase):
"""Non-secret Jellyfin connection config."""
base_url: str
user_id: str = ""
timeout_seconds: int = 10
class JellyfinActivityWidgetConfig(WidgetConfigBase):
"""Live Jellyfin session activity."""
# No user-overridable fields; the service record carries user_id.
pass
DEFINITION = ServiceDefinition(
service_type="jellyfin",
name="Jellyfin",
description="Media server with live session activity.",
config_model=JellyfinConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[
widget_kind(
kind="activity",
name="Activity",
description="Live sessions and idle users.",
model_cls=JellyfinActivityWidgetConfig,
default_config={},
refresh_interval_ms=30_000,
),
],
)
@@ -0,0 +1,32 @@
"""Jellyseerr service definition.
Jellyseerr is a companion to Jellyfin (request management). It is modeled as its
own service type so multiple Jellyseerr instances are supported independently of
Jellyfin. It provides no dashboard widgets today.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
)
class JellyseerrConfig(ServiceConfigBase):
"""Non-secret Jellyseerr connection config."""
base_url: str
DEFINITION = ServiceDefinition(
service_type="jellyseerr",
name="Jellyseerr",
description="Request management companion to Jellyfin.",
config_model=JellyseerrConfig,
secret_fields=[
SecretField(key="api_key", label="API key", required=True),
],
widget_kinds=[],
)
@@ -0,0 +1,32 @@
"""Nextcloud service definition.
Nextcloud is included as a proof-of-concept third-party service. It has no
dashboard widgets yet; its service page holds connection config only.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
)
class NextcloudConfig(ServiceConfigBase):
"""Non-secret Nextcloud connection config."""
base_url: str
username: str = ""
DEFINITION = ServiceDefinition(
service_type="nextcloud",
name="Nextcloud",
description="Self-hosted files and collaboration.",
config_model=NextcloudConfig,
secret_fields=[
SecretField(key="app_password", label="App password", required=True),
],
widget_kinds=[],
)
@@ -0,0 +1,45 @@
"""Prometheus service definition."""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class PrometheusConfig(ServiceConfigBase):
"""Non-secret Prometheus connection config."""
base_url: str
timeout_seconds: int = 10
class PrometheusMetricWidgetConfig(WidgetConfigBase):
"""A PromQL instant query rendered as a metric."""
promql: str
DEFINITION = ServiceDefinition(
service_type="prometheus",
name="Prometheus",
description="Metrics storage and PromQL queries.",
config_model=PrometheusConfig,
secret_fields=[
SecretField(key="api_key", label="API key", helper="Optional bearer token"),
],
widget_kinds=[
widget_kind(
kind="metric",
name="Metric",
description="Instant query result rendered as a metric.",
model_cls=PrometheusMetricWidgetConfig,
default_config={"promql": ""},
refresh_interval_ms=30_000,
),
],
)
@@ -0,0 +1,50 @@
"""Closed registry of service definitions.
Adding a brand-new service still requires a backend deploy and a module here.
There is no runtime plugin loading.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import ServiceDefinition, WidgetKind
from media_library_viewer_api.integrations.grafana import DEFINITION as GRAFANA
from media_library_viewer_api.integrations.jellyfin import DEFINITION as JELLYFIN
from media_library_viewer_api.integrations.jellyseerr import DEFINITION as JELLYSEERR
from media_library_viewer_api.integrations.nextcloud import DEFINITION as NEXTCLOUD
from media_library_viewer_api.integrations.prometheus import DEFINITION as PROMETHEUS
from media_library_viewer_api.integrations.ssh_tasks import DEFINITION as SSH_TASKS
SERVICE_DEFINITIONS: dict[str, ServiceDefinition] = {
GRAFANA.service_type: GRAFANA,
PROMETHEUS.service_type: PROMETHEUS,
JELLYFIN.service_type: JELLYFIN,
JELLYSEERR.service_type: JELLYSEERR,
NEXTCLOUD.service_type: NEXTCLOUD,
SSH_TASKS.service_type: SSH_TASKS,
}
def list_service_types() -> list[str]:
"""Return all registered service type names (sorted for stable output)."""
return sorted(SERVICE_DEFINITIONS)
def get_service_definition(service_type: str) -> ServiceDefinition | None:
"""Return the definition for a service type, or ``None`` if unknown."""
return SERVICE_DEFINITIONS.get(service_type)
def get_widget_kind(service_type: str, widget_kind: str) -> WidgetKind | None:
"""Return a widget kind declared by a service definition, or ``None``."""
definition = get_service_definition(service_type)
if definition is None:
return None
return definition.widget_kind(widget_kind)
def require_service_definition(service_type: str) -> ServiceDefinition:
"""Return the definition or raise ``ValueError`` for an unknown type."""
definition = get_service_definition(service_type)
if definition is None:
raise ValueError(f"Unknown service type: {service_type}")
return definition
@@ -0,0 +1,60 @@
"""SSH task runner service definition.
An ``ssh_tasks`` instance is an SSH endpoint that can run reusable saved tasks.
Tasks themselves stay in the global saved-task registry; the instance only owns
transport (host/port/user/key). Every run is recorded in ``service_task_runs``
and shown as history on the instance's service page.
"""
from __future__ import annotations
from media_library_viewer_api.integrations.base import (
SecretField,
ServiceConfigBase,
ServiceDefinition,
WidgetConfigBase,
widget_kind,
)
class SshTasksConfig(ServiceConfigBase):
"""Non-secret SSH task runner config.
The SSH key itself lives in the saved SSH-key registry and is referenced by
``ssh_key_id``. An optional ``passphrase`` is stored as a secret.
"""
host: str
port: int = 22
username: str = ""
ssh_key_id: str = ""
timeout_seconds: int = 30
class SshTaskOutputWidgetConfig(WidgetConfigBase):
"""Output of a saved task run on this instance."""
task_id: str
# service_id is implicit (the widget's service); allow overriding per-widget.
service_id: str | None = None
DEFINITION = ServiceDefinition(
service_type="ssh_tasks",
name="SSH task runner",
description="Run reusable saved tasks over SSH and keep run history.",
config_model=SshTasksConfig,
secret_fields=[
SecretField(key="passphrase", label="Key passphrase", helper="Optional"),
],
widget_kinds=[
widget_kind(
kind="task_output",
name="Task output",
description="Output of a saved task run.",
model_cls=SshTaskOutputWidgetConfig,
default_config={"task_id": ""},
refresh_interval_ms=0,
),
],
)
+12 -4
View File
@@ -13,7 +13,7 @@ from fastapi.responses import Response as FastAPIResponse
from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings from media_library_viewer_api.auth import require_jwt_auth, validate_auth_settings
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_mail_queue, get_monitoring_poller, get_settings_store from media_library_viewer_api.dependencies import get_mail_queue, get_settings_store
from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra from media_library_viewer_api.logging_utils import configure_logging, describe_settings, sanitize_log_extra
from media_library_viewer_api.observability import ( from media_library_viewer_api.observability import (
get_request_id, get_request_id,
@@ -23,6 +23,8 @@ from media_library_viewer_api.observability import (
) )
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, users
from media_library_viewer_api.routers import services as services_router
from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router from media_library_viewer_api.routers.settings import router as settings_router
from .services.backup_poller import get_backup_poller from .services.backup_poller import get_backup_poller
@@ -37,6 +39,9 @@ async def lifespan(app: FastAPI):
settings = get_settings() settings = get_settings()
configure_logging(settings.log_level, settings.log_format) configure_logging(settings.log_level, settings.log_format)
validate_auth_settings(settings) validate_auth_settings(settings)
from media_library_viewer_api.services.secrets import validate_encryption_key
validate_encryption_key()
logger.info("Backend startup complete: %s", describe_settings(settings)) logger.info("Backend startup complete: %s", describe_settings(settings))
logger.info("Managed known_hosts will be populated lazily on first successful SSH connection") logger.info("Managed known_hosts will be populated lazily on first successful SSH connection")
try: try:
@@ -45,14 +50,15 @@ async def lifespan(app: FastAPI):
write_prometheus_targets(get_settings_store()) write_prometheus_targets(get_settings_store())
except Exception: except Exception:
logger.exception("Failed to write Prometheus file-SD targets during startup") logger.exception("Failed to write Prometheus file-SD targets during startup")
try:
get_settings_store().ensure_defaults()
except Exception:
logger.exception("Failed to seed default settings during startup")
mail_queue = get_mail_queue() mail_queue = get_mail_queue()
monitoring_poller = get_monitoring_poller()
backup_poller = get_backup_poller() backup_poller = get_backup_poller()
mail_queue.start() mail_queue.start()
monitoring_poller.start()
backup_poller.start() backup_poller.start()
yield yield
monitoring_poller.stop()
backup_poller.stop() backup_poller.stop()
mail_queue.stop() mail_queue.stop()
logger.info("Backend shutdown complete") logger.info("Backend shutdown complete")
@@ -140,6 +146,8 @@ 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(services_router.router)
@app.get("/api/health") @app.get("/api/health")
@@ -0,0 +1,94 @@
"""Pydantic models for the service registry API."""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
"""Reject credential keys in non-secret service config.
Secrets are sent in the separate ``secrets`` mapping; the plain ``config``
object must never hold them.
"""
forbidden = {
"password",
"token",
"secret",
"api_key",
"apikey",
"private_key",
"passphrase",
"credential",
}
def _check(value: Any) -> None:
if isinstance(value, dict):
for key, child in value.items():
if key.lower() in forbidden:
raise ValueError(f"Credential key '{key}' is not allowed in service config")
_check(child)
elif isinstance(value, list):
for item in value:
_check(item)
_check(config)
return config
class ServiceInstanceInput(BaseModel):
"""Payload for creating or updating a service instance."""
id: str | None = None
service_type: str = Field(..., min_length=1)
name: str = Field(..., min_length=1)
config: dict[str, Any] = Field(default_factory=dict)
secrets: dict[str, str] = Field(default_factory=dict)
enabled: bool = True
@field_validator("config")
@classmethod
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
return _validate_config_keys(value or {})
class ServiceInstance(BaseModel):
"""Persisted service instance returned by the API (no plaintext secrets)."""
id: str
service_type: str
name: str
config: dict[str, Any]
secrets_set: dict[str, bool]
enabled: bool
created_at: int
updated_at: int
class SecretFieldInfo(BaseModel):
key: str
label: str
required: bool = False
helper: str | None = None
class WidgetKindInfo(BaseModel):
kind: str
name: str
description: str
config_schema: dict[str, Any]
default_config: dict[str, Any]
refresh_interval_ms: int
class ServiceTypeInfo(BaseModel):
"""Metadata about a registered service type."""
service_type: str
name: str
description: str
config_schema: dict[str, Any]
secret_fields: list[SecretFieldInfo]
widget_kinds: list[WidgetKindInfo]
@@ -0,0 +1,111 @@
"""Pydantic models for the dashboard widget system.
Widgets are either:
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
by that service's definition (Grafana link, Prometheus metric, Jellyfin
activity, SSH task output); or
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
service-less kinds (backups, static).
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator, model_validator
FORBIDDEN_CONFIG_KEYS = {
"password",
"token",
"secret",
"api_key",
"apikey",
"private_key",
"passphrase",
"credential",
}
def _looks_secret(value: Any) -> bool:
"""Heuristic to detect values that look like secrets/tokens."""
if not isinstance(value, str) or not value.strip():
return False
lowered = value.lower()
if value.startswith("sk-") or value.startswith("eyJ"):
return True
if len(value) > 64 and lowered.isalnum():
return True
return False
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
"""Recursively reject credential keys and secret-looking values."""
for key, value in config.items():
if key.lower() in FORBIDDEN_CONFIG_KEYS:
raise ValueError(f"Credential key '{key}' is not allowed in widget config")
if _looks_secret(value):
raise ValueError(f"Value for '{key}' looks like a secret")
if isinstance(value, dict):
_validate_config_keys(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
_validate_config_keys(item)
return config
class _WidgetInstanceBase(BaseModel):
"""Shared fields between input and output widget models."""
service_id: str | None = None
widget_kind: str = Field(..., min_length=1)
title: str = Field(..., min_length=1)
config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
sort_order: int = Field(default=0, ge=0)
@field_validator("config")
@classmethod
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
return _validate_config_keys(value or {})
@model_validator(mode="after")
def _validate_kind(self) -> "_WidgetInstanceBase":
# The kind must be non-empty (Field enforces it); service_id may be None
# for built-ins. Deeper validation happens in the router against the
# service definition / built-in registry.
return self
class WidgetInstanceInput(_WidgetInstanceBase):
"""Payload for creating or updating a widget instance."""
id: str | None = None
class WidgetInstance(_WidgetInstanceBase):
"""Persisted widget instance returned by the API."""
id: str
created_at: int
updated_at: int
class BuiltinWidgetKindInfo(BaseModel):
"""Metadata about a built-in (service-less) widget kind."""
kind: str
name: str
description: str
config_schema: dict[str, Any]
default_config: dict[str, Any]
refresh_interval_ms: int
class WidgetDataResponse(BaseModel):
"""Response from the per-widget data endpoint."""
widget_id: str
data: dict[str, Any] | None = None
error: str | None = None
fetched_at: int
@@ -3,7 +3,6 @@
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import Any from typing import Any
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
get_settings_store, get_settings_store,
get_user_id, get_user_id,
) )
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.models.backups import BackupDashboardSummary from media_library_viewer_api.models.backups import BackupDashboardSummary
from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.services.settings_store import SettingsStore
@@ -85,50 +88,6 @@ def delete_shortcut(
return {"status": "deleted"} return {"status": "deleted"}
def _map_sessions_to_activity_rows(sessions: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""Normalize Jellyfin sessions into dashboard activity rows."""
results: list[dict[str, Any]] = []
for session in sessions:
item = session.get("NowPlayingItem") or {}
play_state = session.get("PlayState") or {}
transcoding = session.get("TranscodingInfo") or {}
has_item = bool(item)
series = item.get("SeriesName") or ""
title = (
(f"{series} - {item.get('Name', '')}" if series else item.get("Name", "Unknown")) if has_item else "(idle)"
)
if not has_item:
state_label = "idle"
else:
state_label = "paused" if play_state.get("IsPaused") else "playing"
is_transcoding = bool(transcoding)
transcode_type: list[str] = []
if is_transcoding:
if transcoding.get("IsVideoDirect") is False:
transcode_type.append("video")
if transcoding.get("IsAudioDirect") is False:
transcode_type.append("audio")
if not transcode_type:
transcode_type.append("active")
results.append(
{
"user": session.get("UserName") or "Unknown",
"title": title,
"type": item.get("Type", "") if has_item else "",
"state": state_label,
"transcoding": "yes" if is_transcoding else "no",
"transcoding_type": ", ".join(transcode_type),
"device": session.get("DeviceName") or session.get("Client") or "",
"session_id": session.get("Id") or "",
}
)
return results
@router.get("/activity") @router.get("/activity")
def get_activity( def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client), client: JellyfinClient = Depends(get_jellyfin_client),
@@ -154,38 +113,4 @@ def get_now_playing(
def get_backup_dashboard( def get_backup_dashboard(
store: SettingsStore = Depends(get_settings_store), store: SettingsStore = Depends(get_settings_store),
) -> BackupDashboardSummary: ) -> BackupDashboardSummary:
jobs = store.list_backup_jobs() return build_backup_dashboard_summary(store)
total_jobs = len(jobs)
# Calculate 24h success rate
cutoff = int(time.time()) - (24 * 60 * 60)
recent_runs = []
for job in jobs:
runs = store.list_backup_runs(job_id=job["id"], limit=1)
if runs and runs[0]["started_at"] >= cutoff:
recent_runs.append(runs[0])
successful = sum(1 for r in recent_runs if r["status"] == "success")
success_rate = (successful / len(recent_runs) * 100) if recent_runs else 100.0
# Active alerts
alerts = store.list_backup_alerts(acknowledged=False)
active_alerts = len(alerts)
# Last failed
failed_runs = []
for job in jobs:
runs = store.list_backup_runs(job_id=job["id"], status="failure", limit=1)
if runs:
failed_runs.append(runs[0])
last_failed_at = None
if failed_runs:
last_failed_at = max(r["started_at"] for r in failed_runs)
return BackupDashboardSummary(
total_jobs=total_jobs,
success_rate_24h=round(success_rate, 1),
active_alerts=active_alerts,
last_failed_at=last_failed_at,
)
@@ -1,18 +1,14 @@
"""Monitoring router — disk checks, action history, and observability stack status.""" """Monitoring router — observability stack status (Alertmanager + Prometheus)."""
from __future__ import annotations from __future__ import annotations
import logging import logging
from typing import Any from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends
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_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.monitoring_actions import (
disk_space,
run_machine_operation,
)
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
@@ -68,80 +64,12 @@ def _summary_from_alerts(alerts: list[dict[str, Any]]) -> dict[str, Any]:
router = APIRouter(prefix="/api/monitoring", tags=["monitoring"]) router = APIRouter(prefix="/api/monitoring", tags=["monitoring"])
def _resolve_machine(store: SettingsStore, machine_id: str | None) -> dict[str, Any]:
"""Return the requested machine or the first enabled machine.
Monitoring is treated as a machine-by-machine view. If a machine is
explicitly requested but disabled, we surface that as a user-facing error so
the Settings tab can be used to re-enable it.
"""
machines = store.list_machines()
if machine_id:
machine = next((item for item in machines if item["id"] == machine_id), None)
if not machine:
raise HTTPException(status_code=404, detail="Monitoring machine not found")
if not machine.get("enabled"):
raise HTTPException(status_code=409, detail=f"Monitoring machine '{machine['name']}' is disabled")
return machine
for machine in machines:
if machine.get("enabled"):
return machine
raise HTTPException(status_code=404, detail="No enabled monitoring machines configured")
@router.get("/machines") @router.get("/machines")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return enabled monitoring machines for the UI.""" """Return enabled monitoring machines for the UI."""
return [m for m in store.list_machines() if m.get("enabled")] return [m for m in store.list_machines() if m.get("enabled")]
@router.get("/poller")
def get_poller_status() -> dict[str, Any]:
"""Return the backend poller status and configuration."""
from media_library_viewer_api.dependencies import get_monitoring_poller
poller = get_monitoring_poller().snapshot()
logger.info(
"Monitoring poller status requested running=%s poll_count=%s",
poller.get("worker_running"),
poller.get("poll_count"),
)
return poller
@router.get("/machines/{machine_id}/actions")
def get_machine_actions(
machine_id: str,
limit: int = 20,
action: str | None = None,
status: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Return recent action history for a single machine."""
machine = _resolve_machine(store, machine_id)
actions = store.list_machine_actions(machine["id"], limit=limit, action=action, status=status)
return {"items": actions, "total": len(actions)}
@router.get("/disk")
def get_disk_space(
machine_id: str | None = Query(default=None),
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Return disk space for the configured path of a given machine."""
machine = _resolve_machine(store, machine_id)
app_settings = get_settings()
path = str(machine.get("media_root") or app_settings.media_root or "/")
logger.info("Monitoring disk requested machine_id=%s path=%s", machine["id"], path)
return run_machine_operation(
machine,
store,
f"disk lookup for {path}",
lambda client: disk_space(client, path),
)
@router.get("/prometheus-targets") @router.get("/prometheus-targets")
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]: def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return Prometheus file-SD targets for remote Node Exporters. """Return Prometheus file-SD targets for remote Node Exporters.
@@ -0,0 +1,182 @@
"""REST API for the service registry.
Service instances hold non-secret config and encrypted secrets. Plaintext
secrets are never returned; only the boolean ``secrets_set`` map is exposed.
"""
from __future__ import annotations
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
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.registry import (
SERVICE_DEFINITIONS,
get_service_definition,
require_service_definition,
)
from media_library_viewer_api.models.services import (
SecretFieldInfo,
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
WidgetKindInfo,
)
from media_library_viewer_api.services.settings_store import SettingsStore
router = APIRouter(prefix="/api/services", tags=["services"])
logger = logging.getLogger(__name__)
def _to_type_info(service_type: str) -> ServiceTypeInfo:
definition = require_service_definition(service_type)
return ServiceTypeInfo(
service_type=definition.service_type,
name=definition.name,
description=definition.description,
config_schema=definition.config_schema,
secret_fields=[
SecretFieldInfo(
key=sf.key,
label=sf.label,
required=sf.required,
helper=sf.helper,
)
for sf in definition.secret_fields
],
widget_kinds=[
WidgetKindInfo(
kind=wk.kind,
name=wk.name,
description=wk.description,
config_schema=wk.config_schema,
default_config=wk.default_config,
refresh_interval_ms=wk.refresh_interval_ms,
)
for wk in definition.widget_kinds
],
)
def _to_instance(row: dict[str, Any]) -> ServiceInstance:
"""Build an API response model, surfacing only secret 'set' flags."""
definition = get_service_definition(row["service_type"])
known_secrets = definition.secret_keys if definition else set()
secrets_blob = row.get("secrets") or {}
secrets_set = {key: (key in secrets_blob and bool(secrets_blob[key])) for key in known_secrets}
return ServiceInstance(
id=row["id"],
service_type=row["service_type"],
name=row["name"],
config=row.get("config") or {},
secrets_set=secrets_set,
enabled=row["enabled"],
created_at=row["created_at"],
updated_at=row["updated_at"],
)
def _validate_input(body: ServiceInstanceInput) -> None:
"""Validate service_type, config, and secret keys against the definition."""
definition = get_service_definition(body.service_type)
if definition is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown service type: {body.service_type}",
)
try:
validate_config(definition.config_model, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid service config: {exc}",
) from exc
unknown_secrets = set(body.secrets) - definition.secret_keys
if unknown_secrets:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown secret fields for {body.service_type}: {sorted(unknown_secrets)}",
)
@router.get("/types")
def list_types() -> list[ServiceTypeInfo]:
"""Return metadata for every registered service type."""
return [_to_type_info(service_type) for service_type in sorted(SERVICE_DEFINITIONS)]
@router.get("/instances")
def list_instances(
service_type: str | None = None,
store: SettingsStore = Depends(get_settings_store),
) -> list[ServiceInstance]:
"""Return all persisted service instances (no plaintext secrets)."""
rows = store.list_services(service_type)
return [_to_instance(row) for row in rows]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
def create_instance(
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> ServiceInstance:
"""Create a new service instance."""
_validate_input(body)
row = store.upsert_service(
{
"id": body.id,
"service_type": body.service_type,
"name": body.name,
"config": body.config,
"enabled": body.enabled,
},
secret_values=body.secrets,
)
return _to_instance(row)
@router.put("/instances/{service_id}")
def update_instance(
service_id: str,
body: ServiceInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> ServiceInstance:
"""Update an existing service instance."""
existing = store.get_service(service_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
if body.id is not None and body.id != service_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body",
)
_validate_input(body)
row = store.upsert_service(
{
"id": service_id,
"service_type": body.service_type,
"name": body.name,
"config": body.config,
"enabled": body.enabled,
},
secret_values=body.secrets,
service_id=service_id,
)
return _to_instance(row)
@router.delete("/instances/{service_id}")
def delete_instance(
service_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Delete a service instance (cascade-deletes widgets referencing it)."""
existing = store.get_service(service_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Service not found")
store.delete_service(service_id)
return {"status": "deleted"}
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
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
from media_library_viewer_api.dependencies import get_monitoring_poller, get_settings_store from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.services.db_maintenance import remove_sqlite_database from media_library_viewer_api.services.db_maintenance import remove_sqlite_database
from media_library_viewer_api.services.known_hosts import has_known_host from media_library_viewer_api.services.known_hosts import has_known_host
from media_library_viewer_api.services.media_index import MediaIndex from media_library_viewer_api.services.media_index import MediaIndex
@@ -43,11 +43,6 @@ class MonitoringMachineInput(BaseModel):
password: str = "" password: str = ""
media_root: str = "" media_root: str = ""
path_prefix: str = "" path_prefix: str = ""
jellyfin_url: str = ""
jellyfin_user_id: str = ""
jellyfin_api_key: str = ""
jellyseerr_url: str = ""
jellyseerr_api_key: str = ""
notes: str = "" notes: str = ""
@@ -195,13 +190,8 @@ def post_machine(
) -> dict[str, Any]: ) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id) saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
_write_prometheus_targets(store) _write_prometheus_targets(store)
poller = get_monitoring_poller() saved_machine = MonitoringMachineInput.model_validate(saved)
try: _validate_saved_machine_ssh(saved_machine, store)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
finally:
poller.start()
poller.kick()
return saved return saved
@@ -215,13 +205,8 @@ def put_machine(
raise HTTPException(status_code=404, detail="Machine not found") raise HTTPException(status_code=404, detail="Machine not found")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id) saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
_write_prometheus_targets(store) _write_prometheus_targets(store)
poller = get_monitoring_poller() saved_machine = MonitoringMachineInput.model_validate(saved)
try: _validate_saved_machine_ssh(saved_machine, store)
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
finally:
poller.start()
poller.kick()
return saved return saved
@@ -0,0 +1,215 @@
"""REST API for dashboard widget instances.
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
"""
from __future__ import annotations
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
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.registry import get_service_definition
from media_library_viewer_api.models.widgets import (
BuiltinWidgetKindInfo,
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
)
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.builtin import (
BUILTIN_WIDGET_KINDS,
is_builtin_kind,
validate_builtin_config,
)
from media_library_viewer_api.widgets.sources import (
build_service_record,
get_builtin_adapter,
get_service_adapter,
)
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
"""Validate widget_kind + config against the service definition or built-ins."""
if body.service_id:
service = store.get_service(body.service_id)
if not service:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Service {body.service_id} not found",
)
definition = get_service_definition(service["service_type"])
if definition is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown service type: {service['service_type']}",
)
widget_kind = definition.widget_kind(body.widget_kind)
if widget_kind is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
)
if widget_kind.config_model is not None:
try:
validate_config(widget_kind.config_model, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid widget config: {exc}",
) from exc
else:
if not is_builtin_kind(body.widget_kind):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
),
)
try:
validate_builtin_config(body.widget_kind, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid widget config: {exc}",
) from exc
@router.get("/builtin")
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
"""Return metadata for service-less built-in widget kinds."""
return [
BuiltinWidgetKindInfo(
kind=wk.kind,
name=wk.name,
description=wk.description,
config_schema=wk.config_schema,
default_config=wk.default_config,
refresh_interval_ms=wk.refresh_interval_ms,
)
for wk in BUILTIN_WIDGET_KINDS.values()
]
@router.get("/instances")
def list_instances(
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""Return all persisted widget instances."""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
def create_instance(
body: WidgetInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a new widget instance."""
_validate_widget_input(body, store)
widget = store.upsert_widget(body.model_dump())
return WidgetInstance(**widget).model_dump()
@router.put("/instances/{widget_id}")
def update_instance(
widget_id: str,
body: WidgetInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Update an existing widget instance."""
existing = store.get_widget(widget_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
if body.id is not None and body.id != widget_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body",
)
_validate_widget_input(body, store)
widget = store.upsert_widget(body.model_dump(), widget_id)
return WidgetInstance(**widget).model_dump()
@router.delete("/instances/{widget_id}")
def delete_instance(
widget_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Delete a widget instance."""
existing = store.get_widget(widget_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
store.delete_widget(widget_id)
return {"status": "deleted"}
@router.get("/instances/{widget_id}/data")
async def fetch_data(
widget_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Fetch widget data through the registered source adapter."""
widget = store.get_widget(widget_id)
if not widget:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
service_id = widget.get("service_id")
widget_kind = widget.get("widget_kind") or ""
service: Any = None
if service_id:
service_row = store.get_service(service_id)
if not service_row:
return WidgetDataResponse(
widget_id=widget_id,
error=f"Service {service_id} not found",
fetched_at=int(time.time()),
).model_dump()
if not service_row.get("enabled", True):
return WidgetDataResponse(
widget_id=widget_id,
error="Service is disabled",
fetched_at=int(time.time()),
).model_dump()
adapter = get_service_adapter(service_row["service_type"])
if adapter is None:
return WidgetDataResponse(
widget_id=widget_id,
error=f"No adapter for service type {service_row['service_type']}",
fetched_at=int(time.time()),
).model_dump()
service = build_service_record(store, service_row)
else:
adapter = get_builtin_adapter(widget_kind)
if adapter is None:
return WidgetDataResponse(
widget_id=widget_id,
error=f"Unknown built-in widget kind: {widget_kind}",
fetched_at=int(time.time()),
).model_dump()
try:
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
except Exception as exc: # pragma: no cover - defensive
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Widget data fetch failed",
) from exc
return WidgetDataResponse(
widget_id=widget_id,
data=data if "error" not in data else None,
error=data.get("error"),
fetched_at=int(time.time()),
).model_dump()
@@ -1,208 +0,0 @@
"""Shared monitoring action helpers.
The router and the background poller both use these helpers so machine
operations are recorded consistently whether they were triggered by a user
request or by the backend's scheduled polling loop.
"""
from __future__ import annotations
import json
import logging
import shlex
import time
import uuid
from typing import Any, Callable
from fastapi import HTTPException
from media_library_viewer_api.clients.local import LocalCommandClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.observability import record_ssh_command
from media_library_viewer_api.services.settings_store import SettingsStore
logger = logging.getLogger(__name__)
def build_machine_client(machine: dict[str, Any], store: SettingsStore):
"""Build the appropriate command client for a machine definition."""
mode = str(machine.get("mode") or "local").strip().lower()
if mode == "local":
return LocalCommandClient()
key_directory = str(machine.get("key_directory") or "").strip()
key_name = str(machine.get("key_name") or "").strip()
key_path = f"{key_directory}/{key_name}" if key_directory and key_name else None
private_key = str(machine.get("ssh_private_key") or "")
passphrase = str(machine.get("ssh_private_key_passphrase") or "")
ssh_key_id = str(machine.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
private_key = str(ssh_key.get("private_key") or private_key)
passphrase = str(ssh_key.get("passphrase") or passphrase)
settings = get_settings()
return RemoteSSHClient(
host=str(machine.get("host") or ""),
username=str(machine.get("username") or ""),
port=int(machine.get("port") or 22),
key_filename=key_path,
private_key=private_key or None,
private_key_passphrase=passphrase or None,
password=str(machine.get("password") or "") or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
)
def disk_space(client: Any, path: str = "/") -> dict[str, Any]:
"""Return df information for the filesystem containing ``path``.
Works against any client with a ``run`` method (local shell or SSH).
"""
command = (
"df -P -B1 -- " + shlex.quote(path or "/") + " | awk 'NR==2 {printf "
'"{\\"filesystem\\":\\"%s\\",\\"size\\":%s,"'
'"\\"used\\":%s,\\"available\\":%s,"'
'"\\"used_pct\\":\\"%s\\",\\"mount\\":\\"%s\\"}", "'
"$1,$2,$3,$4,$5,$6}'"
)
logger.debug("Reading disk space for path=%s", path)
result = client.run(command, timeout=20)
if result.exit_status != 0 or not result.stdout.strip():
logger.warning("Failed to read disk space for %s: %s", path, result.stderr or result.stdout)
raise RuntimeError(result.stderr or result.stdout or "failed to read disk space")
data = json.loads(result.stdout)
logger.info("Disk space path=%s mount=%s used_pct=%s", path, data.get("mount"), data.get("used_pct"))
return data
def summarize_operation_result(action: str, result: Any) -> str:
"""Turn an operation result into a compact human-readable summary."""
if result is None:
return action
if isinstance(result, str):
text = result.strip().splitlines()[0] if result.strip() else action
return text[:200]
if isinstance(result, list):
return f"{action}: {len(result)} item(s)"
if isinstance(result, dict):
if action.startswith("disk lookup"):
used_pct = result.get("used_pct")
mount = result.get("mount") or result.get("filesystem")
return f"disk {mount or ''} used {used_pct or '?'}".strip()
if "message" in result and isinstance(result["message"], str):
return result["message"][:200]
return json_compact(result)
return action
def json_compact(value: Any) -> str:
try:
text = json.dumps(value, sort_keys=True, default=str)
return text[:200]
except Exception:
return str(value)[:200]
def run_machine_operation(
machine: dict[str, Any],
store: SettingsStore,
action: str,
callback: Callable[[Any], Any],
*,
summarize: Callable[[Any], str] | None = None,
request_id: str = "",
raise_http: bool = True,
client: Any | None = None,
) -> Any:
"""Run a machine operation, record history, and optionally raise on failure."""
started = time.perf_counter()
if client is None:
client = build_machine_client(machine, store)
try:
result = callback(client)
duration_ms = int((time.perf_counter() - started) * 1000)
record_ssh_command(
machine_id=machine.get("id") or "unknown",
action=action,
status="ok",
duration_seconds=duration_ms / 1000.0,
)
store.record_machine_action(
machine,
action,
"ok",
duration_ms=duration_ms,
request_id=request_id,
message=(summarize(result) if summarize else summarize_operation_result(action, result)),
)
return result
except HTTPException:
raise
except Exception as exc: # pragma: no cover - transport/network fallback
duration_ms = int((time.perf_counter() - started) * 1000)
record_ssh_command(
machine_id=machine.get("id") or "unknown",
action=action,
status="error",
duration_seconds=duration_ms / 1000.0,
)
logger.exception(
"Monitoring %s failed machine_id=%s machine_name=%s",
action,
machine["id"],
machine["name"],
)
error_text = str(exc)
store.record_machine_action(
machine,
action,
"error",
duration_ms=duration_ms,
request_id=request_id,
error=error_text,
)
if not raise_http:
return None
status_code = 503 if machine.get("mode") == "local" else 502
raise HTTPException(
status_code=status_code,
detail=f"{machine['name']}: {action} failed: {exc}",
) from exc
def poll_machine_snapshot(
machine: dict[str, Any],
store: SettingsStore,
*,
metrics_limit: int = 70_000,
request_id: str | None = None,
) -> dict[str, Any]:
"""Collect a backend-scheduled snapshot for a machine.
The legacy POSIX collector has been removed; this now records a lightweight
disk-space lookup on the same schedule so action history stays useful.
"""
request_id = request_id or f"poll:{machine.get('id') or uuid.uuid4().hex}"
results: dict[str, Any] = {"request_id": request_id, "machine_id": machine.get("id"), "actions": []}
client = build_machine_client(machine, store)
settings = get_settings()
path = str(machine.get("media_root") or settings.media_root or "/")
disk = run_machine_operation(
machine,
store,
f"disk lookup for {path}",
lambda client: disk_space(client, path),
request_id=request_id,
raise_http=False,
client=client,
)
results["disk_mount"] = (disk or {}).get("mount") if isinstance(disk, dict) else None
results["actions"].append("disk lookup")
return results
@@ -1,197 +0,0 @@
"""Background poller for monitoring machine snapshots.
The poller runs entirely inside the backend. It periodically reads the defined
machines, collects a small snapshot from each enabled machine over SSH or local
shell execution, and stores the resulting history rows in the settings DB.
This keeps the Monitoring page populated without any daemon or agent running on
the remote machines.
"""
from __future__ import annotations
import logging
import threading
import time
from dataclasses import dataclass
from typing import Any
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class PollerConfig:
interval_seconds: int = 300
initial_delay_seconds: int = 20
metrics_limit: int = 70_000
retention_days: int = 30
class MonitoringPoller:
"""Single-worker background poller for monitoring snapshots."""
def __init__(self) -> None:
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._lock = threading.Lock()
self._last_run_at: float | None = None
self._last_success_at: float | None = None
self._last_error: str = ""
self._last_cycle_ms: int | None = None
self._poll_count = 0
self._error_count = 0
def _config(self) -> PollerConfig:
settings = get_settings()
return PollerConfig(
interval_seconds=max(30, int(getattr(settings, "monitoring_poll_interval_seconds", 300) or 300)),
initial_delay_seconds=max(0, int(getattr(settings, "monitoring_poll_initial_delay_seconds", 20) or 20)),
metrics_limit=70_000,
retention_days=max(1, int(getattr(settings, "monitoring_action_retention_days", 30) or 30)),
)
def start(self) -> None:
"""Start the background worker if it is not already running."""
with self._lock:
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run, name="monitoring-poller", daemon=True)
self._thread.start()
logger.info("Monitoring poller started")
def kick(self) -> None:
"""Run one immediate snapshot cycle in the background."""
store = get_settings_store()
config = self._config()
threading.Thread(
target=self._run_cycle,
args=(store, config),
name="monitoring-poller-kick",
daemon=True,
).start()
logger.info("Monitoring poller kick requested")
def stop(self, timeout: float = 5.0) -> None:
"""Stop the worker thread and wait briefly for shutdown."""
with self._lock:
thread = self._thread
if not thread:
return
self._stop_event.set()
thread.join(timeout=timeout)
if thread.is_alive():
logger.warning("Monitoring poller did not stop within %.1fs", timeout)
else:
logger.info("Monitoring poller stopped")
with self._lock:
if self._thread is thread:
self._thread = None
def status(self) -> dict[str, Any]:
"""Return a small status snapshot for diagnostics and tests."""
with self._lock:
return {
"worker_running": bool(self._thread and self._thread.is_alive()),
"stop_requested": self._stop_event.is_set(),
"last_run_at": self._last_run_at,
"last_success_at": self._last_success_at,
"last_error": self._last_error,
"last_cycle_ms": self._last_cycle_ms,
"poll_count": self._poll_count,
"error_count": self._error_count,
}
def snapshot(self) -> dict[str, Any]:
"""Return status plus the active polling configuration."""
data = self.status()
config = self._config()
data.update(
{
"interval_seconds": config.interval_seconds,
"initial_delay_seconds": config.initial_delay_seconds,
"retention_days": config.retention_days,
}
)
return data
def _run_cycle(self, store: SettingsStore, config: PollerConfig) -> None:
start = time.perf_counter()
machines = store.list_machines()
enabled = [machine for machine in machines if machine.get("enabled")]
logger.info("Monitoring poll cycle starting enabled_machines=%s", len(enabled))
cycle_errors = 0
for machine in enabled:
if self._stop_event.is_set():
break
try:
snapshot = poll_machine_snapshot(
machine,
store,
metrics_limit=config.metrics_limit,
request_id=f"poll:{machine['id']}:{int(time.time())}",
)
logger.info(
"Monitoring poll snapshot machine_id=%s request_id=%s disk_mount=%s actions=%s",
machine["id"],
snapshot.get("request_id"),
snapshot.get("disk_mount"),
snapshot.get("actions"),
)
except Exception:
cycle_errors += 1
logger.exception(
"Monitoring poll snapshot failed machine_id=%s machine_name=%s", machine["id"], machine["name"]
)
retention_seconds = config.retention_days * 24 * 60 * 60
cutoff_ts = int(time.time()) - retention_seconds
removed = store.prune_machine_actions(cutoff_ts)
if removed:
logger.info("Pruned %s old monitoring action rows older than %s", removed, cutoff_ts)
duration_ms = int((time.perf_counter() - start) * 1000)
with self._lock:
self._last_run_at = time.time()
self._last_cycle_ms = duration_ms
self._poll_count += 1
if cycle_errors:
self._error_count += cycle_errors
self._last_error = f"{cycle_errors} machine(s) failed"
else:
self._last_success_at = self._last_run_at
self._last_error = ""
logger.info(
"Monitoring poll cycle complete enabled_machines=%s errors=%s duration_ms=%s removed_rows=%s",
len(enabled),
cycle_errors,
duration_ms,
removed,
)
def _run(self) -> None:
config = self._config()
if config.initial_delay_seconds:
logger.info("Monitoring poller initial delay=%ss", config.initial_delay_seconds)
if self._stop_event.wait(config.initial_delay_seconds):
return
store = get_settings_store()
while not self._stop_event.is_set():
try:
self._run_cycle(store, config)
except Exception:
with self._lock:
self._last_error = "poller cycle failed"
self._error_count += 1
logger.exception("Monitoring poller cycle failed")
if self._stop_event.wait(config.interval_seconds):
break
_MONITORING_POLLER = MonitoringPoller()
def get_monitoring_poller() -> MonitoringPoller:
return _MONITORING_POLLER
@@ -0,0 +1,93 @@
"""Encryption-at-rest for service secrets.
Service API keys / tokens are stored encrypted in the ``services.secrets_json``
column. Encryption uses Fernet (symmetric authenticated encryption) with a single
master key provided via the ``MANAGE_ENCRYPTION_KEY`` environment variable.
* The key **must** be a urlsafe base64-encoded 32-byte value (Fernet format).
* The key is **always required** — there is no development fallback, so secrets
are never accidentally stored in plaintext.
* Secrets are encrypted field-by-field; the ``"which secrets are set"`` metadata
can be derived from the ciphertext blob without decrypting.
"""
from __future__ import annotations
import os
from functools import lru_cache
from cryptography.fernet import Fernet, InvalidToken
ENCRYPTION_KEY_ENV = "MANAGE_ENCRYPTION_KEY"
class EncryptionKeyError(RuntimeError):
"""Raised when the encryption key is missing or invalid."""
@lru_cache(maxsize=1)
def get_encryption_key() -> bytes:
"""Return the raw Fernet key, or raise if missing/invalid.
The result is cached for the process lifetime. Tests should call
:func:`reset_encryption_key_cache` after changing the environment.
"""
raw = os.environ.get(ENCRYPTION_KEY_ENV)
if not raw:
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} is required to store service secrets")
key = raw.strip().encode()
try:
Fernet(key)
except (ValueError, TypeError) as exc: # pragma: no cover - validated by tests
raise EncryptionKeyError(f"{ENCRYPTION_KEY_ENV} must be a valid Fernet key") from exc
return key
def reset_encryption_key_cache() -> None:
"""Drop the cached encryption key (used by tests that swap keys)."""
get_encryption_key.cache_clear()
def _fernet() -> Fernet:
return Fernet(get_encryption_key())
def encrypt_value(plaintext: str) -> str:
"""Encrypt a single secret value and return the ciphertext string."""
return _fernet().encrypt(plaintext.encode()).decode()
def decrypt_value(ciphertext: str) -> str:
"""Decrypt a single ciphertext value."""
try:
return _fernet().decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise EncryptionKeyError("Service secret could not be decrypted") from exc
def encrypt_secrets(values: dict[str, str]) -> dict[str, str]:
"""Encrypt every provided secret value."""
fernet = _fernet()
return {key: fernet.encrypt(value.encode()).decode() for key, value in values.items()}
def decrypt_secrets(blob: dict[str, str]) -> dict[str, str]:
"""Decrypt every secret value in a blob."""
fernet = _fernet()
result: dict[str, str] = {}
for key, ciphertext in blob.items():
try:
result[key] = fernet.decrypt(ciphertext.encode()).decode()
except InvalidToken as exc:
raise EncryptionKeyError(f"Service secret '{key}' could not be decrypted") from exc
return result
def generate_development_key() -> str:
"""Return a freshly generated Fernet key (helper for operators/docs)."""
return Fernet.generate_key().decode()
def validate_encryption_key() -> None:
"""Eagerly validate that the encryption key is present and well-formed."""
get_encryption_key() # raises EncryptionKeyError on failure
@@ -18,6 +18,7 @@ from typing import Any
import paramiko import paramiko
from media_library_viewer_api.config import get_settings from media_library_viewer_api.config import get_settings
from media_library_viewer_api.models.widgets import _validate_config_keys
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"
@@ -43,11 +44,6 @@ def _default_local_machine() -> dict[str, Any]:
"password": "", "password": "",
"media_root": settings.media_root, "media_root": settings.media_root,
"path_prefix": settings.path_prefix, "path_prefix": settings.path_prefix,
"jellyfin_url": "",
"jellyfin_user_id": "",
"jellyfin_api_key": "",
"jellyseerr_url": "",
"jellyseerr_api_key": "",
"node_exporter_enabled": False, "node_exporter_enabled": False,
"node_exporter_port": 9100, "node_exporter_port": 9100,
"node_exporter_scrape_host": "", "node_exporter_scrape_host": "",
@@ -85,25 +81,10 @@ 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)")
conn.execute( # The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
""" # metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
CREATE TABLE IF NOT EXISTS monitoring_machine_actions ( # table on startup so existing databases get a clean slate.
id TEXT PRIMARY KEY, conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
machine_id TEXT NOT NULL,
machine_name TEXT NOT NULL,
mode TEXT NOT NULL,
action TEXT NOT NULL,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
duration_ms INTEGER NOT NULL,
request_id TEXT NOT NULL,
message TEXT NOT NULL,
error TEXT NOT NULL,
stdout_tail TEXT NOT NULL,
stderr_tail TEXT NOT NULL
)
"""
)
conn.execute( conn.execute(
""" """
CREATE TABLE IF NOT EXISTS ssh_keys ( CREATE TABLE IF NOT EXISTS ssh_keys (
@@ -180,16 +161,25 @@ class SettingsStore:
) )
conn.execute( conn.execute(
""" """
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time CREATE TABLE IF NOT EXISTS dashboard_widgets (
ON monitoring_machine_actions(machine_id, created_at DESC) id TEXT PRIMARY KEY,
""" addon_id TEXT NOT NULL,
) widget_type TEXT NOT NULL,
conn.execute( title TEXT NOT NULL,
""" config_json TEXT NOT NULL DEFAULT '{}',
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_action_status enabled INTEGER NOT NULL DEFAULT 1,
ON monitoring_machine_actions(action, status) sort_order INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
""" """
) )
conn.execute("CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)")
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
if "service_id" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
if "widget_kind" not in widget_cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN widget_kind TEXT")
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,
@@ -234,6 +224,44 @@ class SettingsStore:
""") """)
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)") conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_job_id ON backup_alerts(job_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)") conn.execute("CREATE INDEX IF NOT EXISTS idx_backup_alerts_acknowledged ON backup_alerts(acknowledged)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
service_type TEXT NOT NULL,
name TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
secrets_json TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
)
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_services_type ON services(service_type)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS service_task_runs (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
service_id TEXT NOT NULL,
status TEXT NOT NULL,
exit_status INTEGER,
duration_ms INTEGER,
stdout_tail TEXT NOT NULL DEFAULT '',
stderr_tail TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_service "
"ON service_task_runs(service_id, created_at DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)"
)
@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]:
@@ -273,11 +301,6 @@ class SettingsStore:
"password_set": bool(data.get("password")), "password_set": bool(data.get("password")),
"media_root": data.get("media_root", ""), "media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""), "path_prefix": data.get("path_prefix", ""),
"jellyfin_url": data.get("jellyfin_url", ""),
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
"jellyfin_api_key_set": bool(data.get("jellyfin_api_key")),
"jellyseerr_url": data.get("jellyseerr_url", ""),
"jellyseerr_api_key_set": bool(data.get("jellyseerr_api_key")),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)), "node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100), "node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""), "node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -327,17 +350,6 @@ class SettingsStore:
password = str(password or "") password = str(password or "")
media_root = _current_str("media_root") media_root = _current_str("media_root")
path_prefix = _current_str("path_prefix") path_prefix = _current_str("path_prefix")
jellyfin_url = _current_str("jellyfin_url")
jellyfin_user_id = _current_str("jellyfin_user_id")
jellyfin_api_key = payload.get("jellyfin_api_key")
if jellyfin_api_key in (None, ""):
jellyfin_api_key = (current or {}).get("jellyfin_api_key", "")
jellyfin_api_key = str(jellyfin_api_key or "")
jellyseerr_url = _current_str("jellyseerr_url")
jellyseerr_api_key = payload.get("jellyseerr_api_key")
if jellyseerr_api_key in (None, ""):
jellyseerr_api_key = (current or {}).get("jellyseerr_api_key", "")
jellyseerr_api_key = str(jellyseerr_api_key or "")
node_exporter_enabled = bool( node_exporter_enabled = bool(
payload.get("node_exporter_enabled") payload.get("node_exporter_enabled")
if payload.get("node_exporter_enabled") is not None if payload.get("node_exporter_enabled") is not None
@@ -369,23 +381,14 @@ class SettingsStore:
"password": password, "password": password,
"media_root": media_root, "media_root": media_root,
"path_prefix": path_prefix, "path_prefix": path_prefix,
"jellyfin_url": jellyfin_url,
"jellyfin_user_id": jellyfin_user_id,
"jellyfin_api_key": jellyfin_api_key,
"jellyseerr_url": jellyseerr_url,
"jellyseerr_api_key": jellyseerr_api_key,
"node_exporter_enabled": node_exporter_enabled, "node_exporter_enabled": node_exporter_enabled,
"node_exporter_port": node_exporter_port, "node_exporter_port": node_exporter_port,
"node_exporter_scrape_host": node_exporter_scrape_host, "node_exporter_scrape_host": node_exporter_scrape_host,
"notes": notes, "notes": notes,
} }
def ensure_defaults(self) -> None: def _seed_local_machine(self) -> None:
self.init_schema() """Seed the default local machine if none exists."""
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if row and int(row[0]) > 0:
return
machine = _default_local_machine() machine = _default_local_machine()
now = int(time.time()) now = int(time.time())
config = { config = {
@@ -401,11 +404,6 @@ class SettingsStore:
"password": "", "password": "",
"media_root": machine["media_root"], "media_root": machine["media_root"],
"path_prefix": machine["path_prefix"], "path_prefix": machine["path_prefix"],
"jellyfin_url": machine["jellyfin_url"],
"jellyfin_user_id": machine["jellyfin_user_id"],
"jellyfin_api_key": machine["jellyfin_api_key"],
"jellyseerr_url": machine["jellyseerr_url"],
"jellyseerr_api_key": machine["jellyseerr_api_key"],
"node_exporter_enabled": machine["node_exporter_enabled"], "node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"], "node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"], "node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -428,6 +426,22 @@ class SettingsStore:
), ),
) )
def _seed_dashboard_widgets(self) -> None:
"""Default widget seeding was removed.
Widgets are now service-bound (or built-in). A fresh install starts with
no widgets; the user configures services and adds widgets from the UI.
Kept as a no-op so :meth:`ensure_defaults` callers are unchanged.
"""
return None
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if not row or int(row[0]) == 0:
self._seed_local_machine()
def list_machines(self) -> list[dict[str, Any]]: def list_machines(self) -> list[dict[str, Any]]:
self.init_schema() self.init_schema()
with self.connect() as conn: with self.connect() as conn:
@@ -475,11 +489,6 @@ class SettingsStore:
"password": data.get("password", ""), "password": data.get("password", ""),
"media_root": data.get("media_root", ""), "media_root": data.get("media_root", ""),
"path_prefix": data.get("path_prefix", ""), "path_prefix": data.get("path_prefix", ""),
"jellyfin_url": data.get("jellyfin_url", ""),
"jellyfin_user_id": data.get("jellyfin_user_id", ""),
"jellyfin_api_key": data.get("jellyfin_api_key", ""),
"jellyseerr_url": data.get("jellyseerr_url", ""),
"jellyseerr_api_key": data.get("jellyseerr_api_key", ""),
"node_exporter_enabled": bool(data.get("node_exporter_enabled", False)), "node_exporter_enabled": bool(data.get("node_exporter_enabled", False)),
"node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100), "node_exporter_port": int(data.get("node_exporter_port", 9100) or 9100),
"node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""), "node_exporter_scrape_host": data.get("node_exporter_scrape_host", ""),
@@ -519,11 +528,6 @@ class SettingsStore:
"password": machine["password"], "password": machine["password"],
"media_root": machine["media_root"], "media_root": machine["media_root"],
"path_prefix": machine["path_prefix"], "path_prefix": machine["path_prefix"],
"jellyfin_url": machine["jellyfin_url"],
"jellyfin_user_id": machine["jellyfin_user_id"],
"jellyfin_api_key": machine["jellyfin_api_key"],
"jellyseerr_url": machine["jellyseerr_url"],
"jellyseerr_api_key": machine["jellyseerr_api_key"],
"node_exporter_enabled": machine["node_exporter_enabled"], "node_exporter_enabled": machine["node_exporter_enabled"],
"node_exporter_port": machine["node_exporter_port"], "node_exporter_port": machine["node_exporter_port"],
"node_exporter_scrape_host": machine["node_exporter_scrape_host"], "node_exporter_scrape_host": machine["node_exporter_scrape_host"],
@@ -563,88 +567,6 @@ class SettingsStore:
with self.connect() as conn: with self.connect() as conn:
conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,)) conn.execute("DELETE FROM monitoring_machines WHERE id = ?", (machine_id,))
def record_machine_action(
self,
machine: dict[str, Any],
action: str,
status: str,
*,
duration_ms: int,
request_id: str = "",
message: str = "",
error: str = "",
stdout_tail: str = "",
stderr_tail: str = "",
) -> None:
"""Store a compact action history row for a machine operation."""
self.init_schema()
now = int(time.time())
with self.connect() as conn:
conn.execute(
"""
INSERT INTO monitoring_machine_actions
(
id, machine_id, machine_name, mode, action, status,
created_at, duration_ms, request_id, message, error,
stdout_tail, stderr_tail
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
uuid.uuid4().hex,
str(machine.get("id") or ""),
str(machine.get("name") or ""),
str(machine.get("mode") or "local"),
action,
status,
now,
duration_ms,
request_id,
message,
error,
stdout_tail,
stderr_tail,
),
)
def list_machine_actions(
self,
machine_id: str,
*,
limit: int = 20,
action: str | None = None,
status: str | None = None,
) -> list[dict[str, Any]]:
self.init_schema()
clauses = ["machine_id = ?"]
params: list[Any] = [machine_id]
if action:
clauses.append("action = ?")
params.append(action)
if status:
clauses.append("status = ?")
params.append(status)
sql = (
"SELECT machine_id, machine_name, mode, action, status, "
"created_at, duration_ms, request_id, message, error, stdout_tail, stderr_tail "
f"FROM monitoring_machine_actions WHERE {' AND '.join(clauses)} "
"ORDER BY created_at DESC LIMIT ?"
)
params.append(max(1, min(int(limit), 200)))
with self.connect() as conn:
rows = conn.execute(sql, params).fetchall()
return [dict(row) for row in rows]
def prune_machine_actions(self, older_than_ts: int) -> int:
"""Delete action history rows older than the given timestamp."""
self.init_schema()
with self.connect() as conn:
cur = conn.execute(
"DELETE FROM monitoring_machine_actions WHERE created_at < ?",
(int(older_than_ts),),
)
return int(cur.rowcount or 0)
@staticmethod @staticmethod
def _private_key_summary(private_key: str) -> dict[str, str]: def _private_key_summary(private_key: str) -> dict[str, str]:
if not private_key: if not private_key:
@@ -1414,6 +1336,318 @@ class SettingsStore:
(key, value, now), (key, value, now),
) )
# ------------------------------------------------------------------
# Dashboard widgets
# ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
keys = row.keys()
return {
"id": row["id"],
"addon_id": row["addon_id"],
"widget_type": row["widget_type"],
"service_id": row["service_id"] if "service_id" in keys else None,
"widget_kind": row["widget_kind"] if "widget_kind" in keys else None,
"title": row["title"],
"config": json.loads(row["config_json"] or "{}"),
"enabled": bool(row["enabled"]),
"sort_order": int(row["sort_order"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def _normalize_widget_payload(
self,
payload: dict[str, Any],
widget_id: str | None = None,
) -> dict[str, Any]:
current = self.get_widget(widget_id) if widget_id else None
widget_id = str(payload.get("id") or widget_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
service_id = str(payload.get("service_id") or (current or {}).get("service_id") or "").strip() or None
widget_kind = str(payload.get("widget_kind") or (current or {}).get("widget_kind", "")).strip()
title = str(payload.get("title") or (current or {}).get("title", "") or "").strip()
config = payload.get("config", (current or {}).get("config", {}))
if not isinstance(config, dict):
config = {}
# Defense-in-depth: reject credential keys at the store layer too.
_validate_config_keys(config)
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
sort_order = int(payload.get("sort_order", (current or {}).get("sort_order", 0)) or 0)
# Legacy label kept for diagnostics; new code uses service_id + widget_kind.
widget_type = f"{service_id}:{widget_kind}" if widget_kind else ""
return {
"id": widget_id,
"addon_id": "",
"widget_type": widget_type,
"service_id": service_id,
"widget_kind": widget_kind,
"title": title,
"config": config,
"enabled": enabled,
"sort_order": sort_order,
}
def list_widgets(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
rows = conn.execute("SELECT * FROM dashboard_widgets ORDER BY sort_order ASC, created_at ASC").fetchall()
return [self._row_to_widget(row) for row in rows]
def get_widget(self, widget_id: str | None) -> dict[str, Any] | None:
if not widget_id:
return None
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM dashboard_widgets WHERE id = ?", (widget_id,)).fetchone()
return self._row_to_widget(row) if row else None
def upsert_widget(self, payload: dict[str, Any], widget_id: str | None = None) -> dict[str, Any]:
self.init_schema()
widget = self._normalize_widget_payload(payload, widget_id)
now = int(time.time())
with self.connect() as conn:
existing = conn.execute(
"SELECT created_at FROM dashboard_widgets WHERE id = ?",
(widget["id"],),
).fetchone()
created_at = int(existing[0]) if existing else now
conn.execute(
"""
INSERT INTO dashboard_widgets (
id, addon_id, widget_type, service_id, widget_kind, title,
config_json, enabled, sort_order, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
addon_id = excluded.addon_id,
widget_type = excluded.widget_type,
service_id = excluded.service_id,
widget_kind = excluded.widget_kind,
title = excluded.title,
config_json = excluded.config_json,
enabled = excluded.enabled,
sort_order = excluded.sort_order,
updated_at = excluded.updated_at
""",
(
widget["id"],
widget["addon_id"],
widget["widget_type"],
widget["service_id"],
widget["widget_kind"],
widget["title"],
json.dumps(widget["config"]),
1 if widget["enabled"] else 0,
widget["sort_order"],
created_at,
now,
),
)
return self.get_widget(widget["id"]) or widget
def delete_widget(self, widget_id: str) -> None:
self.init_schema()
with self.connect() as conn:
conn.execute("DELETE FROM dashboard_widgets WHERE id = ?", (widget_id,))
# ------------------------------------------------------------------
# Service registry
# ------------------------------------------------------------------
def _row_to_service(self, row: sqlite3.Row) -> dict[str, Any]:
secrets_blob = json.loads(row["secrets_json"] or "{}")
return {
"id": row["id"],
"service_type": row["service_type"],
"name": row["name"],
"config": json.loads(row["config_json"] or "{}"),
"secrets": secrets_blob,
"enabled": bool(row["enabled"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
def list_services(self, service_type: str | None = None) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
if service_type:
rows = conn.execute(
"SELECT * FROM services WHERE service_type = ? ORDER BY name ASC",
(service_type,),
).fetchall()
else:
rows = conn.execute("SELECT * FROM services ORDER BY name ASC").fetchall()
return [self._row_to_service(row) for row in rows]
def get_service(self, service_id: str) -> dict[str, Any] | None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT * FROM services WHERE id = ?", (service_id,)).fetchone()
return self._row_to_service(row) if row else None
def _normalize_service_payload(
self,
payload: dict[str, Any],
service_id: str | None = None,
) -> dict[str, Any]:
current = self.get_service(service_id) if service_id else None
service_id = str(payload.get("id") or service_id or uuid.uuid4().hex[:12]).strip() or uuid.uuid4().hex[:12]
service_type = str(payload.get("service_type") or (current or {}).get("service_type", "")).strip()
name = str(payload.get("name") or (current or {}).get("name", "") or "").strip()
config = payload.get("config", (current or {}).get("config", {}))
if not isinstance(config, dict):
config = {}
enabled = bool(payload.get("enabled", (current or {}).get("enabled", True)))
return {
"id": service_id,
"service_type": service_type,
"name": name,
"config": config,
"enabled": enabled,
}
def upsert_service(
self,
payload: dict[str, Any],
secret_values: dict[str, str] | None = None,
service_id: str | None = None,
) -> dict[str, Any]:
"""Insert or update a service instance.
``secret_values`` carries plaintext secrets to encrypt and store. A key
absent from ``secret_values`` preserves the existing ciphertext; a key
mapped to an empty string clears it.
"""
self.init_schema()
service = self._normalize_service_payload(payload, service_id)
now = int(time.time())
existing = self.get_service(service["id"])
secrets_blob: dict[str, str]
if existing is not None:
secrets_blob = dict(existing["secrets"])
else:
secrets_blob = {}
if secret_values:
from media_library_viewer_api.services.secrets import encrypt_value
for key, value in secret_values.items():
if value == "":
secrets_blob.pop(key, None)
else:
secrets_blob[key] = encrypt_value(value)
with self.connect() as conn:
created_at = int(existing["created_at"]) if existing else now
conn.execute(
"""
INSERT INTO services (
id, service_type, name, config_json, secrets_json,
enabled, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
service_type = excluded.service_type,
name = excluded.name,
config_json = excluded.config_json,
secrets_json = excluded.secrets_json,
enabled = excluded.enabled,
updated_at = excluded.updated_at
""",
(
service["id"],
service["service_type"],
service["name"],
json.dumps(service["config"]),
json.dumps(secrets_blob),
1 if service["enabled"] else 0,
created_at,
now,
),
)
return self.get_service(service["id"]) or service
def delete_service(self, service_id: str) -> None:
"""Delete a service and cascade-delete widgets referencing it."""
self.init_schema()
with self.connect() as conn:
# The service_id column on dashboard_widgets is added in a later
# slice; only cascade when it is present.
widget_cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
if "service_id" in widget_cols:
conn.execute(
"DELETE FROM dashboard_widgets WHERE service_id = ?",
(service_id,),
)
conn.execute("DELETE FROM services WHERE id = ?", (service_id,))
def record_service_task_run(self, payload: dict[str, Any]) -> dict[str, Any]:
"""Append a service task run history row."""
self.init_schema()
run_id = str(payload.get("id") or uuid.uuid4().hex[:12])
now = int(time.time())
with self.connect() as conn:
conn.execute(
"""
INSERT INTO service_task_runs (
id, task_id, service_id, status, exit_status, duration_ms,
stdout_tail, stderr_tail, error, created_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(
run_id,
str(payload.get("task_id") or ""),
str(payload.get("service_id") or ""),
str(payload.get("status") or "error"),
payload.get("exit_status"),
payload.get("duration_ms"),
str(payload.get("stdout_tail") or "")[:8000],
str(payload.get("stderr_tail") or "")[:8000],
str(payload.get("error") or "")[:1000],
int(payload.get("created_at") or now),
),
)
return {"id": run_id}
def list_service_task_runs(
self,
service_id: str | None = None,
task_id: str | None = None,
limit: int = 50,
) -> list[dict[str, Any]]:
self.init_schema()
clauses: list[str] = []
params: list[Any] = []
if service_id:
clauses.append("service_id = ?")
params.append(service_id)
if task_id:
clauses.append("task_id = ?")
params.append(task_id)
where = ("WHERE " + " AND ".join(clauses)) if clauses else ""
params.append(int(limit))
with self.connect() as conn:
rows = conn.execute(
f"SELECT * FROM service_task_runs {where} ORDER BY created_at DESC LIMIT ?",
params,
).fetchall()
return [
{
"id": row["id"],
"task_id": row["task_id"],
"service_id": row["service_id"],
"status": row["status"],
"exit_status": row["exit_status"],
"duration_ms": row["duration_ms"],
"stdout_tail": row["stdout_tail"],
"stderr_tail": row["stderr_tail"],
"error": row["error"],
"created_at": row["created_at"],
}
for row in rows
]
_store: SettingsStore | None = None _store: SettingsStore | None = None
@@ -0,0 +1 @@
"""Widget subsystem package."""
@@ -0,0 +1,68 @@
"""Built-in, service-less widget kinds.
These widgets do not talk to an external service and therefore have no
``service_id``. They are kept out of the service registry (which models
configurable external services) and live here as a small closed set.
Currently: ``backups`` (reads the internal backup tables) and ``static``
(plain text/markdown).
"""
from __future__ import annotations
from typing import Any
from media_library_viewer_api.integrations.base import WidgetKind
BUILTIN_WIDGET_KINDS: dict[str, WidgetKind] = {
"backups": WidgetKind(
kind="backups",
name="Backups",
description="Backup job summary and active alerts.",
config_schema={"type": "object", "properties": {}, "required": []},
default_config={},
refresh_interval_ms=60_000,
),
"static": WidgetKind(
kind="static",
name="Static text",
description="Plain text or markdown note.",
config_schema={
"type": "object",
"properties": {"text": {"type": "string", "description": "Text or markdown content"}},
"required": ["text"],
},
default_config={"text": ""},
refresh_interval_ms=0,
),
}
def get_builtin_widget_kind(kind: str) -> WidgetKind | None:
return BUILTIN_WIDGET_KINDS.get(kind)
def is_builtin_kind(kind: str) -> bool:
return kind in BUILTIN_WIDGET_KINDS
def builtin_widget_kind_models() -> dict[str, type]:
"""Pydantic widget-config models for built-in kinds (validated manually).
Backups has no user fields; static validates ``text``.
"""
from pydantic import BaseModel, Field
class StaticConfig(BaseModel):
text: str = Field(default="")
return {"static": StaticConfig}
def validate_builtin_config(kind: str, config: dict[str, Any]) -> dict[str, Any]:
"""Validate (lightly) a built-in widget config and return the cleaned dict."""
models = builtin_widget_kind_models()
model_cls = models.get(kind)
if model_cls is None:
return dict(config or {})
return model_cls.model_validate(config or {}).model_dump(exclude_none=True)
@@ -0,0 +1,320 @@
"""Widget source adapters.
Adapters translate a widget instance into dashboard data. Service-bound widgets
are resolved against a :class:`ServiceRecord` (config + decrypted secrets); the
built-in widgets (backups, static) take ``service=None``.
Adapters never accept arbitrary commands and never store credentials — secrets
are decrypted in memory only for the duration of a fetch.
"""
from __future__ import annotations
import asyncio
import logging
import shlex
import time
from dataclasses import dataclass, field
from typing import Any, Protocol
import requests
from media_library_viewer_api.clients.jellyfin import JellyfinClient
from media_library_viewer_api.clients.ssh import RemoteSSHClient
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.services.settings_store import SettingsStore, get_settings_store
logger = logging.getLogger(__name__)
@dataclass
class ServiceRecord:
"""Runtime view of a service instance with decrypted secrets."""
id: str
service_type: str
name: str
config: dict[str, Any] = field(default_factory=dict)
secrets: dict[str, str] = field(default_factory=dict)
enabled: bool = True
def build_service_record(store: SettingsStore, service_row: dict[str, Any]) -> ServiceRecord:
"""Build a :class:`ServiceRecord`, decrypting secrets in memory."""
from media_library_viewer_api.services.secrets import decrypt_secrets
return ServiceRecord(
id=service_row["id"],
service_type=service_row["service_type"],
name=service_row["name"],
config=service_row.get("config") or {},
secrets=decrypt_secrets(service_row.get("secrets") or {}),
enabled=bool(service_row.get("enabled", True)),
)
class WidgetSource(Protocol):
"""Protocol for widget source adapters."""
async def fetch(
self,
service: ServiceRecord | None,
widget_kind: str,
config: dict[str, Any],
) -> dict[str, Any]: ...
# ---------------------------------------------------------------------------
# Built-in (service-less) adapters
# ---------------------------------------------------------------------------
class BackupsWidgetSource:
"""Compute the backup dashboard summary from internal tables."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
store = get_settings_store()
summary = build_backup_dashboard_summary(store)
return summary.model_dump()
except Exception as exc:
logger.exception("backups adapter failed")
return {"error": f"Backup summary failed: {exc}"}
class StaticWidgetSource:
"""Return static text/markdown unchanged."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
return {"text": config.get("text", "")}
# ---------------------------------------------------------------------------
# Service-bound adapters
# ---------------------------------------------------------------------------
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:
"""Run a PromQL instant query against a Prometheus service."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
try:
if service is None:
return {"error": "Prometheus widget is missing its service"}
base_url = str(service.config.get("base_url") or "").rstrip("/")
timeout = int(service.config.get("timeout_seconds") or 10)
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
url = f"{base_url}/api/v1/query"
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
url,
params={"query": promql},
timeout=timeout,
),
timeout=timeout,
)
response.raise_for_status()
payload = response.json()
return {"result": payload.get("data", {})}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except requests.RequestException as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"}
except Exception as exc:
logger.exception("prometheus adapter failed")
return {"error": f"Prometheus query failed: {exc}"}
class JellyfinWidgetSource:
"""Fetch Jellyfin sessions and map them to activity rows."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 10
try:
if service is None:
return {"error": "Jellyfin widget is missing its service"}
base_url = str(service.config.get("base_url") or "")
api_key = str(service.secrets.get("api_key") or "")
timeout = int(service.config.get("timeout_seconds") or 10)
client = await asyncio.wait_for(
asyncio.to_thread(JellyfinClient, base_url, api_key, timeout),
timeout=timeout,
)
sessions = await asyncio.wait_for(
asyncio.to_thread(client.sessions),
timeout=timeout,
)
rows = _map_sessions_to_activity_rows(sessions)
return {"sessions": rows}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("jellyfin adapter failed")
return {"error": f"Jellyfin data fetch failed: {exc}"}
class SshTaskWidgetSource:
"""Run a saved task on an SSH task runner instance and log the run."""
async def fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) -> dict[str, Any]:
timeout = 30
try:
if service is None:
return {"error": "SSH task widget is missing its service"}
store = get_settings_store()
task_id = config.get("task_id") or ""
if not task_id:
return {"error": "task_id is required"}
task = store.get_task(task_id)
if not task:
return {"error": f"Task {task_id} not found"}
if not task.get("enabled", True):
return {"error": "Task is disabled"}
client = _build_ssh_client(store, service)
timeout = int(service.config.get("timeout_seconds") or 30)
task_type = str(task.get("task_type") or "shell").lower()
command = str(task.get("content") or "")
if task_type == "python":
command = f"python3 -c {shlex.quote(command)}"
elif task_type != "shell":
return {"error": f"Unknown task type: {task_type}"}
start = time.perf_counter()
result = await asyncio.wait_for(
asyncio.to_thread(client.run, command, timeout),
timeout=timeout,
)
duration_ms = int((time.perf_counter() - start) * 1000)
stdout = result.stdout or ""
stderr = result.stderr or ""
store.record_service_task_run(
{
"task_id": task_id,
"service_id": service.id,
"status": "success" if result.exit_status == 0 else "failure",
"exit_status": result.exit_status,
"duration_ms": duration_ms,
"stdout_tail": stdout,
"stderr_tail": stderr,
"error": "" if result.exit_status == 0 else (stderr or stdout or "Task failed"),
}
)
return {"exit_status": result.exit_status, "stdout": stdout, "stderr": stderr}
except asyncio.TimeoutError:
_record_timeout(service, config, timeout)
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("ssh_task adapter failed")
store = get_settings_store()
store.record_service_task_run(
{
"task_id": str(config.get("task_id") or ""),
"service_id": service.id if service else "",
"status": "error",
"duration_ms": 0,
"error": str(exc)[:1000],
}
)
return {"error": f"SSH task failed: {exc}"}
def _record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) -> None:
try:
store = get_settings_store()
store.record_service_task_run(
{
"task_id": str(config.get("task_id") or ""),
"service_id": service.id if service else "",
"status": "timeout",
"duration_ms": timeout * 1000,
"error": f"Task timed out after {timeout}s",
}
)
except Exception: # pragma: no cover - logging best-effort
logger.exception("failed to record ssh task timeout")
def _build_ssh_client(store: SettingsStore, service: ServiceRecord) -> RemoteSSHClient:
"""Build an SSH client from an ssh_tasks service instance + referenced key."""
config = service.config
host = str(config.get("host") or "").strip()
username = str(config.get("username") or "").strip()
if not host or not username:
raise ValueError("SSH task service is missing host or username")
settings = get_settings()
private_key = ""
key_passphrase = ""
ssh_key_id = str(config.get("ssh_key_id") or "").strip()
if ssh_key_id:
ssh_key = store.get_ssh_key(ssh_key_id)
if ssh_key:
private_key = str(ssh_key.get("private_key") or "")
key_passphrase = str(ssh_key.get("passphrase") or "")
# Service-level passphrase secret takes precedence.
key_passphrase = str(service.secrets.get("passphrase") or "") or key_passphrase
return RemoteSSHClient(
host=host,
username=username,
port=int(config.get("port") or 22),
private_key=private_key or None,
private_key_passphrase=key_passphrase or None,
known_hosts_path=str(settings.ssh_known_hosts_file),
timeout=int(config.get("timeout_seconds") or 30),
)
# ---------------------------------------------------------------------------
# Registries
# ---------------------------------------------------------------------------
SERVICE_ADAPTERS: dict[str, WidgetSource] = {
"grafana": GrafanaWidgetSource(),
"prometheus": PrometheusWidgetSource(),
"jellyfin": JellyfinWidgetSource(),
"ssh_tasks": SshTaskWidgetSource(),
}
BUILTIN_ADAPTERS: dict[str, WidgetSource] = {
"backups": BackupsWidgetSource(),
"static": StaticWidgetSource(),
}
def get_service_adapter(service_type: str) -> WidgetSource | None:
return SERVICE_ADAPTERS.get(service_type)
def get_builtin_adapter(kind: str) -> WidgetSource | None:
return BUILTIN_ADAPTERS.get(kind)
-28
View File
@@ -598,34 +598,6 @@ class TestJobs:
class TestMonitoring: class TestMonitoring:
def _ensure_machine(self):
store = app.dependency_overrides[get_settings_store]()
if not store.list_machines():
store.upsert_machine(
{
"name": "Test Machine",
"mode": "ssh",
"enabled": True,
"services": ["monitoring", "files", "jellyfin"],
"host": "test-host",
"username": "test-user",
}
)
def test_disk(self, test_client, mock_ssh):
self._ensure_machine()
mock_ssh.run.return_value = CommandResult(
command="df ...",
exit_status=0,
stdout='{"filesystem":"/dev/sda1","size":1000000000,"used":500000000,"available":500000000,"used_pct":"50%","mount":"/"}',
stderr="",
)
with patch("media_library_viewer_api.services.monitoring_actions.build_machine_client", return_value=mock_ssh):
response = test_client.get("/api/monitoring/disk")
assert response.status_code == 200
data = response.json()
assert data["used_pct"] == "50%"
def test_prometheus_targets_empty(self, test_client): def test_prometheus_targets_empty(self, test_client):
response = test_client.get("/api/monitoring/prometheus-targets") response = test_client.get("/api/monitoring/prometheus-targets")
assert response.status_code == 200 assert response.status_code == 200
-36
View File
@@ -1,36 +0,0 @@
from unittest.mock import MagicMock, patch
from media_library_viewer_api.services.monitoring_actions import poll_machine_snapshot
def test_poll_machine_snapshot_records_disk_lookup():
store = MagicMock()
machine = {
"id": "local",
"name": "This machine",
"mode": "local",
"media_root": "/srv/media",
}
with (
patch(
"media_library_viewer_api.services.monitoring_actions.build_machine_client",
return_value=object(),
) as build_client,
patch(
"media_library_viewer_api.services.monitoring_actions.disk_space",
return_value={"mount": "/srv/media", "used_pct": "12.5%"},
) as disk_fn,
):
result = poll_machine_snapshot(machine, store, metrics_limit=123, request_id="poll:test")
assert result["request_id"] == "poll:test"
assert result["disk_mount"] == "/srv/media"
assert result["actions"] == ["disk lookup"]
build_client.assert_called_once_with(machine, store)
disk_fn.assert_called_once_with(build_client.return_value, "/srv/media")
assert store.record_machine_action.call_count == 1
recorded_action = store.record_machine_action.call_args
assert recorded_action.args[1] == "disk lookup for /srv/media"
assert recorded_action.kwargs["request_id"] == "poll:test"
assert recorded_action.args[2] == "ok"
+368
View File
@@ -0,0 +1,368 @@
"""Tests for the service registry: definitions, encryption, CRUD, cascade delete."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
from fastapi.testclient import TestClient
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.integrations.registry import (
SERVICE_DEFINITIONS,
get_service_definition,
get_widget_kind,
)
from media_library_viewer_api.main import app
from media_library_viewer_api.services.secrets import (
EncryptionKeyError,
decrypt_secrets,
decrypt_value,
encrypt_secrets,
encrypt_value,
get_encryption_key,
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):
"""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 client(tmp_path):
"""FastAPI test client with a fresh settings store and auth disabled."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: store
auth_settings = SimpleNamespace(auth_enabled=False)
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
yield TestClient(app)
app.dependency_overrides.clear()
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
def test_registry_contains_five_service_types():
assert set(SERVICE_DEFINITIONS) == {
"grafana",
"prometheus",
"jellyfin",
"jellyseerr",
"nextcloud",
"ssh_tasks",
}
def test_definitions_declare_widget_kinds():
assert {wk.kind for wk in get_service_definition("grafana").widget_kinds} == {"link"}
assert {wk.kind for wk in get_service_definition("prometheus").widget_kinds} == {"metric"}
assert {wk.kind for wk in get_service_definition("jellyfin").widget_kinds} == {"activity"}
assert get_service_definition("nextcloud").widget_kinds == []
assert {wk.kind for wk in get_service_definition("ssh_tasks").widget_kinds} == {"task_output"}
def test_widget_kind_lookup():
assert get_widget_kind("grafana", "link") is not None
assert get_widget_kind("grafana", "missing") is None
assert get_widget_kind("unknown", "link") is None
def test_service_config_schema_is_json_schema():
schema = get_service_definition("grafana").config_schema
assert schema["type"] == "object"
assert "base_url" in schema["properties"]
# ---------------------------------------------------------------------------
# Encryption
# ---------------------------------------------------------------------------
def test_encrypt_decrypt_round_trip():
cipher = encrypt_value("hunter2")
assert cipher != "hunter2"
assert decrypt_value(cipher) == "hunter2"
def test_encrypt_decrypt_secrets_dict():
blob = encrypt_secrets({"api_key": "abc", "token": "xyz"})
assert decrypt_secrets(blob) == {"api_key": "abc", "token": "xyz"}
def test_missing_encryption_key_raises(monkeypatch):
monkeypatch.delenv("MANAGE_ENCRYPTION_KEY", raising=False)
reset_encryption_key_cache()
with pytest.raises(EncryptionKeyError):
get_encryption_key()
reset_encryption_key_cache()
def test_decrypt_with_wrong_key_raises(monkeypatch):
blob = encrypt_secrets({"api_key": "abc"})
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", Fernet.generate_key().decode())
reset_encryption_key_cache()
with pytest.raises(EncryptionKeyError):
decrypt_secrets(blob)
reset_encryption_key_cache()
def test_invalid_ciphertext_raises():
with pytest.raises(EncryptionKeyError):
decrypt_value("not-a-real-token")
# ---------------------------------------------------------------------------
# Service type metadata endpoint
# ---------------------------------------------------------------------------
def test_list_service_types(client):
response = client.get("/api/services/types")
assert response.status_code == 200
types = {item["service_type"] for item in response.json()}
assert types == {
"grafana",
"jellyfin",
"jellyseerr",
"nextcloud",
"prometheus",
"ssh_tasks",
}
def test_service_type_includes_secret_and_widget_metadata(client):
response = client.get("/api/services/types")
grafana = next(item for item in response.json() if item["service_type"] == "grafana")
assert [sf["key"] for sf in grafana["secret_fields"]] == ["api_key"]
assert [wk["kind"] for wk in grafana["widget_kinds"]] == ["link"]
# ---------------------------------------------------------------------------
# CRUD
# ---------------------------------------------------------------------------
def _grafana_payload(**overrides):
payload = {
"service_type": "grafana",
"name": "Production Grafana",
"config": {"base_url": "https://grafana.example.com"},
"secrets": {"api_key": "secret-token"},
"enabled": True,
}
payload.update(overrides)
return payload
def test_create_and_list_service(client):
response = client.post("/api/services/instances", json=_grafana_payload())
assert response.status_code == 201
created = response.json()
assert created["service_type"] == "grafana"
assert created["config"]["base_url"] == "https://grafana.example.com"
# Plaintext secrets are never returned.
assert "secrets" not in created
assert created["secrets_set"] == {"api_key": True}
response = client.get("/api/services/instances")
assert response.status_code == 200
assert len(response.json()) == 1
def test_list_instances_filters_by_type(client):
client.post("/api/services/instances", json=_grafana_payload())
client.post(
"/api/services/instances",
json={
"service_type": "prometheus",
"name": "Prom",
"config": {"base_url": "http://prometheus:9090"},
},
)
response = client.get("/api/services/instances?service_type=grafana")
assert response.status_code == 200
assert len(response.json()) == 1
assert response.json()[0]["service_type"] == "grafana"
def test_update_service_preserves_unsent_secrets(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json()
# Update without sending secrets; the existing key should remain set.
updated = client.put(
f"/api/services/instances/{created['id']}",
json={
"service_type": "grafana",
"name": "Renamed Grafana",
"config": {"base_url": "https://grafana.example.com", "timeout_seconds": 10},
},
).json()
assert updated["name"] == "Renamed Grafana"
assert updated["secrets_set"] == {"api_key": True}
def test_update_service_can_clear_secret(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json()
updated = client.put(
f"/api/services/instances/{created['id']}",
json={
"service_type": "grafana",
"name": "Production Grafana",
"config": {"base_url": "https://grafana.example.com"},
"secrets": {"api_key": ""},
},
).json()
assert updated["secrets_set"] == {"api_key": False}
def test_unknown_service_type_rejected(client):
response = client.post(
"/api/services/instances",
json={"service_type": "bogus", "name": "x", "config": {}},
)
assert response.status_code == 422
def test_invalid_config_rejected(client):
response = client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": "x", "config": {"base_url": ""}},
)
# Pydantic accepts empty string; force a real validation error via bad type.
response = client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": "x", "config": {"timeout_seconds": "fast"}},
)
assert response.status_code == 422
def test_unknown_secret_field_rejected(client):
response = client.post(
"/api/services/instances",
json={
"service_type": "grafana",
"name": "x",
"config": {"base_url": "https://grafana.example.com"},
"secrets": {"password": "leak"},
},
)
assert response.status_code == 422
def test_credential_key_in_config_rejected(client):
response = client.post(
"/api/services/instances",
json={
"service_type": "grafana",
"name": "x",
"config": {"base_url": "https://grafana.example.com", "api_key": "leak"},
},
)
assert response.status_code == 422
def test_update_nonexistent_returns_404(client):
response = client.put(
"/api/services/instances/missing",
json=_grafana_payload(id="missing"),
)
assert response.status_code == 404
def test_update_id_mismatch_returns_400(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json()
response = client.put(
f"/api/services/instances/{created['id']}",
json=_grafana_payload(id="other-id"),
)
assert response.status_code == 400
def test_delete_service(client):
created = client.post("/api/services/instances", json=_grafana_payload()).json()
response = client.delete(f"/api/services/instances/{created['id']}")
assert response.status_code == 200
assert client.get("/api/services/instances").json() == []
def test_delete_nonexistent_returns_404(client):
assert client.delete("/api/services/instances/missing").status_code == 404
# ---------------------------------------------------------------------------
# Cascade delete
# ---------------------------------------------------------------------------
def test_delete_service_cascades_to_widgets(client, tmp_path):
"""Once widgets carry service_id (Slice 2), deleting a service removes them.
This test seeds a widget row directly with the column present to prove the
cascade path; the column is added defensively here so the test is meaningful
even before Slice 2 lands.
"""
store = app.dependency_overrides[get_settings_store]()
service = store.upsert_service(
{"service_type": "grafana", "name": "Grafana", "config": {"base_url": "u"}, "enabled": True}
)
# Ensure the service_id column exists and seed a referencing widget.
with store.connect() as conn:
cols = {row[1] for row in conn.execute("PRAGMA table_info(dashboard_widgets)").fetchall()}
if "service_id" not in cols:
conn.execute("ALTER TABLE dashboard_widgets ADD COLUMN service_id TEXT")
conn.execute(
"""
INSERT INTO dashboard_widgets (id, addon_id, widget_type, title, config_json,
enabled, sort_order, created_at, updated_at, service_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
("w1", "grafana", "grafana.link", "Link", "{}", 1, 0, 1, 1, service["id"]),
)
store.delete_service(service["id"])
assert store.get_service(service["id"]) is None
with store.connect() as conn:
remaining = conn.execute(
"SELECT COUNT(*) FROM dashboard_widgets WHERE service_id = ?",
(service["id"],),
).fetchone()
assert int(remaining[0]) == 0
# ---------------------------------------------------------------------------
# Service task run history
# ---------------------------------------------------------------------------
def test_record_and_list_service_task_runs(client):
store = app.dependency_overrides[get_settings_store]()
service = store.upsert_service(
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h"}, "enabled": True}
)
store.record_service_task_run(
{
"task_id": "t1",
"service_id": service["id"],
"status": "success",
"exit_status": 0,
"stdout_tail": "ok",
}
)
runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1
assert runs[0]["status"] == "success"
assert runs[0]["stdout_tail"] == "ok"
+387
View File
@@ -0,0 +1,387 @@
"""Tests for the dashboard widget system: service-bound + built-in widgets."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from cryptography.fernet import Fernet
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
from media_library_viewer_api.widgets.sources import (
BackupsWidgetSource,
GrafanaWidgetSource,
ServiceRecord,
StaticWidgetSource,
)
TEST_KEY = Fernet.generate_key().decode()
@pytest.fixture(autouse=True)
def _encryption_key(monkeypatch):
monkeypatch.setenv("MANAGE_ENCRYPTION_KEY", TEST_KEY)
yield
@pytest.fixture
def client(tmp_path):
"""FastAPI test client with a fresh settings store and auth disabled."""
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
app.dependency_overrides[get_settings_store] = lambda: store
auth_settings = SimpleNamespace(auth_enabled=False)
with patch("media_library_viewer_api.auth.get_settings", return_value=auth_settings):
yield TestClient(app)
app.dependency_overrides.clear()
def _make_grafana_service(client, name="Production Grafana", **config_overrides):
config = {"base_url": "https://grafana.example.com"}
config.update(config_overrides)
return client.post(
"/api/services/instances",
json={"service_type": "grafana", "name": name, "config": config, "enabled": True},
).json()
# ---------------------------------------------------------------------------
# Built-in kinds + built-in widget CRUD
# ---------------------------------------------------------------------------
def test_list_builtin_kinds(client):
response = client.get("/api/widgets/builtin")
assert response.status_code == 200
kinds = {item["kind"] for item in response.json()}
assert kinds == {"backups", "static"}
def test_create_and_read_static_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"widget_kind": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
assert response.status_code == 201
created = response.json()
assert created["widget_kind"] == "static"
assert created["service_id"] is None
assert created["config"]["text"] == "hello"
listed = client.get("/api/widgets/instances").json()
assert len(listed) == 1
assert listed[0]["id"] == created["id"]
def test_create_backups_widget(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
)
assert response.status_code == 201
def test_unknown_builtin_kind_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "bogus", "title": "x", "config": {}},
)
assert response.status_code == 422
def test_credential_key_in_config_rejected(client):
response = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {"api_key": "leak"}},
)
assert response.status_code == 422
# ---------------------------------------------------------------------------
# Service-bound widget CRUD
# ---------------------------------------------------------------------------
def test_create_service_bound_widget(client):
service = _make_grafana_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(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "metric",
"title": "x",
"config": {},
},
)
assert response.status_code == 422
def test_service_bound_widget_service_not_found_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"service_id": "missing",
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
)
assert response.status_code == 422
def test_service_bound_widget_invalid_config_rejected(client):
service = _make_grafana_service(client)
response = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": ""}, # empty still validates; use bad type
},
)
# 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
def test_update_and_delete_widget(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "a"}},
).json()
updated = client.put(
f"/api/widgets/instances/{created['id']}",
json={"widget_kind": "static", "title": "Note2", "config": {"text": "b"}},
).json()
assert updated["title"] == "Note2"
assert client.delete(f"/api/widgets/instances/{created['id']}").status_code == 200
assert client.get("/api/widgets/instances").json() == []
def test_update_nonexistent_returns_404(client):
response = client.put(
"/api/widgets/instances/missing",
json={"widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 404
def test_update_id_mismatch_returns_400(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "x", "config": {}},
).json()
response = client.put(
f"/api/widgets/instances/{created['id']}",
json={"id": "other", "widget_kind": "static", "title": "x", "config": {}},
)
assert response.status_code == 400
# ---------------------------------------------------------------------------
# Data endpoint
# ---------------------------------------------------------------------------
def test_fetch_static_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "static", "title": "Note", "config": {"text": "hello"}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
body = response.json()
assert body["data"]["text"] == "hello"
assert body["error"] is None
def test_fetch_backups_widget_data(client):
created = client.post(
"/api/widgets/instances",
json={"widget_kind": "backups", "title": "Backups", "config": {}},
).json()
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
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):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
# Deleting the service cascade-deletes its widgets, so the widget is gone.
client.delete(f"/api/services/instances/{service['id']}")
assert client.get("/api/widgets/instances").json() == []
assert client.get(f"/api/widgets/instances/{created['id']}/data").status_code == 404
def test_fetch_widget_service_disabled(client):
service = _make_grafana_service(client)
created = client.post(
"/api/widgets/instances",
json={
"service_id": service["id"],
"widget_kind": "link",
"title": "x",
"config": {"dashboard_uid": "u"},
},
).json()
client.put(
f"/api/services/instances/{service['id']}",
json={
"service_type": "grafana",
"name": service["name"],
"config": {"base_url": "https://grafana.example.com"},
"enabled": False,
},
)
response = client.get(f"/api/widgets/instances/{created['id']}/data")
assert response.status_code == 200
assert "disabled" in response.json()["error"]
def test_fetch_widget_not_found(client):
assert client.get("/api/widgets/instances/missing/data").status_code == 404
# ---------------------------------------------------------------------------
# Adapter unit tests
# ---------------------------------------------------------------------------
@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
async def test_static_adapter():
adapter = StaticWidgetSource()
result = await adapter.fetch(None, "static", {"text": "hi"})
assert result == {"text": "hi"}
@pytest.mark.asyncio
async def test_backups_adapter(client):
store = app.dependency_overrides[get_settings_store]()
with patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store):
adapter = BackupsWidgetSource()
result = await adapter.fetch(None, "backups", {})
assert "total_jobs" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_missing_service():
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
result = await adapter.fetch(None, "task_output", {"task_id": "t1"})
assert "error" in result
@pytest.mark.asyncio
async def test_ssh_task_adapter_records_history_on_run(client):
store = app.dependency_overrides[get_settings_store]()
# Save a task and an ssh_tasks service instance.
task = store.upsert_task(
{
"name": "echo",
"task_type": "shell",
"content": "echo hi",
"enabled": True,
"default_machine_id": "",
}
)
service = store.upsert_service(
{"service_type": "ssh_tasks", "name": "box", "config": {"host": "h", "username": "u"}, "enabled": True}
)
fake_result = SimpleNamespace(exit_status=0, stdout="hi\n", stderr="")
fake_client = SimpleNamespace(run=lambda *a, **k: fake_result)
from media_library_viewer_api.widgets.sources import SshTaskWidgetSource
adapter = SshTaskWidgetSource()
service_record = ServiceRecord(
id=service["id"], service_type="ssh_tasks", name="box", config={"host": "h", "username": "u"}
)
with (
patch("media_library_viewer_api.widgets.sources.get_settings_store", return_value=store),
patch("media_library_viewer_api.widgets.sources._build_ssh_client", return_value=fake_client),
):
result = await adapter.fetch(service_record, "task_output", {"task_id": task["id"]})
assert result["exit_status"] == 0
runs = store.list_service_task_runs(service_id=service["id"])
assert len(runs) == 1
assert runs[0]["status"] == "success"
+4 -1
View File
@@ -16,7 +16,8 @@ services:
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000} ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?set MANAGE_ENCRYPTION_KEY in your .env}
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
@@ -37,6 +38,8 @@ services:
VITE_API_URL: "/api" VITE_API_URL: "/api"
VITE_OIDC_ENABLED: "false" VITE_OIDC_ENABLED: "false"
VITE_DEV_API_PROXY_TARGET: "http://backend:8000" VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
VITE_GRAFANA_URL: "http://localhost:3000"
VITE_PROMETHEUS_URL: "http://localhost:9090"
ports: ports:
- "5173:5173" - "5173:5173"
volumes: volumes:
+4 -1
View File
@@ -27,7 +27,8 @@ services:
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd} PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093} ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000} ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
MANAGE_ENCRYPTION_KEY: ${MANAGE_ENCRYPTION_KEY:?generate one with python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"}
volumes: volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache - ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
restart: unless-stopped restart: unless-stopped
@@ -69,6 +70,8 @@ services:
VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI:?set VITE_OIDC_REDIRECT_URI} VITE_OIDC_REDIRECT_URI: ${VITE_OIDC_REDIRECT_URI:?set VITE_OIDC_REDIRECT_URI}
VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI} VITE_OIDC_POST_LOGOUT_REDIRECT_URI: ${VITE_OIDC_POST_LOGOUT_REDIRECT_URI:?set VITE_OIDC_POST_LOGOUT_REDIRECT_URI}
VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000} VITE_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
VITE_PROMETHEUS_URL: ${VITE_PROMETHEUS_URL:-http://localhost:9090}
VITE_APP_VERSION: ${APP_VERSION:-0.1.0} VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev} VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
depends_on: depends_on:
+3 -3
View File
@@ -105,9 +105,9 @@ repo/
| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals | | `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals |
| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown | | `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown |
| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info | | `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info |
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? | | `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? *(legacy/removed)* |
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples | | `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples *(legacy/removed)* |
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root | | `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root *(removed 2026-06-17; metrics now in Prometheus/Grafana)* |
| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector | | `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector |
| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector | | `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector |
| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector | | `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector |
+143 -2
View File
@@ -10,6 +10,67 @@ Build Manage, a compact web application for browsing a remote Jellyfin media lib
Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates. Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server monitoring, and safe job templates.
## Frontend Design System & Architecture
The Manage frontend is a React + TypeScript SPA built on a **single design system**.
The legacy Material UI (MUI v9) / Emotion / recharts / D3 / `theme.ts` stack has been
fully removed (web-ui-rework; see decision log 2026-06-17).
### Design system
- **shadcn/ui** components + **Tailwind CSS v4** + **lucide-react** icons are the only UI layer.
- Design tokens live as CSS `@theme` tokens in `frontend/src/index.css` (light + `.dark`),
with the primary brand color `#4f8cff`.
- The `chart-1`..`chart-5` color tokens are **repurposed as status / Grafana-link color
cues** (not charts): `chart-1`=info/brand, `chart-2`=success/healthy, `chart-3`=warning,
`chart-4`=destructive, `chart-5`=neutral accent. No token value changed.
- Removed from the frontend dependency tree: `@mui/material`, `@mui/icons-material`,
`@mui/x-data-grid`, `@emotion/react`, `@emotion/styled`, `recharts`, `d3`, and the
no-op `src/theme.ts` shim.
### Thin-dashboard observability model
- The app does **no in-app charting**. Metrics, charts, and logs live in the external,
decoupled observability stack (Prometheus / Loki / Grafana / Alertmanager).
- In-app observability surfaces (`/observability`) show **Alertmanager alerts, Prometheus
target health, machine health, and Grafana deep-links** (per-machine metric/log panels),
not rendered graphs.
- The legacy in-app D3 monitoring charts and the POSIX remote resource collector are
superseded by this Grafana-based model (see decision log 2026-06-13 and 2026-06-17).
- **Manage no longer scrapes its own system metrics** (decision 2026-06-17). The backend
`MonitoringPoller` (which SSH-ran `df` on every machine every 5 minutes into a local
SQLite `monitoring_machine_actions` table), the `/api/monitoring/disk`, `/poller`, and
`/machines/{id}/actions` endpoints, and the frontend `DiskSpaceCard` have been removed.
Disk/CPU/memory visibility is owned by Prometheus + node_exporter + Grafana. The
`disk_usage` **job template** in Actions remains as a manual on-demand SSH check.
### Tables
- Tabular surfaces use **TanStack Table** (`@tanstack/react-table`) behind a `DataTable`
wrapper (`components/ui/data-table.tsx`).
- Parity is **visibility-only**: pagination, row selection, row click, and column
visibility are supported. There is **no client sorting and no column resizing**.
- Media uses **server-driven pagination** (`manualPagination` + `rowCount`); the File
Browser renders the full listing without pagination.
- The Media and File Browser tables previously used `@mui/x-data-grid`; both now use the
TanStack `DataTable` (earlier "AG Grid" / `@mui/x-data-grid` references are superseded).
### Reconciled information architecture
- **Backups** is a top-level navigation item at `/backups`.
- The media/applications surface is named **Media** and lives at `/media`; `/applications`
redirects to `/media`, mirroring the existing `/monitoring``/observability` redirect.
- User deep-links (`/users?user=<id>`), dashboard shortcut deep-links, and the Media →
File Browser row-click navigation are preserved under the reconciled routes.
### Frontend testing
- Component tests run on **Vitest + @testing-library/react** (`npm test`), with the
`@testing-library/jest-dom` matchers.
- Legacy plain-Node suites (`frontend/tests/*.test.mjs`) run via
`node --test tests/*.test.mjs` (npm script `test:node`).
- The build/lint gate is `npm run build` (`tsc -b` + `vite build`) + `npm run lint` (ESLint).
## Core Requirements ## Core Requirements
### Jellyfin Library ### Jellyfin Library
@@ -82,7 +143,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- Support manual path entry and refresh. - Support manual path entry and refresh.
- Remote file listing must be compact, structured, and navigable. - Remote file listing must be compact, structured, and navigable.
- The file table should be read-only. - The file table should be read-only.
- The file table should use row selection (single-select) in an AG Grid format consistent with the Media tab. - The file table should use row selection (single-select) in a TanStack `DataTable` format consistent with the Media tab (both migrated off the legacy `@mui/x-data-grid`/AG Grid).
- The file table should not expose a visible checkbox selection column. - The file table should not expose a visible checkbox selection column.
- The file table should not show a visible `selected` column. - The file table should not show a visible `selected` column.
- Include a top `[UP] ..` row, when not at `/`, to navigate to the parent directory. - Include a top `[UP] ..` row, when not at `/`, to navigate to the parent directory.
@@ -166,7 +227,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- The dashboard should present disk space as a single combined card with the progress/fill bar embedded inside the card and the size breakdown laid out clearly, with centered sub-card text for the Used/Free/Total breakdown and consistent vertical spacing across the dashboard cards. - The dashboard should present disk space as a single combined card with the progress/fill bar embedded inside the card and the size breakdown laid out clearly, with centered sub-card text for the Used/Free/Total breakdown and consistent vertical spacing across the dashboard cards.
- The disk usage bar should change color as usage increases so high utilization is easy to notice at a glance. - The disk usage bar should change color as usage increases so high utilization is easy to notice at a glance.
- The disk usage card should avoid redundant percentage labels next to the bar if the bar itself already communicates the value. - The disk usage card should avoid redundant percentage labels next to the bar if the bar itself already communicates the value.
- Render Monitoring charts directly with D3 so the UI can support brush-based range selection, hover tooltips with a moving vertical cursor and snapped point markers, summary chips, and moving averages without a separate wrapper library. - (Superseded by the thin-dashboard observability model — 2026-06-17.) The app no longer renders in-app monitoring charts with D3; metrics/charts/logs live in the external Grafana stack, and the in-app Observability page surfaces Alertmanager alerts, Prometheus target health, and Grafana deep-links.
- Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`. - Use a lightweight remote collector that reads Linux `/proc`, `/sys/block`, and `df` data into a JSONL file under `/tmp`.
- The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap. - The collector should rotate/prune its JSONL metrics file so it does not grow unbounded; default retention is 7 days with a 70,000-line safety cap.
- The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack. - The collector should be startable/stoppable/restartable from the dashboard and should not require installing a full monitoring stack.
@@ -195,8 +256,83 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- Job templates should remain centralized in `jobs.py` for future extension. - Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution. - Remote job template values must be shell-quoted before execution.
## Service Registry and Dashboard Widgets
### Overview
External services (Grafana, Prometheus, Jellyfin, Nextcloud, SSH task runner) are
configured **in the app** and persisted in the backend SQLite database. Each
service instance holds non-secret config plus encrypted secret fields. Dashboard
widgets are either **service-bound** (reference a service instance + a widget
kind declared by that service) or **built-in / service-less** (backups, static
text).
Service definitions live as Pydantic modules in the backend
(`integrations/`); they declare the service config schema, secret fields, and
the widget kinds the service provides. There is no runtime plugin loading.
### Services
- **Grafana** — base URL + optional API key; provides a dashboard-link widget.
- **Prometheus** — base URL + optional bearer token; provides a PromQL metric widget.
- **Jellyfin** — base URL + API key; provides a live-activity widget.
- **Nextcloud** — base URL + app password (no widgets yet).
- **SSH task runner** — host/port/username + saved SSH key reference + optional
passphrase; provides a task-output widget. Tasks stay in the global saved-task
registry; every run is recorded in `service_task_runs` as history.
Multiple instances per service type are supported. Services are managed from the
**Services** page (`/services`) and each instance has a detail page at
`/services/:serviceType/:serviceId`.
### Built-in widgets
- **Backups** — internal backup job summary and active alerts.
- **Static text** — plain text or markdown note.
These do not reference a service.
### Security
- Service secrets (API keys, tokens, passphrases) are **encrypted at rest** with
Fernet using a single env-provided `MANAGE_ENCRYPTION_KEY`, which is always
required to start the backend.
- Widget `config` and service `config` may not contain credential keys or
secret-looking values; secrets go in the dedicated secret fields only.
- Plaintext secrets are never returned by the API; only `secrets_set` flags are
surfaced.
- SSH task widgets only run tasks from the saved-task registry; arbitrary
commands are not accepted.
### API
- `GET /api/services/types` — service definition metadata (config schema,
secret fields, widget kinds).
- `GET /api/services/instances` — list service instances (no plaintext secrets).
- `POST /api/services/instances` — create instance.
- `PUT /api/services/instances/{id}` — update instance.
- `DELETE /api/services/instances/{id}` — delete instance (cascade-deletes
widgets referencing it).
- `GET /api/widgets/builtin` — built-in (service-less) widget kinds.
- `GET /api/widgets/instances` — list widget instances.
- `POST/PUT/DELETE /api/widgets/instances/{id}` — widget CRUD.
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
### Breaking change
Grafana/Prometheus URLs and credentials moved from environment variables into
service records. The legacy `GRAFANA_URL` / `PROMETHEUS_URL` backend settings and
the widget/addon-pages model were removed. `MANAGE_ENCRYPTION_KEY` is now required.
> **Follow-up (not in this change):** machine-level Jellyfin/Jellyseerr app
> config still powers the Media/Users/Files pages. Migrating those onto the
> service registry (and removing the machine app fields) is a separate change;
> see `openspec/changes/service-registry/design.md` §12.5.
## Decision Log ## Decision Log
- 2026-06-17: Decommissioned the legacy Manage-side system-metric scraping. Removed the backend `MonitoringPoller` (SSH-ran `df` on every machine every 5 min into a local SQLite `monitoring_machine_actions` table), the entire `services/monitoring_actions.py` module, the `/api/monitoring/poller`, `/api/monitoring/machines/{id}/actions`, and `/api/monitoring/disk` endpoints, the `monitoring_machine_actions` table (DROP on startup), the three `monitoring_poll_*` / `monitoring_action_retention_days` config knobs, and the orphaned frontend `DiskSpaceCard` + `DiskSpace` type. System metrics are now owned exclusively by Prometheus + node_exporter + Grafana. Kept the Alertmanager proxy (`/alerts`, `/alertmanager-status`, `/alertmanager-webhook`), `/prometheus-targets`, `/machines`, the `node_exporter_*` machine fields, and the on-demand `disk_usage` job template.
- 2026-06-17: Completed the web UI rework to a single design system. The frontend now uses **shadcn/ui + Tailwind CSS v4 + lucide-react** exclusively, with CSS `@theme` tokens in `src/index.css` (primary `#4f8cff`; `chart-1..5` repurposed as status/Grafana-link cues). Removed `@mui/material`, `@mui/icons-material`, `@mui/x-data-grid`, `@emotion/react`, `@emotion/styled`, `recharts`, `d3`, and the `src/theme.ts` shim. Tables moved from `@mui/x-data-grid`/AG Grid to a visibility-only TanStack `DataTable` wrapper (pagination, row selection, row click, column visibility — no sorting/resizing). Adopted the thin-dashboard observability model (no in-app charts; Alertmanager alerts + Prometheus target health + Grafana deep-links). Reconciled the information architecture: Backups is a top-level nav item at `/backups`, and the media surface is named Media at `/media` with `/applications` redirecting to `/media` (mirroring `/monitoring``/observability`). Frontend tests moved to Vitest + @testing-library/react (`npm test`), with legacy node suites in `frontend/tests`.
- 2026-06-13: Adopted a dedicated, self-hosted observability subsystem based on Prometheus, Grafana Loki, Grafana, and Alertmanager. Metrics will be pulled from Node Exporter on machines and from application exporters in containers; logs will be structured JSON shipped by Promtail/Grafana Alloy. The existing POSIX remote collector will be removed and backup alerts migrated to Alertmanager rules. See `docs/monitoring-logging-design.md`. - 2026-06-13: Adopted a dedicated, self-hosted observability subsystem based on Prometheus, Grafana Loki, Grafana, and Alertmanager. Metrics will be pulled from Node Exporter on machines and from application exporters in containers; logs will be structured JSON shipped by Promtail/Grafana Alloy. The existing POSIX remote collector will be removed and backup alerts migrated to Alertmanager rules. See `docs/monitoring-logging-design.md`.
- 2026-06-13 (Phase 1): Added Prometheus, Loki, Grafana Alloy, Grafana, Alertmanager, and Node Exporter services to `docker-compose.yml` and `docker-compose.dev.yml`. Provisioned Grafana datasources and an initial `Manage Overview` dashboard as code. Configured Alloy to tail Docker logs and ship to Loki. Added Grafana generic OAuth configuration via `monitoring/grafana/grafana.ini` and a dedicated Traefik host rule. Added Alertmanager email routing with env-var interpolation. Added `/grafana` proxy to the Vite dev server for iframe embedding. - 2026-06-13 (Phase 1): Added Prometheus, Loki, Grafana Alloy, Grafana, Alertmanager, and Node Exporter services to `docker-compose.yml` and `docker-compose.dev.yml`. Provisioned Grafana datasources and an initial `Manage Overview` dashboard as code. Configured Alloy to tail Docker logs and ship to Loki. Added Grafana generic OAuth configuration via `monitoring/grafana/grafana.ini` and a dedicated Traefik host rule. Added Alertmanager email routing with env-var interpolation. Added `/grafana` proxy to the Vite dev server for iframe embedding.
- 2026-06-13 (Phase 2): Extended machine settings with `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host`. Added Node Exporter install/restart/status job templates to `jobs.py`. Implemented `media_library_viewer_api.services.targets` to generate Prometheus file-SD target files and wired target regeneration into machine create/update/delete. Added `/api/monitoring/prometheus-targets` for live target previews. Configured Prometheus with a `node-exporter-remote` job reading file SD from the backend cache volume. Added a minimal `Node Exporter Overview` Grafana dashboard. Added unit and integration tests for target generation and the new endpoint. - 2026-06-13 (Phase 2): Extended machine settings with `node_exporter_enabled`, `node_exporter_port`, and `node_exporter_scrape_host`. Added Node Exporter install/restart/status job templates to `jobs.py`. Implemented `media_library_viewer_api.services.targets` to generate Prometheus file-SD target files and wired target regeneration into machine create/update/delete. Added `/api/monitoring/prometheus-targets` for live target previews. Configured Prometheus with a `node-exporter-remote` job reading file SD from the backend cache volume. Added a minimal `Node Exporter Overview` Grafana dashboard. Added unit and integration tests for target generation and the new endpoint.
@@ -289,9 +425,11 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
## Backup Monitoring ## Backup Monitoring
### Overview ### Overview
The system receives backup execution reports from an external backup tool via HTTP API, stores job and run history, and provides alerting on failures, missed schedules, and anomalies. The system receives backup execution reports from an external backup tool via HTTP API, stores job and run history, and provides alerting on failures, missed schedules, and anomalies.
### API ### API
- `POST /api/backups/report` — Submit backup run (Bearer token auth) - `POST /api/backups/report` — Submit backup run (Bearer token auth)
- `POST /api/backups/report/start` — Mark backup as in_progress - `POST /api/backups/report/start` — Mark backup as in_progress
- `GET /api/backups/jobs` — List jobs - `GET /api/backups/jobs` — List jobs
@@ -301,16 +439,19 @@ The system receives backup execution reports from an external backup tool via HT
- `GET /api/dashboard/backups` — Dashboard summary - `GET /api/dashboard/backups` — Dashboard summary
### Data Model ### Data Model
- **BackupJob**: id, name, source, target, schedule_interval_seconds, created_at - **BackupJob**: id, name, source, target, schedule_interval_seconds, created_at
- **BackupRun**: id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json - **BackupRun**: id, job_id, started_at, ended_at, status, bytes_transferred, duration_ms, error_message, details_json
- **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at - **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at
### Alert Types ### Alert Types
- `failed_status` — Backup reported failure (critical) - `failed_status` — Backup reported failure (critical)
- `missed_schedule` — No run within 1.5x expected interval (warning) - `missed_schedule` — No run within 1.5x expected interval (warning)
- `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning) - `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning)
- `anomaly_duration` — Duration >300% of 7-day median (warning) - `anomaly_duration` — Duration >300% of 7-day median (warning)
### Authentication ### Authentication
- 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
+16 -5
View File
@@ -60,10 +60,16 @@ The existing POSIX remote collector will be removed, and the Python backup alert
### Metrics ### Metrics
- `backend/src/media_library_viewer_api/clients/resources.py` deploys a POSIX shell collector to `/tmp` on each remote machine. > **Historical note (2026-06-17):** The legacy Manage-side `MonitoringPoller` that
- The collector samples `/proc/stat`, `/proc/meminfo`, `/proc/net/dev`, and `/sys/block/*/stat` every 10s and writes JSONL to `/tmp/media_library_viewer_metrics.jsonl`. > SSH-scraped `/proc` + `df` into a local SQLite table (`monitoring_machine_actions`)
- `MonitoringPoller` (`monitoring_poller.py`) runs every 5 minutes, reads the remote JSONL, and stores snapshots in SQLite (`monitoring_machine_actions`). > has been **decommissioned**. System metrics now live entirely in the external
- Retention defaults to 30 days with periodic pruning. > observability stack: `node_exporter` on each machine is scraped by **Prometheus**
> and visualised in **Grafana** (see the standalone `docker-compose.observability.yml`
> stack). Manage is a thin dashboard: it surfaces Alertmanager alerts + Prometheus
> target health + Grafana deep-links, and does not collect or store its own metrics.
- `main.py` has a `log_requests` middleware that emits method, path, client IP, status code, and elapsed time.
- Frontend uses standard `console.log` / browser dev tools; no server-side log aggregation.
### Alerting ### Alerting
@@ -210,7 +216,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- Manage API overview (request rate, latency, errors). - Manage API overview (request rate, latency, errors).
- Manage operations (SSH commands, media index builds, mail queue). - Manage operations (SSH commands, media index builds, mail queue).
- Backup runs and alert history. - Backup runs and alert history.
- Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and ` kiosk` mode. - Manage iframe embeds point to specific dashboard panels using Grafana's `panelId` and `kiosk` mode.
### Manage React UI ### Manage React UI
@@ -309,6 +315,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- [x] Wire Grafana OAuth to Authentik. - [x] Wire Grafana OAuth to Authentik.
**Phase 1 files**: **Phase 1 files**:
- `monitoring/prometheus/prometheus.yml` - `monitoring/prometheus/prometheus.yml`
- `monitoring/prometheus/rules/backup_alerts.yml` - `monitoring/prometheus/rules/backup_alerts.yml`
- `monitoring/loki/loki.yml` - `monitoring/loki/loki.yml`
@@ -336,6 +343,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- [x] Remove POSIX collector fallback. The legacy collector code in `backend/src/media_library_viewer_api/clients/resources.py` has been deleted, the collector control endpoints were removed from `routers/monitoring.py`, and `disk_space` was relocated to `services/monitoring_actions.py` as a lightweight SSH/local helper. Metrics are now sourced exclusively from Prometheus/Node Exporter. - [x] Remove POSIX collector fallback. The legacy collector code in `backend/src/media_library_viewer_api/clients/resources.py` has been deleted, the collector control endpoints were removed from `routers/monitoring.py`, and `disk_space` was relocated to `services/monitoring_actions.py` as a lightweight SSH/local helper. Metrics are now sourced exclusively from Prometheus/Node Exporter.
**Phase 2 files**: **Phase 2 files**:
- `backend/src/media_library_viewer_api/jobs.py` (Node Exporter job templates). - `backend/src/media_library_viewer_api/jobs.py` (Node Exporter job templates).
- `backend/src/media_library_viewer_api/routers/settings.py` (machine input fields + target regeneration). - `backend/src/media_library_viewer_api/routers/settings.py` (machine input fields + target regeneration).
- `backend/src/media_library_viewer_api/services/settings_store.py` (machine persistence fields). - `backend/src/media_library_viewer_api/services/settings_store.py` (machine persistence fields).
@@ -364,6 +372,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- [x] Added tests for the Alertmanager endpoints and the backup success gauge. - [x] Added tests for the Alertmanager endpoints and the backup success gauge.
**Phase 3 files**: **Phase 3 files**:
- `backend/src/media_library_viewer_api/routers/monitoring.py` (`/alerts` and `/alertmanager-status` endpoints). - `backend/src/media_library_viewer_api/routers/monitoring.py` (`/alerts` and `/alertmanager-status` endpoints).
- `backend/src/media_library_viewer_api/observability.py` (`BACKUP_RUNS_LAST_SUCCESS` gauge + updated `record_backup_run`). - `backend/src/media_library_viewer_api/observability.py` (`BACKUP_RUNS_LAST_SUCCESS` gauge + updated `record_backup_run`).
- `backend/src/media_library_viewer_api/routers/backups.py` (pass `success=True` to `record_backup_run` on successful reports). - `backend/src/media_library_viewer_api/routers/backups.py` (pass `success=True` to `record_backup_run` on successful reports).
@@ -386,6 +395,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- [x] Wire the new `/observability` route into `App.tsx` and the sidebar navigation. - [x] Wire the new `/observability` route into `App.tsx` and the sidebar navigation.
**Phase 4 files**: **Phase 4 files**:
- `frontend/src/components/ObservabilityPage.tsx` (page component). - `frontend/src/components/ObservabilityPage.tsx` (page component).
- `frontend/src/hooks/useObservability.ts` (React Query hooks). - `frontend/src/hooks/useObservability.ts` (React Query hooks).
- `frontend/src/api/client.ts` (API client functions). - `frontend/src/api/client.ts` (API client functions).
@@ -406,6 +416,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- [ ] Optional: add OpenTelemetry Collector as a translation layer for traces later. - [ ] Optional: add OpenTelemetry Collector as a translation layer for traces later.
**Phase 5 files**: **Phase 5 files**:
- `docker-compose.yml` and `docker-compose.dev.yml` (health checks, resource limits, `depends_on` conditions). - `docker-compose.yml` and `docker-compose.dev.yml` (health checks, resource limits, `depends_on` conditions).
- `monitoring/prometheus/prometheus.yml` (additional scrape jobs for observability services). - `monitoring/prometheus/prometheus.yml` (additional scrape jobs for observability services).
- `monitoring/prometheus/rules/backup_alerts.yml` (renamed scope to include observability health alerts). - `monitoring/prometheus/rules/backup_alerts.yml` (renamed scope to include observability health alerts).
+6
View File
@@ -15,6 +15,8 @@ ARG VITE_OIDC_SCOPE=openid profile email
ARG VITE_OIDC_REDIRECT_URI= ARG VITE_OIDC_REDIRECT_URI=
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI= ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000 ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
ARG VITE_GRAFANA_URL=https://grafana.example.com
ARG VITE_PROMETHEUS_URL=http://localhost:9090
ARG VITE_APP_VERSION=0.1.0 ARG VITE_APP_VERSION=0.1.0
ARG VITE_APP_BUILD_INFO=dev ARG VITE_APP_BUILD_INFO=dev
@@ -26,6 +28,8 @@ ENV VITE_API_URL=${VITE_API_URL} \
VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI} \ VITE_OIDC_REDIRECT_URI=${VITE_OIDC_REDIRECT_URI} \
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \ VITE_OIDC_POST_LOGOUT_REDIRECT_URI=${VITE_OIDC_POST_LOGOUT_REDIRECT_URI} \
VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \ VITE_DEV_API_PROXY_TARGET=${VITE_DEV_API_PROXY_TARGET} \
VITE_GRAFANA_URL=${VITE_GRAFANA_URL} \
VITE_PROMETHEUS_URL=${VITE_PROMETHEUS_URL} \
VITE_APP_VERSION=${VITE_APP_VERSION} \ VITE_APP_VERSION=${VITE_APP_VERSION} \
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO} VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
@@ -50,6 +54,8 @@ COPY frontend/ ./
ENV VITE_API_URL=/api \ ENV VITE_API_URL=/api \
VITE_OIDC_ENABLED=false \ VITE_OIDC_ENABLED=false \
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \ VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
VITE_GRAFANA_URL=http://localhost:3000 \
VITE_PROMETHEUS_URL=http://localhost:9090 \
VITE_APP_VERSION=0.1.0 \ VITE_APP_VERSION=0.1.0 \
VITE_APP_BUILD_INFO=dev VITE_APP_BUILD_INFO=dev
+1126 -1491
View File
File diff suppressed because it is too large Load Diff
+11 -10
View File
@@ -7,19 +7,17 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:node": "node --test tests/*.test.mjs"
}, },
"dependencies": { "dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist": "^5.2.8",
"@mui/icons-material": "^9.0.0",
"@mui/material": "^9.0.0",
"@mui/x-data-grid": "^9.0.4",
"@tanstack/react-query": "^5.100.6", "@tanstack/react-query": "^5.100.6",
"@tanstack/react-table": "^8.21.3",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"d3": "^7.9.0",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0", "oidc-client-ts": "^3.5.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
@@ -27,7 +25,6 @@
"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.8.1",
"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"
@@ -36,7 +33,9 @@
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^4.3.0", "@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/vite": "^4.3.0", "@tailwindcss/vite": "^4.3.0",
"@types/d3": "^7.4.3", "@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.13.2", "@types/node": "^24.13.2",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
@@ -46,10 +45,12 @@
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.14", "postcss": "^8.5.14",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"typescript-eslint": "^8.58.2", "typescript-eslint": "^8.58.2",
"vite": "^8.0.10" "vite": "^8.0.10",
"vitest": "^4.1.9"
} }
} }
+33 -5
View File
@@ -22,6 +22,8 @@ import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions"; import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage"; import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage"; import { ObservabilityPage } from "./components/ObservabilityPage";
import { ServicePage } from "./pages/ServicePage";
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";
@@ -43,6 +45,7 @@ import {
import { import {
LayoutDashboard, LayoutDashboard,
Activity, Activity,
DatabaseBackup,
Monitor, Monitor,
Users, Users,
Zap, Zap,
@@ -54,6 +57,7 @@ import {
LogOut, LogOut,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Boxes,
} from "lucide-react"; } from "lucide-react";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
@@ -82,10 +86,12 @@ function useDarkMode() {
const navItems = [ const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard }, { path: "/", label: "Dashboard", icon: LayoutDashboard },
{ path: "/observability", label: "Observability", icon: Activity }, { path: "/observability", label: "Observability", icon: Activity },
{ path: "/applications", label: "Media", icon: Monitor }, { path: "/media", label: "Media", icon: Monitor },
{ path: "/files", label: "Files", icon: FolderOpen }, { path: "/files", label: "Files", icon: FolderOpen },
{ path: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users }, { path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap }, { path: "/actions", label: "Actions", icon: Zap },
{ path: "/services", label: "Services", icon: Boxes },
{ path: "/settings", label: "Settings", icon: SettingsIcon }, { path: "/settings", label: "Settings", icon: SettingsIcon },
]; ];
@@ -432,15 +438,26 @@ function AppInner() {
<Routes> <Routes>
<Route element={<AuthenticatedApp />}> <Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Navigate to="/observability" replace />} /> <Route
<Route path="/applications" element={<Applications />} /> path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} /> <Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} /> <Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} /> <Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} /> <Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
@@ -457,15 +474,26 @@ function AppInner() {
} }
> >
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Navigate to="/observability" replace />} /> <Route
<Route path="/applications" element={<Applications />} /> path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} /> <Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} /> <Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} /> <Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} /> <Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} /> <Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} /> <Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} /> <Route path="/settings" element={<Settings />} />
<Route path="/services" element={<ServicesPage />} />
<Route
path="/services/:serviceType/:serviceId"
element={<ServicePage />}
/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
+24 -22
View File
@@ -134,26 +134,26 @@ async function del<T>(path: string): Promise<T> {
return response.json(); return response.json();
} }
// Dashboard // Dashboard (Jellyfin-backed; selected via jellyfin_service_id)
export const fetchCounts = (machineId?: string) => export const fetchCounts = (jellyfinServiceId?: string) =>
get<MediaCounts>( get<MediaCounts>(
"/api/dashboard/counts", "/api/dashboard/counts",
machineId ? { machine_id: machineId } : undefined, jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
); );
export const fetchLibraries = (machineId?: string) => export const fetchLibraries = (jellyfinServiceId?: string) =>
get<LibraryCount[]>( get<LibraryCount[]>(
"/api/dashboard/libraries", "/api/dashboard/libraries",
machineId ? { machine_id: machineId } : undefined, jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
); );
export const fetchActivity = (machineId?: string) => export const fetchActivity = (jellyfinServiceId?: string) =>
get<NowPlayingSession[]>( get<NowPlayingSession[]>(
"/api/dashboard/activity", "/api/dashboard/activity",
machineId ? { machine_id: machineId } : undefined, jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
); );
export const fetchUsers = (machineId?: string) => export const fetchUsers = (jellyfinServiceId?: string) =>
get<UserDirectoryResponse>( get<UserDirectoryResponse>(
"/api/users", "/api/users",
machineId ? { machine_id: machineId } : undefined, jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
); );
// Backward-compatible alias used by older hooks/components. // Backward-compatible alias used by older hooks/components.
@@ -293,27 +293,27 @@ export const resetLocalDatabase = (payload: ResetLocalDatabaseInput) =>
); );
// Media // Media
export const fetchMediaStatus = (machineId?: string) => export const fetchMediaStatus = (jellyfinServiceId?: string) =>
get<MediaIndexStatus>( get<MediaIndexStatus>(
"/api/media/status", "/api/media/status",
machineId ? { machine_id: machineId } : undefined, jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
); );
export const buildMediaIndex = (machineId?: string) => export const buildMediaIndex = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>( post<MediaIndexActionResponse>(
machineId jellyfinServiceId
? `/api/media/build?machine_id=${encodeURIComponent(machineId)}` ? `/api/media/build?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/build", : "/api/media/build",
); );
export const stopMediaIndexBuild = (machineId?: string) => export const stopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>( post<MediaIndexActionResponse>(
machineId jellyfinServiceId
? `/api/media/stop?machine_id=${encodeURIComponent(machineId)}` ? `/api/media/stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/stop", : "/api/media/stop",
); );
export const forceStopMediaIndexBuild = (machineId?: string) => export const forceStopMediaIndexBuild = (jellyfinServiceId?: string) =>
post<MediaIndexActionResponse>( post<MediaIndexActionResponse>(
machineId jellyfinServiceId
? `/api/media/force-stop?machine_id=${encodeURIComponent(machineId)}` ? `/api/media/force-stop?jellyfin_service_id=${encodeURIComponent(jellyfinServiceId)}`
: "/api/media/force-stop", : "/api/media/force-stop",
); );
export const queryMedia = (params: { export const queryMedia = (params: {
@@ -325,7 +325,7 @@ export const queryMedia = (params: {
sort_order?: string; sort_order?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
machineId?: string; jellyfinServiceId?: string;
}) => }) =>
get<MediaQueryResponse>("/api/media/query", { get<MediaQueryResponse>("/api/media/query", {
libraries: params.libraries || "", libraries: params.libraries || "",
@@ -336,7 +336,9 @@ export const queryMedia = (params: {
sort_order: params.sort_order || "Ascending", sort_order: params.sort_order || "Ascending",
limit: String(params.limit || 100), limit: String(params.limit || 100),
offset: String(params.offset || 0), offset: String(params.offset || 0),
...(params.machineId ? { machine_id: params.machineId } : {}), ...(params.jellyfinServiceId
? { jellyfin_service_id: params.jellyfinServiceId }
: {}),
}); });
// Files // Files
+59
View File
@@ -0,0 +1,59 @@
import type {
ServiceInstance,
ServiceInstanceInput,
ServiceTypeInfo,
} from "../types";
const API_BASE = "/api";
export async function fetchServiceTypes(): Promise<ServiceTypeInfo[]> {
const res = await fetch(`${API_BASE}/services/types`);
if (!res.ok) throw new Error("Failed to fetch service types");
return res.json();
}
export async function fetchServiceInstances(
serviceType?: string,
): Promise<ServiceInstance[]> {
const query = serviceType
? `?service_type=${encodeURIComponent(serviceType)}`
: "";
const res = await fetch(`${API_BASE}/services/instances${query}`);
if (!res.ok) throw new Error("Failed to fetch service instances");
return res.json();
}
export async function createServiceInstance(
input: ServiceInstanceInput,
): Promise<ServiceInstance> {
const res = await fetch(`${API_BASE}/services/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create service instance");
return res.json();
}
export async function updateServiceInstance(
input: ServiceInstanceInput,
): Promise<ServiceInstance> {
if (!input.id) throw new Error("Service ID is required for update");
const res = await fetch(`${API_BASE}/services/instances/${input.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to update service instance");
return res.json();
}
export async function deleteServiceInstance(
serviceId: string,
): Promise<{ status: string }> {
const res = await fetch(`${API_BASE}/services/instances/${serviceId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete service instance");
return res.json();
}
+65
View File
@@ -0,0 +1,65 @@
import type {
BuiltinWidgetKindInfo,
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
} from "../types";
const API_BASE = "/api";
export async function fetchBuiltinWidgetKinds(): Promise<
BuiltinWidgetKindInfo[]
> {
const res = await fetch(`${API_BASE}/widgets/builtin`);
if (!res.ok) throw new Error("Failed to fetch built-in widget kinds");
return res.json();
}
export async function fetchWidgetInstances(): Promise<WidgetInstance[]> {
const res = await fetch(`${API_BASE}/widgets/instances`);
if (!res.ok) throw new Error("Failed to fetch widget instances");
return res.json();
}
export async function createWidgetInstance(
input: WidgetInstanceInput,
): Promise<WidgetInstance> {
const res = await fetch(`${API_BASE}/widgets/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to create widget instance");
return res.json();
}
export async function updateWidgetInstance(
input: WidgetInstanceInput,
): Promise<WidgetInstance> {
if (!input.id) throw new Error("Widget ID is required for update");
const res = await fetch(`${API_BASE}/widgets/instances/${input.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(input),
});
if (!res.ok) throw new Error("Failed to update widget instance");
return res.json();
}
export async function deleteWidgetInstance(
widgetId: string,
): Promise<{ status: string }> {
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}`, {
method: "DELETE",
});
if (!res.ok) throw new Error("Failed to delete widget instance");
return res.json();
}
export async function fetchWidgetData(
widgetId: string,
): Promise<WidgetDataResponse> {
const res = await fetch(`${API_BASE}/widgets/instances/${widgetId}/data`);
if (!res.ok) throw new Error("Failed to fetch widget data");
return res.json();
}
+63 -46
View File
@@ -1,56 +1,73 @@
import { Button, Chip, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from "@mui/material"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { BackupAlert } from "../types/backups"; import type { BackupAlert } from "../types/backups";
interface Props { interface Props {
alerts: BackupAlert[]; alerts: BackupAlert[];
onAcknowledge: (alertId: string) => void; onAcknowledge: (alertId: string) => void;
} }
function formatTimestamp(ts: number): string { function formatTimestamp(ts: number): string {
return new Date(ts * 1000).toLocaleString(); return new Date(ts * 1000).toLocaleString();
}
type SeverityVariant = "destructive" | "warning";
/**
* Map an alert severity onto a Badge variant per design §2.3.
* `critical` → destructive (chart-4); `warning` → warning (chart-3).
*/
function severityVariant(severity: string): SeverityVariant {
return severity === "critical" ? "destructive" : "warning";
} }
export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) { export default function BackupAlertsTable({ alerts, onAcknowledge }: Props) {
return ( return (
<TableContainer component={Paper}> <div className="overflow-hidden rounded-lg border border-border">
<Table> <Table aria-label="Backup alerts">
<TableHead> <TableHeader>
<TableRow> <TableRow className="bg-card hover:bg-card">
<TableCell>Severity</TableCell> <TableHead>Severity</TableHead>
<TableCell>Type</TableCell> <TableHead>Type</TableHead>
<TableCell>Message</TableCell> <TableHead>Message</TableHead>
<TableCell>Created</TableCell> <TableHead>Created</TableHead>
<TableCell>Actions</TableCell> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
</TableHead> </TableHeader>
<TableBody> <TableBody>
{alerts.map((alert) => ( {alerts.map((alert) => (
<TableRow key={alert.id} hover> <TableRow key={alert.id}>
<TableCell> <TableCell>
<Chip <Badge variant={severityVariant(alert.severity)}>
label={alert.severity} {alert.severity}
color={alert.severity === "critical" ? "error" : "warning"} </Badge>
size="small" </TableCell>
/> <TableCell>{alert.alert_type}</TableCell>
</TableCell> <TableCell>{alert.message}</TableCell>
<TableCell>{alert.alert_type}</TableCell> <TableCell>{formatTimestamp(alert.created_at)}</TableCell>
<TableCell>{alert.message}</TableCell> <TableCell>
<TableCell>{formatTimestamp(alert.created_at)}</TableCell> {!alert.acknowledged && (
<TableCell> <Button
{!alert.acknowledged && ( size="sm"
<Button variant="outline"
size="small" onClick={() => onAcknowledge(alert.id)}
variant="outlined" >
onClick={() => onAcknowledge(alert.id)} Acknowledge
> </Button>
Acknowledge )}
</Button> </TableCell>
)} </TableRow>
</TableCell> ))}
</TableRow> </TableBody>
))} </Table>
</TableBody> </div>
</Table> );
</TableContainer>
);
} }
@@ -1,52 +1,49 @@
import { Card, CardContent, Typography, Box, Chip } from "@mui/material"; import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useBackupDashboard } from "../hooks/useBackups"; import { useBackupDashboard } from "../hooks/useBackups";
export default function BackupDashboardWidget() { export default function BackupDashboardWidget() {
const { data, isLoading } = useBackupDashboard(); const { data, isLoading } = useBackupDashboard();
if (isLoading || !data) { return (
return ( <Card>
<Card> <CardHeader>
<CardContent> <CardTitle>Backups</CardTitle>
<Typography variant="h6">Backups</Typography> </CardHeader>
<Typography color="text.secondary">Loading...</Typography> <CardContent>
</CardContent> {isLoading || !data ? (
</Card> <p className="text-sm text-muted-foreground">Loading</p>
); ) : (
} <div className="flex flex-row flex-wrap gap-6">
<div>
return ( <div className="text-2xl font-semibold">{data.total_jobs}</div>
<Card> <div className="text-xs text-muted-foreground">Jobs</div>
<CardContent> </div>
<Typography variant="h6" gutterBottom>Backups</Typography> <div>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}> <div className="text-2xl font-semibold">
<Box> {data.success_rate_24h}%
<Typography variant="h4">{data.total_jobs}</Typography> </div>
<Typography variant="body2" color="text.secondary">Jobs</Typography> <div className="text-xs text-muted-foreground">24h Success</div>
</Box> </div>
<Box> <div>
<Typography variant="h4">{data.success_rate_24h}%</Typography> <div className="text-2xl font-semibold">
<Typography variant="body2" color="text.secondary">24h Success</Typography> {data.active_alerts > 0 ? (
</Box> <Badge variant="destructive">{data.active_alerts}</Badge>
<Box> ) : (
<Typography variant="h4"> 0
{data.active_alerts > 0 ? ( )}
<Chip label={data.active_alerts} color="error" size="small" /> </div>
) : ( <div className="text-xs text-muted-foreground">Alerts</div>
0 </div>
)} {data.last_failed_at && (
</Typography> <div className="self-center text-xs text-destructive">
<Typography variant="body2" color="text.secondary">Alerts</Typography> Last failed:{" "}
</Box> {new Date(data.last_failed_at * 1000).toLocaleString()}
{data.last_failed_at && ( </div>
<Box> )}
<Typography variant="body2" color="error"> </div>
Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()} )}
</Typography> </CardContent>
</Box> </Card>
)} );
</Box>
</CardContent>
</Card>
);
} }
+76 -70
View File
@@ -1,84 +1,90 @@
import { Badge } from "@/components/ui/badge";
import { import {
Chip, Table,
Paper, TableBody,
Table, TableCell,
TableBody, TableHead,
TableCell, TableHeader,
TableContainer, TableRow,
TableHead, } from "@/components/ui/table";
TableRow,
} from "@mui/material";
import type { BackupJob, BackupRun } from "../types/backups"; import type { BackupJob, BackupRun } from "../types/backups";
interface Props { interface Props {
jobs: BackupJob[]; jobs: BackupJob[];
latestRuns: Map<string, BackupRun>; latestRuns: Map<string, BackupRun>;
} }
function formatInterval(seconds: number | null): string { function formatInterval(seconds: number | null): string {
if (!seconds) return "N/A"; if (!seconds) return "N/A";
if (seconds < 60) return `${seconds}s`; if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`; if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`; return `${Math.floor(seconds / 86400)}d`;
} }
function formatTimestamp(ts: number | null): string { function formatTimestamp(ts: number | null): string {
if (!ts) return "Never"; if (!ts) return "Never";
return new Date(ts * 1000).toLocaleString(); return new Date(ts * 1000).toLocaleString();
}
type StatusVariant = "success" | "destructive" | "warning" | "secondary";
/**
* Map a job/run status onto a Badge variant per design §2.3:
* `success` → success (chart-2); `failure` → destructive (chart-4);
* `in_progress` → warning (chart-3); unknown → secondary (neutral accent).
*/
function statusVariant(status: string): StatusVariant {
if (status === "success") return "success";
if (status === "failure") return "destructive";
if (status === "in_progress") return "warning";
return "secondary";
} }
export default function BackupJobsTable({ jobs, latestRuns }: Props) { export default function BackupJobsTable({ jobs, latestRuns }: Props) {
return ( return (
<TableContainer component={Paper}> <div className="overflow-hidden rounded-lg border border-border">
<Table> <Table aria-label="Backup jobs">
<TableHead> <TableHeader>
<TableRow> <TableRow className="bg-card hover:bg-card">
<TableCell>Name</TableCell> <TableHead>Name</TableHead>
<TableCell>Source</TableCell> <TableHead>Source</TableHead>
<TableCell>Target</TableCell> <TableHead>Target</TableHead>
<TableCell>Schedule</TableCell> <TableHead>Schedule</TableHead>
<TableCell>Last Status</TableCell> <TableHead>Last Status</TableHead>
<TableCell>Last Run</TableCell> <TableHead>Last Run</TableHead>
<TableCell>Next Expected</TableCell> <TableHead>Next Expected</TableHead>
</TableRow> </TableRow>
</TableHead> </TableHeader>
<TableBody> <TableBody>
{jobs.map((job) => { {jobs.map((job) => {
const run = latestRuns.get(job.id); const run = latestRuns.get(job.id);
const status = run?.status ?? "unknown"; const status = run?.status ?? "unknown";
const nextExpected = run && job.schedule_interval_seconds const nextExpected =
? run.started_at + job.schedule_interval_seconds run && job.schedule_interval_seconds
: null; ? run.started_at + job.schedule_interval_seconds
: null;
return (
<TableRow key={job.id} hover> return (
<TableCell>{job.name}</TableCell> <TableRow key={job.id}>
<TableCell>{job.source ?? "—"}</TableCell> <TableCell>{job.name}</TableCell>
<TableCell>{job.target ?? "—"}</TableCell> <TableCell>{job.source ?? "—"}</TableCell>
<TableCell>{formatInterval(job.schedule_interval_seconds)}</TableCell> <TableCell>{job.target ?? "—"}</TableCell>
<TableCell> <TableCell>
<Chip {formatInterval(job.schedule_interval_seconds)}
label={status} </TableCell>
color={ <TableCell>
status === "success" <Badge variant={statusVariant(status)}>{status}</Badge>
? "success" </TableCell>
: status === "failure" <TableCell>
? "error" {formatTimestamp(run?.started_at ?? null)}
: status === "in_progress" </TableCell>
? "warning" <TableCell>{formatTimestamp(nextExpected)}</TableCell>
: "default" </TableRow>
} );
size="small" })}
/> </TableBody>
</TableCell> </Table>
<TableCell>{formatTimestamp(run?.started_at ?? null)}</TableCell> </div>
<TableCell>{formatTimestamp(nextExpected)}</TableCell> );
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
} }
+93 -86
View File
@@ -1,103 +1,110 @@
import {
Chip,
FormControl,
InputLabel,
MenuItem,
Paper,
Select,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { Badge } from "@/components/ui/badge";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { BackupRun } from "../types/backups"; import type { BackupRun } from "../types/backups";
interface Props { interface Props {
runs: BackupRun[]; runs: BackupRun[];
} }
function formatBytes(bytes: number | null): string { function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "—"; if (bytes === null || bytes === undefined) return "—";
if (bytes < 1024) return `${bytes} B`; if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
} }
function formatDuration(ms: number | null): string { function formatDuration(ms: number | null): string {
if (ms === null || ms === undefined) return "—"; if (ms === null || ms === undefined) return "—";
if (ms < 1000) return `${ms}ms`; if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`; if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
return `${(ms / 3600_000).toFixed(1)}h`; return `${(ms / 3600_000).toFixed(1)}h`;
} }
function formatTimestamp(ts: number): string { function formatTimestamp(ts: number): string {
return new Date(ts * 1000).toLocaleString(); return new Date(ts * 1000).toLocaleString();
}
type StatusVariant = "success" | "destructive" | "warning";
/**
* Map a run status onto a Badge variant per design §2.3:
* `success` → success (chart-2); `failure` → destructive (chart-4);
* `in_progress` → warning (chart-3).
*/
function statusVariant(status: string): StatusVariant {
if (status === "success") return "success";
if (status === "failure") return "destructive";
return "warning";
} }
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 filteredRuns = statusFilter === "all" const filteredRuns =
? runs statusFilter === "all"
: runs.filter((r) => r.status === statusFilter); ? runs
: runs.filter((r) => r.status === statusFilter);
return (
<> return (
<FormControl sx={{ minWidth: 120, mb: 2 }}> <div className="space-y-3">
<InputLabel>Status</InputLabel> <Select value={statusFilter} onValueChange={setStatusFilter}>
<Select <SelectTrigger className="w-[160px]" aria-label="Status filter">
value={statusFilter} <SelectValue placeholder="Status" />
label="Status" </SelectTrigger>
onChange={(e) => setStatusFilter(e.target.value)} <SelectContent>
> <SelectItem value="all">All</SelectItem>
<MenuItem value="all">All</MenuItem> <SelectItem value="success">Success</SelectItem>
<MenuItem value="success">Success</MenuItem> <SelectItem value="failure">Failure</SelectItem>
<MenuItem value="failure">Failure</MenuItem> <SelectItem value="in_progress">In Progress</SelectItem>
<MenuItem value="in_progress">In Progress</MenuItem> </SelectContent>
</Select> </Select>
</FormControl>
<div className="overflow-hidden rounded-lg border border-border">
<TableContainer component={Paper}> <Table aria-label="Backup runs">
<Table> <TableHeader>
<TableHead> <TableRow className="bg-card hover:bg-card">
<TableRow> <TableHead>Job</TableHead>
<TableCell>Job</TableCell> <TableHead>Status</TableHead>
<TableCell>Status</TableCell> <TableHead>Duration</TableHead>
<TableCell>Duration</TableCell> <TableHead>Size</TableHead>
<TableCell>Size</TableCell> <TableHead>Started</TableHead>
<TableCell>Started</TableCell> </TableRow>
</TableRow> </TableHeader>
</TableHead> <TableBody>
<TableBody> {filteredRuns.map((run) => (
{filteredRuns.map((run) => ( <TableRow key={run.id}>
<TableRow key={run.id} hover> <TableCell>{run.job_id}</TableCell>
<TableCell>{run.job_id}</TableCell> <TableCell>
<TableCell> <Badge variant={statusVariant(run.status)}>
<Chip {run.status}
label={run.status} </Badge>
color={ </TableCell>
run.status === "success" <TableCell>{formatDuration(run.duration_ms)}</TableCell>
? "success" <TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
: run.status === "failure" <TableCell>{formatTimestamp(run.started_at)}</TableCell>
? "error" </TableRow>
: "warning" ))}
} </TableBody>
size="small" </Table>
/> </div>
</TableCell> </div>
<TableCell>{formatDuration(run.duration_ms)}</TableCell> );
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
} }
+63 -60
View File
@@ -1,69 +1,72 @@
import { Box, Tab, Tabs, Typography } from "@mui/material";
import { useState } from "react"; import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { import {
useAcknowledgeAlert, useAcknowledgeAlert,
useBackupAlerts, useBackupAlerts,
useBackupJobs, useBackupJobs,
useBackupRuns, useBackupRuns,
} from "../hooks/useBackups"; } from "../hooks/useBackups";
import BackupAlertsTable from "./BackupAlertsTable"; import BackupAlertsTable from "./BackupAlertsTable";
import BackupJobsTable from "./BackupJobsTable"; import BackupJobsTable from "./BackupJobsTable";
import BackupRunsTable from "./BackupRunsTable"; import BackupRunsTable from "./BackupRunsTable";
export default function BackupsPage() { export default function BackupsPage() {
const [tab, setTab] = useState(0); const [tab, setTab] = useState("jobs");
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs(); const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns(); const { data: runsData, isLoading: runsLoading } = useBackupRuns();
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false); const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
const acknowledgeMutation = useAcknowledgeAlert(); undefined,
false,
// Build a map of latest runs per job );
const latestRuns = new Map(); const acknowledgeMutation = useAcknowledgeAlert();
if (runsData) {
for (const run of runsData) { // Build a map of latest runs per job
const existing = latestRuns.get(run.job_id); const latestRuns = new Map();
if (!existing || run.started_at > existing.started_at) { if (runsData) {
latestRuns.set(run.job_id, run); for (const run of runsData) {
} const existing = latestRuns.get(run.job_id);
} if (!existing || run.started_at > existing.started_at) {
} latestRuns.set(run.job_id, run);
}
return ( }
<Box sx={{ p: 3 }}> }
<Typography variant="h4" gutterBottom>Backups</Typography>
const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts";
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Jobs" /> return (
<Tab label="Runs" /> <div className="space-y-4">
<Tab label={`Alerts ${alertsData ? `(${alertsData.length})` : ""}`} /> <h1 className="text-2xl font-bold tracking-tight">Backups</h1>
</Tabs> <Tabs value={tab} onValueChange={setTab}>
<TabsList>
{tab === 0 && ( <TabsTrigger value="jobs">Jobs</TabsTrigger>
jobsLoading ? ( <TabsTrigger value="runs">Runs</TabsTrigger>
<Typography>Loading jobs...</Typography> <TabsTrigger value="alerts">{alertsLabel}</TabsTrigger>
) : ( </TabsList>
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} /> <TabsContent value="jobs">
) {jobsLoading ? (
)} <p className="text-sm text-muted-foreground">Loading jobs</p>
) : (
{tab === 1 && ( <BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
runsLoading ? ( )}
<Typography>Loading runs...</Typography> </TabsContent>
) : ( <TabsContent value="runs">
<BackupRunsTable runs={runsData ?? []} /> {runsLoading ? (
) <p className="text-sm text-muted-foreground">Loading runs</p>
)} ) : (
<BackupRunsTable runs={runsData ?? []} />
{tab === 2 && ( )}
alertsLoading ? ( </TabsContent>
<Typography>Loading alerts...</Typography> <TabsContent value="alerts">
) : ( {alertsLoading ? (
<BackupAlertsTable <p className="text-sm text-muted-foreground">Loading alerts</p>
alerts={alertsData ?? []} ) : (
onAcknowledge={(id) => acknowledgeMutation.mutate(id)} <BackupAlertsTable
/> alerts={alertsData ?? []}
) onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
)} />
</Box> )}
); </TabsContent>
</Tabs>
</div>
);
} }
+27 -19
View File
@@ -1,12 +1,17 @@
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription,
DialogHeader,
DialogTitle, DialogTitle,
Stack, } from "@/components/ui/dialog";
Typography,
} from "@mui/material";
import { DialogFooter } from "./DialogFooter"; import { DialogFooter } from "./DialogFooter";
/**
* Reusable confirmation dialog built on the shadcn Dialog family and the
* shared `DialogFooter`. Same exported props as the MUI version; Esc / overlay
* click routes to `onCancel` via `onOpenChange`.
*/
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
title, title,
@@ -25,23 +30,26 @@ export function ConfirmDialog({
busy?: boolean; busy?: boolean;
}) { }) {
return ( return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs"> <Dialog
<DialogTitle>{title}</DialogTitle> open={open}
<DialogContent> onOpenChange={(next) => {
<Stack spacing={1}> if (!next) onCancel();
<Typography variant="body2" color="text.secondary"> }}
{message} >
</Typography> <DialogContent showCloseButton={false}>
</Stack> <DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</DialogContent> </DialogContent>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</Dialog> </Dialog>
); );
} }
+41 -16
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Box, Button, DialogActions } from "@mui/material"; import { Button } from "@/components/ui/button";
interface DialogFooterProps { interface DialogFooterProps {
onCancel: () => void; onCancel: () => void;
@@ -14,6 +14,28 @@ interface DialogFooterProps {
secondaryAction?: ReactNode; secondaryAction?: ReactNode;
} }
/**
* Resolve the legacy MUI color/variant props onto a shadcn Button variant so
* the exported API stays unchanged for consuming pages (ConfirmDialog here,
* plus Dashboard/Settings/Actions in later slices).
*/
function resolveConfirmVariant(
color: DialogFooterProps["confirmColor"],
variant: DialogFooterProps["confirmVariant"],
): "default" | "outline" | "ghost" | "destructive" {
if (color === "error") return "destructive";
if (variant === "outlined") return "outline";
if (variant === "text") return "ghost";
return "default";
}
/**
* Dialog action row: cancel + optional secondary action + confirm.
*
* Renders a horizontal Button row (`flex flex-row items-center gap-2`).
* Preserves cancel/confirm/secondary-action props and the busy/disabled label
* contract (renders `confirmBusyLabel` when provided, else `confirmLabel`).
*/
export function DialogFooter({ export function DialogFooter({
onCancel, onCancel,
cancelLabel = "Cancel", cancelLabel = "Cancel",
@@ -27,20 +49,23 @@ export function DialogFooter({
secondaryAction, secondaryAction,
}: DialogFooterProps) { }: DialogFooterProps) {
return ( return (
<DialogActions sx={{ px: 3, py: 2 }}> <div className="flex flex-row flex-wrap items-center justify-end gap-2">
<Button onClick={onCancel}>{cancelLabel}</Button> <Button variant="ghost" onClick={onCancel}>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}> {cancelLabel}
{secondaryAction} </Button>
<Button {secondaryAction ? (
variant={confirmVariant} <div className="flex flex-row items-center gap-2">
color={confirmColor} {secondaryAction}
disabled={confirmDisabled} </div>
startIcon={confirmStartIcon} ) : null}
onClick={onConfirm} <Button
> variant={resolveConfirmVariant(confirmColor, confirmVariant)}
{confirmBusyLabel ?? confirmLabel} disabled={confirmDisabled}
</Button> onClick={onConfirm}
</Box> >
</DialogActions> {confirmStartIcon}
{confirmBusyLabel ?? confirmLabel}
</Button>
</div>
); );
} }
-154
View File
@@ -1,154 +0,0 @@
import {
Box,
Card,
CardContent,
Grid,
LinearProgress,
Stack,
Typography,
} from "@mui/material";
interface Props {
used: number;
available: number;
size: number;
usedPct: string;
}
function formatBytes(bytes: number): string {
if (!bytes || bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unitIdx = 0;
while (value >= 1000 && unitIdx < units.length - 1) {
value /= 1000;
unitIdx++;
}
return `${value.toFixed(1)} ${units[unitIdx]}`;
}
/**
* Dashboard card that summarizes the configured media disk.
*
* It intentionally keeps the progress bar inside the card so the capacity
* signal, raw byte values, and free-space breakdown stay visually grouped.
*/
export function DiskSpaceCard({ used, available, size, usedPct }: Props) {
const pct = Math.max(0, Math.min(100, Number.parseFloat(usedPct) || 0));
const barColor = pct < 70 ? "success" : pct < 90 ? "warning" : "error";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: { xs: 1.5, sm: 2 } }}>
<Stack spacing={1.5}>
<Box>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase" }}
>
Disk space
</Typography>
<Typography
variant="h5"
sx={{
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
}}
>
{usedPct} used
</Typography>
</Box>
<Box sx={{ width: "100%" }}>
<LinearProgress
variant="determinate"
value={pct}
color={barColor}
sx={{
height: 12,
borderRadius: 999,
bgcolor: "action.hover",
"& .MuiLinearProgress-bar": {
borderRadius: 999,
},
}}
/>
</Box>
<Grid container spacing={1.5} sx={{ alignItems: "stretch" }}>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Used
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(used)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Free
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(available)}
</Typography>
</Box>
</Grid>
<Grid size={{ xs: 12, sm: 4 }}>
<Box
sx={{
p: 1.5,
borderRadius: 2,
bgcolor: "action.hover",
height: "100%",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
textAlign: "center",
gap: 0.25,
}}
>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{formatBytes(size)}
</Typography>
</Box>
</Grid>
</Grid>
</Stack>
</CardContent>
</Card>
);
}
+17 -12
View File
@@ -1,32 +1,37 @@
import EditOutlinedIcon from "@mui/icons-material/EditOutlined"; import { Pencil } from "lucide-react";
import { IconButton } from "@mui/material"; import { Button } from "@/components/ui/button";
interface HoverEditButtonProps { interface HoverEditButtonProps {
onClick: () => void; onClick: () => void;
label?: string; label?: string;
} }
/**
* Hover-to-reveal edit affordance.
*
* Keeps the `rail-edit` class plus the opacity-0 base + transition so the
* existing hover-reveal rules in consuming pages (Actions, Settings) still
* target it (`&:hover .rail-edit { opacity: 1 }`) until those pages migrate.
* MUI IconButton + EditOutlined → shadcn `Button variant="ghost" size="icon-sm"`
* + lucide `Pencil`. Same exported props/display name.
*/
export function HoverEditButton({ export function HoverEditButton({
onClick, onClick,
label = "Edit", label = "Edit",
}: HoverEditButtonProps) { }: HoverEditButtonProps) {
return ( return (
<IconButton <Button
className="rail-edit" variant="ghost"
size="icon-sm"
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
aria-label={label} aria-label={label}
size="small"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onClick(); onClick();
}} }}
sx={{
opacity: 0,
transition: "opacity 120ms ease",
color: "text.secondary",
}}
> >
<EditOutlinedIcon fontSize="inherit" /> <Pencil />
</IconButton> </Button>
); );
} }
+30 -29
View File
@@ -1,56 +1,57 @@
import { Card, CardContent, Grid, Stack, Typography } from "@mui/material"; import { Card, CardContent } from "@/components/ui/card";
import type { LibraryCount } from "../types"; import type { LibraryCount } from "../types";
interface Props { interface Props {
libraries: LibraryCount[]; libraries: LibraryCount[];
} }
/**
* Two-column overview of movie and TV libraries on a responsive CSS grid
* (`grid grid-cols-1 md:grid-cols-2 gap-4`). Same exported props as the MUI
* version; the per-library counts render verbatim.
*/
export function LibraryOverview({ libraries }: Props) { export function LibraryOverview({ libraries }: Props) {
const movieLibs = libraries.filter((l) => l.type === "movies"); const movieLibs = libraries.filter((l) => l.type === "movies");
const tvLibs = libraries.filter((l) => l.type === "tvshows"); const tvLibs = libraries.filter((l) => l.type === "tvshows");
return ( return (
<Grid container spacing={2}> <div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<Grid size={{ xs: 12, md: 6 }}> <div className="flex flex-col gap-4">
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}> <h4 className="text-sm font-semibold text-muted-foreground">
Movie libraries Movie libraries
</Typography> </h4>
<Stack spacing={1.5}> <div className="flex flex-col gap-4">
{movieLibs.map((lib) => ( {movieLibs.map((lib) => (
<Card key={lib.library} variant="outlined"> <Card key={lib.library}>
<CardContent> <CardContent className="flex flex-col gap-1">
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <span className="text-base font-semibold">{lib.library}</span>
{lib.library} <span className="text-sm text-muted-foreground">
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Movies:{" "} Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()} {lib.movies.toLocaleString()}
</Typography> </span>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</Stack> </div>
</Grid> </div>
<Grid size={{ xs: 12, md: 6 }}> <div className="flex flex-col gap-4">
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}> <h4 className="text-sm font-semibold text-muted-foreground">
TV libraries TV libraries
</Typography> </h4>
<Stack spacing={1.5}> <div className="flex flex-col gap-4">
{tvLibs.map((lib) => ( {tvLibs.map((lib) => (
<Card key={lib.library} variant="outlined"> <Card key={lib.library}>
<CardContent> <CardContent className="flex flex-col gap-1">
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}> <span className="text-base font-semibold">{lib.library}</span>
{lib.library} <span className="text-sm text-muted-foreground">
</Typography>
<Typography variant="body2" color="text.secondary">
Total: {lib.total.toLocaleString()} | Series:{" "} Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()} {lib.series.toLocaleString()}
</Typography> </span>
</CardContent> </CardContent>
</Card> </Card>
))} ))}
</Stack> </div>
</Grid> </div>
</Grid> </div>
); );
} }
+15 -35
View File
@@ -1,4 +1,4 @@
import { Card, CardContent, Typography } from "@mui/material"; import { Card, CardContent } from "@/components/ui/card";
interface Props { interface Props {
label: string; label: string;
@@ -6,44 +6,24 @@ interface Props {
subtext?: string; subtext?: string;
} }
/**
* Compact metric tile: label / value / optional subtext on the comfortable
* density ramp (label `text-sm`, value `text-lg font-semibold`, subtext
* `text-xs text-muted-foreground`). Same exported props as the MUI version.
*/
export function MetricCard({ label, value, subtext }: Props) { export function MetricCard({ label, value, subtext }: Props) {
return ( return (
<Card variant="outlined" sx={{ height: "100%" }}> <Card className="h-full">
<CardContent <CardContent className="flex h-full flex-col gap-1.5">
sx={{ <span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
p: { xs: 1.5, sm: 2 },
display: "flex",
flexDirection: "column",
gap: 0.5,
height: "100%",
}}
>
<Typography
variant="caption"
color="text.secondary"
sx={{ textTransform: "uppercase", lineHeight: 1.2 }}
>
{label} {label}
</Typography> </span>
<Typography <span className="text-lg font-semibold leading-tight">{value}</span>
variant="h5" {subtext ? (
sx={{ <span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
fontWeight: 700,
fontSize: { xs: "1.05rem", sm: "1.5rem" },
lineHeight: 1.15,
}}
>
{value}
</Typography>
{subtext && (
<Typography
variant="caption"
color="text.secondary"
sx={{ whiteSpace: "pre-line", display: "block", lineHeight: 1.35 }}
>
{subtext} {subtext}
</Typography> </span>
)} ) : null}
</CardContent> </CardContent>
</Card> </Card>
); );
+20 -27
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Box, Card, CardContent, Stack, Typography } from "@mui/material"; import { Card, CardContent } from "@/components/ui/card";
interface SectionCardProps { interface SectionCardProps {
title: string; title: string;
@@ -8,6 +8,13 @@ interface SectionCardProps {
children: ReactNode; children: ReactNode;
} }
/**
* Titled section surface built on the shadcn Card family.
*
* Comfortable density: `gap-4` between the header row and the body. Exports
* the same props/display name as the prior MUI implementation so every
* consuming page compiles unchanged.
*/
export function SectionCard({ export function SectionCard({
title, title,
description, description,
@@ -15,32 +22,18 @@ export function SectionCard({
children, children,
}: SectionCardProps) { }: SectionCardProps) {
return ( return (
<Card variant="outlined"> <Card className="gap-4">
<CardContent sx={{ p: 1.5 }}> <CardContent className="flex flex-col gap-4">
<Stack spacing={1.25}> <div className="flex flex-wrap items-center justify-between gap-2">
<Box <div className="min-w-0">
sx={{ <h3 className="text-base font-semibold">{title}</h3>
display: "flex", {description ? (
alignItems: "center", <p className="text-sm text-muted-foreground">{description}</p>
justifyContent: "space-between", ) : null}
gap: 1, </div>
flexWrap: "wrap", {action}
}} </div>
> {children}
<Box sx={{ minWidth: 0 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{title}
</Typography>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
) : null}
</Box>
{action}
</Box>
{children}
</Stack>
</CardContent> </CardContent>
</Card> </Card>
); );
+19 -46
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Box, Card, CardContent, Typography } from "@mui/material"; import { Card } from "@/components/ui/card";
interface SelectionRailCardProps { interface SelectionRailCardProps {
title: string; title: string;
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
children: ReactNode; children: ReactNode;
footer?: ReactNode; footer?: ReactNode;
minHeight?: number; minHeight?: number;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object; contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
bodySx?: object; bodySx?: object;
} }
/**
* Selection-rail surface: titled header, scrollable body, optional footer.
*
* Preserves the exported props (`minHeight`, `footer`, and the legacy `*Sx`
* no-op passthroughs) so consuming pages (Actions, Settings) compile
* unchanged. The scrollable body and footer contract are preserved.
*/
export function SelectionRailCard({ export function SelectionRailCard({
title, title,
description, description,
children, children,
footer, footer,
minHeight = 420, minHeight = 420,
contentSx,
bodySx,
}: SelectionRailCardProps) { }: SelectionRailCardProps) {
return ( return (
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}> <Card className="h-fit self-start py-0" style={{ minHeight }}>
<CardContent <div className="flex flex-col" style={{ minHeight }}>
sx={{ <div className="border-b bg-muted/50 px-4 py-3">
p: 0, <h4 className="text-sm font-semibold tracking-wide">{title}</h4>
display: "flex",
flexDirection: "column",
minHeight,
...contentSx,
}}
>
<Box
sx={{
px: 1.5,
py: 1.25,
borderBottom: 1,
borderColor: "divider",
bgcolor: "action.hover",
}}
>
<Typography
variant="subtitle2"
sx={{ fontWeight: 800, letterSpacing: 0.2 }}
>
{title}
</Typography>
{description ? ( {description ? (
<Typography variant="body2" color="text.secondary"> <p className="text-xs text-muted-foreground">{description}</p>
{description}
</Typography>
) : null} ) : null}
</Box> </div>
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box> <div className="flex-1 overflow-y-auto">{children}</div>
{footer ? ( {footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
<Box </div>
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
{footer}
</Box>
) : null}
</CardContent>
</Card> </Card>
); );
} }
+64 -135
View File
@@ -1,15 +1,13 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { import {
Button,
Chip,
Paper,
Table, Table,
TableBody, TableBody,
TableCell, TableCell,
TableContainer,
TableHead, TableHead,
TableHeader,
TableRow, TableRow,
Typography, } from "@/components/ui/table";
} from "@mui/material";
import type { NowPlayingSession } from "../types"; import type { NowPlayingSession } from "../types";
interface Props { interface Props {
@@ -19,6 +17,23 @@ interface Props {
onSelectSession?: (session: NowPlayingSession) => void; onSelectSession?: (session: NowPlayingSession) => void;
} }
type SessionStateVariant = "success" | "warning" | "secondary";
/**
* Map a session state onto a Badge variant per design §2.3.
*
* `playing` (active/healthy) → `success` (chart-2), `paused` → `warning`
* (chart-3), anything else (idle/unknown) → `secondary` (neutral accent).
*/
function sessionStateVariant(state: string): SessionStateVariant {
const normalized = String(state || "")
.trim()
.toLowerCase();
if (normalized === "playing") return "success";
if (normalized === "paused") return "warning";
return "secondary";
}
function formatStateLabel(state: string): string { function formatStateLabel(state: string): string {
const normalized = String(state || "") const normalized = String(state || "")
.trim() .trim()
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
const userFallback = selectedUserLabel || "Unknown user"; const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) { if (!sessions.length) {
return ( return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
} }
return ( return (
<TableContainer <div className="max-h-[280px] overflow-auto rounded-lg border border-border">
component={Paper} <Table aria-label="Session activity details" className="min-w-[880px]">
variant="outlined" <TableHeader>
sx={{ <TableRow className="bg-card hover:bg-card">
maxHeight: 280, <TableHead className="min-w-[160px]">User</TableHead>
borderColor: "divider", <TableHead className="w-[82px]">State</TableHead>
borderRadius: 1, <TableHead className="min-w-[140px]">Title / Type</TableHead>
overflowX: "auto", <TableHead className="min-w-[140px]">Device</TableHead>
}} <TableHead className="w-[118px]">Transcoding</TableHead>
>
<Table
size="small"
stickyHeader
aria-label="Session activity details"
sx={{ minWidth: 880 }}
>
<TableHead>
<TableRow>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 160,
}}
>
User
</TableCell>
<TableCell
sx={{ fontWeight: 700, bgcolor: "background.default", width: 82 }}
>
State
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Title / Type
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
minWidth: 140,
}}
>
Device
</TableCell>
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 118,
}}
>
Transcoding
</TableCell>
{onSelectSession ? ( {onSelectSession ? (
<TableCell <TableHead className="w-[150px]">Action</TableHead>
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
) : null} ) : null}
</TableRow> </TableRow>
</TableHead> </TableHeader>
<TableBody> <TableBody>
<TableRow> <TableRow className="bg-card hover:bg-card">
<TableCell <TableCell
colSpan={onSelectSession ? 6 : 5} colSpan={onSelectSession ? 6 : 5}
sx={{ py: 0.75, bgcolor: "background.paper" }} className="bg-card py-3"
> >
<Typography variant="caption" color="text.secondary"> <span className="text-xs text-muted-foreground">
{buildStatusSummary(sessions)} {buildStatusSummary(sessions)}
</Typography> </span>
</TableCell> </TableCell>
</TableRow> </TableRow>
{sessions.map((session) => { {sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state); const sessionLabel = formatStateLabel(session.state);
return ( return (
<TableRow <TableRow
key={session.session_id} key={session.session_id}
hover className={onSelectSession ? "cursor-pointer" : undefined}
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
onClick={ onClick={
onSelectSession ? () => onSelectSession(session) : undefined onSelectSession ? () => onSelectSession(session) : undefined
} }
> >
<TableCell sx={{ py: 0.75, minWidth: 160 }}> <TableCell className="min-w-[160px]">
<Typography <div
variant="body2" className="truncate text-sm"
noWrap
title={session.user || userFallback} title={session.user || userFallback}
> >
{session.user || userFallback} {session.user || userFallback}
</Typography> </div>
<Typography <div
variant="caption" className="truncate text-xs text-muted-foreground"
color="text.secondary"
noWrap
title={session.session_id} title={session.session_id}
> >
{session.session_id} {session.session_id}
</Typography> </div>
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}> <TableCell className="whitespace-nowrap">
<Chip <Badge variant={sessionStateVariant(session.state)}>
size="small" {sessionLabel}
label={sessionLabel} </Badge>
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}> <TableCell className="min-w-[140px]">
<Typography <div className="truncate text-sm" title={session.title || ""}>
variant="body2"
noWrap
title={session.title || ""}
>
{session.title || "(idle)"} {session.title || "(idle)"}
</Typography> </div>
<Typography variant="caption" color="text.secondary" noWrap> <div className="truncate text-xs text-muted-foreground">
{session.type || "—"} {session.type || "—"}
</Typography> </div>
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}> <TableCell className="min-w-[140px]">
<Typography variant="body2" noWrap> <div className="truncate text-sm">
{session.device || "Unknown device"} {session.device || "Unknown device"}
</Typography> </div>
</TableCell> </TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}> <TableCell className="whitespace-nowrap">
<Typography variant="body2" noWrap> <span className="text-sm">
{session.transcoding === "yes" {session.transcoding === "yes"
? session.transcoding_type ? session.transcoding_type
? `yes (${session.transcoding_type})` ? `yes (${session.transcoding_type})`
: "yes" : "yes"
: "no"} : "no"}
</Typography> </span>
</TableCell> </TableCell>
{onSelectSession ? ( {onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}> <TableCell className="whitespace-nowrap">
<Button <Button
size="small" variant="outline"
variant="outlined" size="sm"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
onSelectSession(session); onSelectSession(session);
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
})} })}
</TableBody> </TableBody>
</Table> </Table>
</TableContainer> </div>
); );
} }
+18 -17
View File
@@ -1,38 +1,39 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { Box, Card, CardContent, Tabs } from "@mui/material"; import { Card } from "@/components/ui/card";
import { Tabs, TabsList } from "@/components/ui/tabs";
interface TabbedCardProps { interface TabbedCardProps {
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
tabs: ReactElement[]; tabs: ReactElement[];
children: ReactNode; children: ReactNode;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object; contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
tabsSx?: object; tabsSx?: object;
} }
/**
* Card surface with a line-style tab bar on top and a content area below.
*
* `value`/`onChange` stay string-typed (controlled) and the `tabs` prop stays
* `ReactElement[]`, so consuming pages compile unchanged. The page owns the
* rendered content from `children` keyed off `value`, exactly as before.
*/
export function TabbedCard({ export function TabbedCard({
value, value,
onChange, onChange,
tabs, tabs,
children, children,
contentSx,
tabsSx,
}: TabbedCardProps) { }: TabbedCardProps) {
return ( return (
<Card variant="outlined"> <Card className="gap-0 py-0">
<CardContent sx={{ p: 0 }}> <Tabs value={value} onValueChange={(next) => onChange(String(next))}>
<Tabs <div className="border-b px-2">
value={value} <TabsList variant="line">{tabs}</TabsList>
onChange={(_, next) => onChange(String(next))} </div>
variant="scrollable" <div className="p-4">{children}</div>
scrollButtons="auto" </Tabs>
allowScrollButtonsMobile
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
>
{tabs}
</Tabs>
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
</CardContent>
</Card> </Card>
); );
} }
@@ -0,0 +1,478 @@
import { useMemo, useState } from "react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ChevronDown, ChevronUp, Pencil, Plus, Trash2 } from "lucide-react";
import {
useDeleteWidgetInstance,
useSaveWidgetInstance,
useWidgetInstances,
} from "../hooks/useWidgets";
import { useServiceInstances } from "../hooks/useServices";
import { useTasks } from "../hooks/useSettings";
import type { WidgetInstance, WidgetInstanceInput } from "../types";
import {
BUILTIN_WIDGETS,
SERVICE_REGISTRY,
type ServiceWidgetBinding,
} from "../integrations/registry";
interface Props {
open: boolean;
onClose: () => void;
}
interface Draft {
id?: string;
serviceId: string | null;
widgetKind: string;
title: string;
config: Record<string, unknown>;
enabled: boolean;
sortOrder: number;
}
function Field({
label,
htmlFor,
helper,
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>
);
}
function bindingLabel(serviceId: string | null, widgetKind: string): string {
if (serviceId === null)
return BUILTIN_WIDGETS[widgetKind]?.name ?? widgetKind;
return widgetKind;
}
function WidgetConfigEditor({
binding,
isTaskOutput,
config,
onChange,
tasks,
}: {
binding: ServiceWidgetBinding | undefined;
isTaskOutput: boolean;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
tasks: { id: string; name: string; enabled: boolean }[];
}) {
// SSH task output gets a dedicated task picker; everything else gets a
// generic text field per top-level schema property.
if (isTaskOutput) {
return (
<Field label="Saved task" htmlFor="widget-task-id">
<Select
value={String(config.task_id ?? "")}
onValueChange={(v) => onChange({ ...config, task_id: v })}
>
<SelectTrigger id="widget-task-id">
<SelectValue placeholder="Select a task" />
</SelectTrigger>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
const properties = binding
? Object.entries(
(
binding.configSchema as
| { properties?: Record<string, unknown> }
| undefined
)?.properties ?? {},
)
: [];
if (properties.length === 0) return null;
return (
<div className="flex flex-col gap-3">
{properties.map(([key, schema]) => {
const isNumber =
(schema as { type?: string }).type === "integer" ||
(schema as { type?: string }).type === "number";
return (
<Field
key={key}
label={key}
htmlFor={`widget-cfg-${key}`}
helper={(schema as { description?: string }).description}
>
<Input
id={`widget-cfg-${key}`}
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>
);
})}
</div>
);
}
export function WidgetConfigDialog({ open, onClose }: Props) {
const { data: instances = [] } = useWidgetInstances();
const { data: services = [] } = useServiceInstances();
const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance();
const [draft, setDraft] = useState<Draft | null>(null);
const sortedInstances = useMemo(
() =>
[...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
),
[instances],
);
function startAddBuiltIn(kind: string) {
const binding = BUILTIN_WIDGETS[kind];
setDraft({
serviceId: null,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startAddService(serviceId: string, kind: string) {
const binding = SERVICE_REGISTRY[
services.find((s) => s.id === serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === kind);
setDraft({
serviceId,
widgetKind: kind,
title: binding?.name ?? kind,
config: { ...(binding?.defaultConfig ?? {}) },
enabled: true,
sortOrder: 0,
});
}
function startEdit(instance: WidgetInstance) {
setDraft({
id: instance.id,
serviceId: instance.service_id,
widgetKind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: instance.enabled,
sortOrder: instance.sort_order,
});
}
function reset() {
setDraft(null);
}
async function saveDraft() {
if (!draft) return;
const input: WidgetInstanceInput = {
id: draft.id ?? null,
service_id: draft.serviceId,
widget_kind: draft.widgetKind,
title: draft.title,
config: draft.config,
enabled: draft.enabled,
sort_order: draft.sortOrder,
};
await saveWidget.mutateAsync(input);
reset();
}
async function toggleEnabled(instance: WidgetInstance) {
await saveWidget.mutateAsync({
id: instance.id,
service_id: instance.service_id,
widget_kind: instance.widget_kind,
title: instance.title,
config: instance.config,
enabled: !instance.enabled,
sort_order: instance.sort_order,
});
}
async function moveInstance(index: number, direction: -1 | 1) {
const targetIndex = index + direction;
if (targetIndex < 0 || targetIndex >= sortedInstances.length) return;
const a = sortedInstances[index];
const b = sortedInstances[targetIndex];
await Promise.all([
saveWidget.mutateAsync({ ...a, sort_order: b.sort_order }),
saveWidget.mutateAsync({ ...b, sort_order: a.sort_order }),
]);
}
async function removeInstance(instance: WidgetInstance) {
await deleteWidget.mutateAsync(instance.id);
}
function handleClose(next: boolean) {
if (!next) {
reset();
onClose();
}
}
const draftBinding = draft
? draft.serviceId
? SERVICE_REGISTRY[
services.find((s) => s.id === draft.serviceId)?.service_type ?? ""
]?.widgets.find((w) => w.kind === draft.widgetKind)
: BUILTIN_WIDGETS[draft.widgetKind]
: undefined;
const isTaskOutput =
draft?.serviceId !== null &&
services.find((s) => s.id === draft?.serviceId)?.service_type ===
"ssh_tasks";
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{draft
? draft.id
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
</DialogHeader>
{draft ? (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<Field label="Title" htmlFor="widget-title">
<Input
id="widget-title"
value={draft.title}
onChange={(e) =>
setDraft({ ...draft, title: e.target.value })
}
/>
</Field>
<Field label="Sort order" htmlFor="widget-sort-order">
<Input
id="widget-sort-order"
type="number"
value={String(draft.sortOrder)}
onChange={(e) =>
setDraft({
...draft,
sortOrder:
e.target.value === "" ? 0 : Number(e.target.value),
})
}
/>
</Field>
</div>
<div className="flex items-center gap-2">
<Switch
id="widget-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
setDraft({ ...draft, enabled: checked })
}
/>
<Label htmlFor="widget-enabled">Enabled</Label>
</div>
<WidgetConfigEditor
binding={draftBinding}
isTaskOutput={!!isTaskOutput}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
tasks={tasks}
/>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={reset}>
Back
</Button>
<Button onClick={saveDraft} disabled={saveWidget.isPending}>
Save widget
</Button>
</div>
</div>
) : (
<div className="flex flex-col gap-4">
{sortedInstances.length === 0 ? (
<Alert>
<AlertDescription>
No widgets yet. Add one below.
</AlertDescription>
</Alert>
) : (
<div className="flex flex-col gap-2">
{sortedInstances.map((instance, index) => {
const serviceName = instance.service_id
? services.find((s) => s.id === instance.service_id)?.name
: "Built-in";
return (
<div
key={instance.id}
className="flex items-center gap-2 rounded border p-2"
>
<div className="flex flex-1 flex-col gap-1">
<div className="flex items-center gap-2">
<span className="font-medium">{instance.title}</span>
<Badge variant="outline">
{bindingLabel(
instance.service_id,
instance.widget_kind,
)}
</Badge>
{serviceName ? (
<span className="text-xs text-muted-foreground">
{serviceName}
</span>
) : null}
{!instance.enabled ? (
<Badge variant="secondary">disabled</Badge>
) : null}
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === 0}
onClick={() => moveInstance(index, -1)}
>
<ChevronUp className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
disabled={index === sortedInstances.length - 1}
onClick={() => moveInstance(index, 1)}
>
<ChevronDown className="h-4 w-4" />
</Button>
<Switch
checked={instance.enabled}
onCheckedChange={() => toggleEnabled(instance)}
aria-label={`Toggle ${instance.title}`}
/>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => startEdit(instance)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive"
onClick={() => removeInstance(instance)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
</div>
)}
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Add widget</p>
<div className="flex flex-wrap gap-2">
{Object.values(BUILTIN_WIDGETS).map((b) => (
<Button
key={b.kind}
variant="outline"
size="sm"
onClick={() => startAddBuiltIn(b.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{b.name}
</Button>
))}
{services
.filter((s) => s.enabled)
.flatMap((s) =>
(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map(
(w) => (
<Button
key={`${s.id}:${w.kind}`}
variant="outline"
size="sm"
onClick={() => startAddService(s.id, w.kind)}
>
<Plus className="mr-1 h-3 w-3" />
{w.name} · {s.name}
</Button>
),
),
)}
</div>
<p className="text-xs text-muted-foreground">
Configure services on their service pages to unlock more
widgets.
</p>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,36 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { useServiceInstances } from "../hooks/useServices";
import { resolveWidget } from "../integrations/registry";
import type { WidgetInstance } from "../types";
import { SectionCard } from "./SectionCard";
interface Props {
widget: WidgetInstance;
}
export function WidgetInstanceCard({ widget }: Props) {
const { data: services = [] } = useServiceInstances();
const resolved = resolveWidget(widget, services);
if (!resolved) {
const label = widget.service_id
? `Unknown widget: ${widget.widget_kind} (service-bound)`
: `Unknown widget: ${widget.widget_kind} (built-in)`;
return (
<SectionCard title={widget.title}>
<Alert>
<AlertDescription>{label}</AlertDescription>
</Alert>
</SectionCard>
);
}
const Component = resolved.component;
return (
<Component
widget={widget}
refreshIntervalMs={resolved.refreshIntervalMs}
description={resolved.description}
/>
);
}
@@ -0,0 +1,63 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import BackupAlertsTable from "../BackupAlertsTable";
import type { BackupAlert } from "../../types/backups";
function alert(overrides: Partial<BackupAlert> = {}): BackupAlert {
return {
id: "a1",
job_id: "job-1",
run_id: null,
alert_type: "failed_status",
severity: "warning",
message: "Run failed",
acknowledged: false,
resolved_at: null,
created_at: 1_700_000_000,
...overrides,
};
}
describe("BackupAlertsTable", () => {
it("maps alert severity onto Badge variants per design §2.3", () => {
render(
<BackupAlertsTable
alerts={[
alert({ id: "c", severity: "critical" }),
alert({ id: "w", severity: "warning" }),
]}
onAcknowledge={vi.fn()}
/>,
);
expect(screen.getByText("critical").getAttribute("data-variant")).toBe(
"destructive",
);
expect(screen.getByText("warning").getAttribute("data-variant")).toBe(
"warning",
);
});
it("calls onAcknowledge with the alert id when the button is clicked", async () => {
const onAcknowledge = vi.fn();
render(
<BackupAlertsTable
alerts={[alert({ id: "ack-me" })]}
onAcknowledge={onAcknowledge}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Acknowledge" }));
expect(onAcknowledge).toHaveBeenCalledTimes(1);
expect(onAcknowledge).toHaveBeenCalledWith("ack-me");
});
it("hides the acknowledge button for already-acknowledged alerts", () => {
render(
<BackupAlertsTable
alerts={[alert({ id: "done", acknowledged: true })]}
onAcknowledge={vi.fn()}
/>,
);
expect(screen.queryByRole("button", { name: "Acknowledge" })).toBeNull();
});
});
@@ -0,0 +1,63 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import BackupDashboardWidget from "../BackupDashboardWidget";
import { useBackupDashboard } from "../../hooks/useBackups";
// The widget reads from the react-query hook; mocking `useBackupDashboard` lets
// us exercise the render paths without a QueryClientProvider or network.
vi.mock("../../hooks/useBackups", () => ({
useBackupDashboard: vi.fn(),
}));
const mockUseBackupDashboard = vi.mocked(useBackupDashboard);
type DashboardResult = ReturnType<typeof useBackupDashboard>;
function mockResult(
data: DashboardResult["data"],
isLoading = false,
): DashboardResult {
return { data, isLoading } as DashboardResult;
}
beforeEach(() => {
mockUseBackupDashboard.mockReset();
});
describe("BackupDashboardWidget", () => {
it("renders the loading state while data is pending", () => {
mockUseBackupDashboard.mockReturnValue(mockResult(undefined, true));
render(<BackupDashboardWidget />);
expect(screen.getByText("Loading…")).toBeInTheDocument();
});
it("renders the backup dashboard stats (jobs / 24h success)", () => {
mockUseBackupDashboard.mockReturnValue(
mockResult({
total_jobs: 4,
success_rate_24h: 96,
active_alerts: 0,
last_failed_at: null,
}),
);
render(<BackupDashboardWidget />);
expect(screen.getByText("4")).toBeInTheDocument();
expect(screen.getByText("96%")).toBeInTheDocument();
expect(screen.getByText("Jobs")).toBeInTheDocument();
expect(screen.getByText("24h Success")).toBeInTheDocument();
});
it("renders a destructive Badge for active alerts and shows last-failed time", () => {
mockUseBackupDashboard.mockReturnValue(
mockResult({
total_jobs: 2,
success_rate_24h: 50,
active_alerts: 3,
last_failed_at: 1_700_000_000,
}),
);
render(<BackupDashboardWidget />);
const badge = screen.getByText("3");
expect(badge.getAttribute("data-variant")).toBe("destructive");
expect(screen.getByText(/Last failed:/)).toBeInTheDocument();
});
});
@@ -0,0 +1,59 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import BackupRunsTable from "../BackupRunsTable";
import type { BackupRun } from "../../types/backups";
function run(overrides: Partial<BackupRun> = {}): BackupRun {
return {
id: "r1",
job_id: "job-1",
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,
};
}
describe("BackupRunsTable", () => {
it("maps run status onto Badge variants per design §2.3", () => {
render(
<BackupRunsTable
runs={[
run({ id: "a", status: "success" }),
run({ id: "b", status: "failure" }),
run({ id: "c", status: "in_progress" }),
]}
/>,
);
expect(screen.getByText("success").getAttribute("data-variant")).toBe(
"success",
);
expect(screen.getByText("failure").getAttribute("data-variant")).toBe(
"destructive",
);
expect(screen.getByText("in_progress").getAttribute("data-variant")).toBe(
"warning",
);
});
it("renders the formatted duration and transferred size", () => {
render(
<BackupRunsTable
runs={[
run({
id: "fmt",
duration_ms: 1500,
bytes_transferred: 2048,
}),
]}
/>,
);
expect(screen.getByText("1.5s")).toBeInTheDocument();
expect(screen.getByText("2.0 KB")).toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { ConfirmDialog } from "../ConfirmDialog";
describe("ConfirmDialog", () => {
it("renders the title and message and wires confirm/cancel", async () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<ConfirmDialog
open
title="Delete machine?"
message="This cannot be undone."
confirmLabel="Delete"
onCancel={onCancel}
onConfirm={onConfirm}
/>,
);
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
expect(screen.getByText("This cannot be undone.")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
it("renders nothing when closed", () => {
render(
<ConfirmDialog
open={false}
title="Hidden"
message="nope"
onCancel={() => {}}
onConfirm={() => {}}
/>,
);
expect(screen.queryByText("Hidden")).not.toBeInTheDocument();
});
});
@@ -0,0 +1,52 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DialogFooter } from "../DialogFooter";
describe("DialogFooter", () => {
it("renders cancel/confirm labels and wires both callbacks", async () => {
const onCancel = vi.fn();
const onConfirm = vi.fn();
render(
<DialogFooter
onCancel={onCancel}
cancelLabel="Cancel"
onConfirm={onConfirm}
confirmLabel="Save"
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
await userEvent.click(screen.getByRole("button", { name: "Save" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
it("prefers the busy label and maps confirmColor=error to destructive", () => {
render(
<DialogFooter
onCancel={() => {}}
onConfirm={() => {}}
confirmLabel="Delete"
confirmBusyLabel="Deleting…"
confirmColor="error"
/>,
);
const confirm = screen.getByRole("button", { name: "Deleting…" });
expect(confirm).toBeInTheDocument();
expect(confirm.getAttribute("data-variant")).toBe("destructive");
});
it("renders the secondary action when provided", () => {
render(
<DialogFooter
onCancel={() => {}}
onConfirm={() => {}}
confirmLabel="OK"
secondaryAction={<button type="button">Test SSH</button>}
/>,
);
expect(
screen.getByRole("button", { name: "Test SSH" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { HoverEditButton } from "../HoverEditButton";
describe("HoverEditButton", () => {
it("fires onClick and exposes the default aria-label", async () => {
const onClick = vi.fn();
render(<HoverEditButton onClick={onClick} />);
const button = screen.getByRole("button", { name: "Edit" });
await userEvent.click(button);
expect(onClick).toHaveBeenCalledTimes(1);
});
it("honors a custom label", () => {
render(<HoverEditButton onClick={() => {}} label="Rename machine" />);
expect(
screen.getByRole("button", { name: "Rename machine" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,35 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { LibraryOverview } from "../LibraryOverview";
import type { LibraryCount } from "../../types";
const libraries: LibraryCount[] = [
{
library: "Films",
type: "movies",
movies: 100,
series: 0,
episodes: 0,
total: 100,
},
{
library: "Shows",
type: "tvshows",
movies: 0,
series: 12,
episodes: 240,
total: 252,
},
];
describe("LibraryOverview", () => {
it("renders movie and TV library cards with their counts", () => {
render(<LibraryOverview libraries={libraries} />);
expect(screen.getByText("Movie libraries")).toBeInTheDocument();
expect(screen.getByText("TV libraries")).toBeInTheDocument();
expect(screen.getByText("Films")).toBeInTheDocument();
expect(screen.getByText(/Total: 100 \| Movies: 100/)).toBeInTheDocument();
expect(screen.getByText("Shows")).toBeInTheDocument();
expect(screen.getByText(/Total: 252 \| Series: 12/)).toBeInTheDocument();
});
});
@@ -0,0 +1,21 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { MetricCard } from "../MetricCard";
describe("MetricCard", () => {
it("renders the label, value, and subtext on the comfortable ramp", () => {
render(
<MetricCard label="Movies" value="1,234" subtext="across 3 libraries" />,
);
expect(screen.getByText("Movies")).toBeInTheDocument();
expect(screen.getByText("1,234")).toBeInTheDocument();
expect(screen.getByText(/across 3 libraries/)).toBeInTheDocument();
});
it("omits subtext when not provided", () => {
render(<MetricCard label="Series" value="42" />);
expect(screen.getByText("Series")).toBeInTheDocument();
expect(screen.getByText("42")).toBeInTheDocument();
expect(screen.queryByText(/subtext/i)).not.toBeInTheDocument();
});
});
@@ -0,0 +1,12 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { NowPlaying } from "../NowPlaying";
describe("NowPlaying", () => {
it("renders the dashboard empty-state message contract when there are no sessions", () => {
render(<NowPlaying sessions={[]} />);
expect(
screen.getByText("No recent user activity sessions right now."),
).toBeInTheDocument();
});
});
@@ -0,0 +1,27 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SectionCard } from "../SectionCard";
describe("SectionCard", () => {
it("renders title, description, action, and children", () => {
render(
<SectionCard
title="Shortcuts"
description="Quick links"
action={<button type="button">Add</button>}
>
<p>Body content</p>
</SectionCard>,
);
expect(screen.getByText("Shortcuts")).toBeInTheDocument();
expect(screen.getByText("Quick links")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add" })).toBeInTheDocument();
expect(screen.getByText("Body content")).toBeInTheDocument();
});
it("renders without a description or action", () => {
render(<SectionCard title="Only title">children</SectionCard>);
expect(screen.getByText("Only title")).toBeInTheDocument();
expect(screen.getByText("children")).toBeInTheDocument();
});
});
@@ -0,0 +1,33 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { SelectionRailCard } from "../SelectionRailCard";
describe("SelectionRailCard", () => {
it("renders the title, body, and footer and honors minHeight", () => {
render(
<SelectionRailCard
title="Saved tasks"
description="Pick one"
minHeight={200}
footer={<button type="button">New task</button>}
>
<div>Task A</div>
</SelectionRailCard>,
);
expect(screen.getByText("Saved tasks")).toBeInTheDocument();
expect(screen.getByText("Task A")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "New task" }),
).toBeInTheDocument();
// minHeight is applied to the Card via inline style.
const card = screen
.getByText("Saved tasks")
.closest("[data-slot='card']") as HTMLElement | null;
expect(card?.style.minHeight).toBe("200px");
});
it("renders without a footer", () => {
render(<SelectionRailCard title="No footer">body</SelectionRailCard>);
expect(screen.getByText("No footer")).toBeInTheDocument();
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { SessionActivityPanel } from "../SessionActivityPanel";
import type { NowPlayingSession } from "../../types";
function session(
overrides: Partial<NowPlayingSession> = {},
): NowPlayingSession {
return {
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
...overrides,
};
}
describe("SessionActivityPanel", () => {
it("maps a playing (healthy) session to the success Badge variant", () => {
render(<SessionActivityPanel sessions={[session({ state: "playing" })]} />);
const badge = screen.getByText("Playing");
expect(badge.getAttribute("data-variant")).toBe("success");
});
it("maps paused → warning and idle → secondary", () => {
const { rerender } = render(
<SessionActivityPanel sessions={[session({ state: "paused" })]} />,
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
rerender(<SessionActivityPanel sessions={[session({ state: "idle" })]} />);
expect(screen.getByText("Idle").getAttribute("data-variant")).toBe(
"secondary",
);
});
it("renders the empty-state message when there are no sessions", () => {
render(
<SessionActivityPanel sessions={[]} emptyMessage="Nothing playing." />,
);
expect(screen.getByText("Nothing playing.")).toBeInTheDocument();
});
it("calls onSelectSession on row click and on the action button", async () => {
const onSelectSession = vi.fn();
render(
<SessionActivityPanel
sessions={[session({ state: "playing" })]}
onSelectSession={onSelectSession}
/>,
);
await userEvent.click(screen.getByText("alice"));
expect(onSelectSession).toHaveBeenCalledTimes(1);
await userEvent.click(
screen.getByRole("button", { name: "Open in Users" }),
);
expect(onSelectSession).toHaveBeenCalledTimes(2);
});
});
@@ -0,0 +1,32 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { TabbedCard } from "../TabbedCard";
import { TabsTrigger } from "@/components/ui/tabs";
describe("TabbedCard", () => {
it("renders the provided tab triggers and reports selection changes", async () => {
const onChange = vi.fn();
render(
<TabbedCard
value="jellyfin"
onChange={onChange}
tabs={[
<TabsTrigger key="jellyfin" value="jellyfin">
Jellyfin
</TabsTrigger>,
<TabsTrigger key="nextcloud" value="nextcloud">
Nextcloud
</TabsTrigger>,
]}
>
<p>Body</p>
</TabbedCard>,
);
expect(screen.getByText("Jellyfin")).toBeInTheDocument();
expect(screen.getByText("Body")).toBeInTheDocument();
await userEvent.click(screen.getByText("Nextcloud"));
expect(onChange).toHaveBeenCalledWith("nextcloud");
});
});
@@ -0,0 +1,15 @@
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { Badge } from "../badge";
// Slice 1 harness smoke test: proves the Vitest + jsdom + Testing Library
// harness runs and the new `success` Badge variant renders with the chart-2 cue.
describe("Badge", () => {
it("renders a success variant tagged with the chart-2 cue", () => {
render(<Badge variant="success">Healthy</Badge>);
const badge = screen.getByText("Healthy");
expect(badge).toBeInTheDocument();
expect(badge.getAttribute("data-variant")).toBe("success");
expect(badge.className).toContain("bg-chart-2/10");
});
});
@@ -0,0 +1,185 @@
import { describe, it, expect, vi } from "vitest";
import { useState } from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ColumnDef } from "@tanstack/react-table";
import { DataTable } from "../data-table";
interface Row {
id: string;
name: string;
role: string;
}
const rows: Row[] = [
{ id: "1", name: "Alice", role: "Admin" },
{ id: "2", name: "Bob", role: "Editor" },
{ id: "3", name: "Carol", role: "Viewer" },
];
const columns: ColumnDef<Row>[] = [
{
accessorKey: "name",
header: () => "Name",
cell: ({ row }) => row.original.name,
},
{
accessorKey: "role",
header: () => "Role",
cell: ({ row }) => row.original.role,
},
];
/** Wrapper so the DataTable's controlled state can update during interaction. */
function Harness({
onRowClick,
initialSelection = {},
}: {
onRowClick?: (row: Row) => void;
initialSelection?: Record<string, boolean>;
}) {
const [selection, setSelection] =
useState<Record<string, boolean>>(initialSelection);
const [visibility, setVisibility] = useState<Record<string, boolean>>({});
return (
<DataTable
columns={columns}
data={rows}
getRowId={(row) => row.id}
enableRowSelection
rowSelection={selection}
onRowSelectionChange={setSelection}
onRowClick={onRowClick}
enableColumnVisibilityToggle
columnVisibility={visibility}
onColumnVisibilityChange={setVisibility}
/>
);
}
describe("DataTable (slice 7a — TanStack wrapper)", () => {
it("renders the column headers and rows", () => {
render(<Harness />);
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Role")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("Carol")).toBeInTheDocument();
});
it("toggles row selection via the per-row checkbox and reflects state", async () => {
render(<Harness />);
// Header select-all checkbox + one per-row checkbox exist before rows.
expect(screen.getAllByRole("checkbox", { name: "Select row" }).length).toBe(
rows.length,
);
const aliceCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(aliceCheckbox);
expect(aliceCheckbox).toBeChecked();
// Toggling again un-selects (controlled membership flips).
await userEvent.click(aliceCheckbox);
expect(aliceCheckbox).not.toBeChecked();
});
it("selects all page rows via the header select-all checkbox", async () => {
render(<Harness />);
const selectAll = screen.getByRole("checkbox", {
name: "Select all rows on this page",
});
await userEvent.click(selectAll);
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
expect(cb).toBeChecked();
}
await userEvent.click(selectAll);
for (const cb of screen.getAllByRole("checkbox", { name: "Select row" })) {
expect(cb).not.toBeChecked();
}
});
it("toggles column visibility via the Columns dropdown (column disappears)", async () => {
render(<Harness />);
// Role column header present initially.
expect(screen.getByText("Role")).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
await userEvent.click(
screen.getByRole("menuitemcheckbox", { name: "role" }),
);
// Role header + all role cells vanish from the table.
expect(screen.queryByText("Role")).toBeNull();
expect(screen.queryByText("Admin")).toBeNull();
expect(screen.queryByText("Viewer")).toBeNull();
// Name column is unaffected.
expect(screen.getByText("Name")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
});
it("fires onRowClick with row.original when a row body is clicked", async () => {
const onRowClick = vi.fn();
render(<Harness onRowClick={onRowClick} />);
await userEvent.click(screen.getByText("Bob"));
expect(onRowClick).toHaveBeenCalledTimes(1);
expect(onRowClick).toHaveBeenCalledWith(
expect.objectContaining({ id: "2", name: "Bob", role: "Editor" }),
);
});
it("does NOT fire onRowClick when the selection checkbox is toggled", async () => {
const onRowClick = vi.fn();
render(<Harness onRowClick={onRowClick} />);
const firstCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(firstCheckbox);
expect(onRowClick).not.toHaveBeenCalled();
});
it("renders the empty message when data is empty", () => {
render(
<DataTable
columns={columns}
data={[]}
emptyMessage="No files in this directory."
/>,
);
expect(screen.getByText("No files in this directory.")).toBeInTheDocument();
});
it("renders client pagination controls when enabled", () => {
render(
<DataTable
columns={columns}
data={rows}
enablePagination
pageSizeOptions={[2, 10]}
/>,
);
expect(screen.getByText(/Page 1 of/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
expect(screen.getByRole("button", { name: "Next page" })).toBeEnabled();
});
it("renders the manual pagination total when rowCount is supplied", () => {
render(
<DataTable
columns={columns}
data={rows.slice(0, 2)}
enablePagination
manualPagination
rowCount={42}
pagination={{ pageIndex: 0, pageSize: 2 }}
/>,
);
expect(screen.getByText("42 rows")).toBeInTheDocument();
expect(screen.getByText(/Page 1 of 21/)).toBeInTheDocument();
});
});
+112
View File
@@ -0,0 +1,112 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root> & {
size?: "default" | "sm" | "lg"
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn(
"aspect-square size-full rounded-full object-cover",
className
)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props}
/>
)
}
function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props}
/>
)
}
function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props}
/>
)
}
function AvatarGroupCount({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props}
/>
)
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+4
View File
@@ -14,6 +14,10 @@ const badgeVariants = cva(
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80", "bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive: destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20", "bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
success:
"bg-chart-2/10 text-chart-2 focus-visible:ring-chart-2/20 dark:bg-chart-2/20 dark:focus-visible:ring-chart-2/40 [a]:hover:bg-chart-2/20",
warning:
"bg-chart-3/10 text-chart-3 focus-visible:ring-chart-3/20 dark:bg-chart-3/20 dark:focus-visible:ring-chart-3/40 [a]:hover:bg-chart-3/20",
outline: outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground", "border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost: ghost:
+33
View File
@@ -0,0 +1,33 @@
"use client"
import * as React from "react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon } from "lucide-react"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer relative flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input transition-colors outline-none group-has-disabled/field:opacity-50 after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:aria-checked:border-primary dark:bg-input/30 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:border-primary data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none [&>svg]:size-3.5"
>
<CheckIcon
/>
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
+342
View File
@@ -0,0 +1,342 @@
"use client";
import * as React from "react";
import {
type ColumnDef,
type OnChangeFn,
type PaginationState,
type RowSelectionState,
type Table as TableInstance,
type VisibilityState,
flexRender,
getCoreRowModel,
getPaginationRowModel,
useReactTable,
} from "@tanstack/react-table";
import { Columns3 } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export interface DataTableProps<TData, TValue = unknown> {
columns: ColumnDef<TData, TValue>[];
data: TData[];
/** Stable row identity; Media derives it from `path` so selection survives paging. */
getRowId?: (row: TData, index: number) => string;
/** Visibility-only feature set (no sorting, no resizing — locked, design §3.3). */
enableRowSelection?: boolean;
rowSelection?: RowSelectionState;
onRowSelectionChange?: OnChangeFn<RowSelectionState>;
onRowClick?: (row: TData) => void;
columnVisibility?: VisibilityState;
onColumnVisibilityChange?: OnChangeFn<VisibilityState>;
enableColumnVisibilityToggle?: boolean;
/** Pagination (Media only; FileBrowser does not paginate). */
enablePagination?: boolean;
manualPagination?: boolean;
pagination?: PaginationState;
onPaginationChange?: OnChangeFn<PaginationState>;
pageSizeOptions?: number[];
/** Server total for Media (manual pagination). */
rowCount?: number;
emptyMessage?: string;
}
/**
* Reusable TanStack Table wrapper built on the shadcn `Table` primitive.
*
* Visibility-only feature scope (locked, design §3): pagination, row selection,
* row click, column visibility. A sorting row model is deliberately never
* wired and column resizing/sizing is never enabled — both are explicit
* non-goals.
*/
export function DataTable<TData, TValue = unknown>({
columns,
data,
getRowId,
enableRowSelection = false,
rowSelection,
onRowSelectionChange,
onRowClick,
columnVisibility,
onColumnVisibilityChange,
enableColumnVisibilityToggle = false,
enablePagination = false,
manualPagination = false,
pagination,
onPaginationChange,
pageSizeOptions = [10, 20, 30, 50],
rowCount,
emptyMessage = "No results.",
}: DataTableProps<TData, TValue>) {
const pageSize = pagination?.pageSize ?? pageSizeOptions[0] ?? 10;
// Selection column is a *display* column (no accessor); only rendered when
// the consumer opts in. Its checkbox handlers stopPropagation so toggling a
// row never also fires onRowClick navigation.
const tableColumns = React.useMemo<ColumnDef<TData, TValue>[]>(() => {
if (!enableRowSelection) return columns;
const selectColumn: ColumnDef<TData, TValue> = {
id: "__select__",
enableSorting: false,
header: ({ table }) => (
<Checkbox
aria-label="Select all rows on this page"
checked={
table.getIsAllPageRowsSelected()
? true
: table.getIsSomePageRowsSelected()
? "indeterminate"
: false
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
onClick={(e) => e.stopPropagation()}
/>
),
cell: ({ row }) => (
<Checkbox
aria-label="Select row"
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
onClick={(e) => e.stopPropagation()}
/>
),
enableHiding: false,
};
return [selectColumn as ColumnDef<TData, TValue>, ...columns];
}, [columns, enableRowSelection]);
/* eslint-disable react-hooks/incompatible-library -- TanStack's
useReactTable intentionally returns non-memoizable updater fns (controlled state). */
const table = useReactTable<TData>({
data,
columns: tableColumns,
getRowId,
enableRowSelection,
onRowSelectionChange,
onColumnVisibilityChange,
manualPagination: enablePagination ? manualPagination : false,
rowCount: enablePagination && manualPagination ? rowCount : undefined,
getCoreRowModel: getCoreRowModel(),
// Client pagination model ONLY when paginating locally (FileBrowser does
// not paginate; Media drives the page from the server via limit/offset).
getPaginationRowModel:
enablePagination && !manualPagination
? getPaginationRowModel()
: undefined,
state: {
...(rowSelection !== undefined ? { rowSelection } : {}),
...(columnVisibility !== undefined ? { columnVisibility } : {}),
...(enablePagination
? { pagination: pagination ?? { pageIndex: 0, pageSize } }
: {}),
},
onPaginationChange,
// Visibility-only: deliberately NO sorting model / sorting state.
});
const pageCount =
enablePagination && rowCount !== undefined && pageSize > 0
? Math.max(1, Math.ceil(rowCount / pageSize))
: table.getPageCount();
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-end gap-2">
{enableColumnVisibilityToggle && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Columns3 className="size-4" />
Columns
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Toggle columns</DropdownMenuLabel>
<DropdownMenuSeparator />
{table
.getAllColumns()
.filter((column) => column.getCanHide())
.map((column) => {
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
onSelect={(e) => e.preventDefault()}
>
{column.id}
</DropdownMenuCheckboxItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id} className="hover:bg-transparent">
{headerGroup.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext(),
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() ? "selected" : undefined}
className={cn(onRowClick && "cursor-pointer")}
onClick={() => onRowClick?.(row.original)}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext(),
)}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={tableColumns.length}
className="h-24 text-center text-muted-foreground"
>
{emptyMessage}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{enablePagination && (
<DataTablePagination
table={table}
pageSizeOptions={pageSizeOptions}
pageCount={pageCount}
manual={manualPagination}
rowCount={rowCount}
/>
)}
</div>
);
}
interface PaginationProps<TData> {
table: TableInstance<TData>;
pageSizeOptions: number[];
pageCount: number;
manual: boolean;
rowCount?: number;
}
function DataTablePagination<TData>({
table,
pageSizeOptions,
pageCount,
manual,
rowCount,
}: PaginationProps<TData>) {
const pageIndex = table.getState().pagination.pageIndex;
const pageSize = table.getState().pagination.pageSize;
const visibleRows = table.getRowModel().rows.length;
const totalRows = manual ? (rowCount ?? 0) : visibleRows;
return (
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
<div className="text-muted-foreground">
{`${totalRows} row${totalRows === 1 ? "" : "s"}`}
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Rows per page</span>
<Select
value={String(pageSize)}
onValueChange={(value) => table.setPageSize(Number(value))}
>
<SelectTrigger
size="sm"
className="w-[70px]"
aria-label="Rows per page"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((option) => (
<SelectItem key={option} value={String(option)}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<span className="text-muted-foreground">
Page {pageIndex + 1} of {pageCount}
</span>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
aria-label="Previous page"
>
Previous
</Button>
<Button
variant="outline"
size="sm"
onClick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
aria-label="Next page"
>
Next
</Button>
</div>
</div>
</div>
);
}
+166
View File
@@ -0,0 +1,166 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
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",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
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",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button
variant="ghost"
className="absolute top-2 right-2"
size="icon-sm"
>
<XIcon
/>
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}: React.ComponentProps<"div"> & {
showCloseButton?: boolean
}) {
return (
<div
data-slot="dialog-footer"
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",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn(
"text-base leading-none font-medium",
className
)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
@@ -0,0 +1,267 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn("z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden 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 )}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator"
>
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon
/>
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn("z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 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 )}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Input }
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { Label as LabelPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import { Progress as ProgressPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"relative flex h-1 w-full items-center overflow-x-hidden rounded-full bg-muted",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="size-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import { ScrollArea as ScrollAreaPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
data-orientation={orientation}
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none data-horizontal:h-2.5 data-horizontal:flex-col data-horizontal:border-t data-horizontal:border-t-transparent data-vertical:h-full data-vertical:w-2.5 data-vertical:border-l data-vertical:border-l-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="relative flex-1 rounded-full bg-border"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+26
View File
@@ -0,0 +1,26 @@
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-horizontal:h-px data-horizontal:w-full data-vertical:w-px data-vertical:self-stretch",
className
)}
{...props}
/>
)
}
export { Separator }
+31
View File
@@ -0,0 +1,31 @@
import * as React from "react"
import { Switch as SwitchPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Switch({
className,
size = "default",
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root> & {
size?: "sm" | "default"
}) {
return (
<SwitchPrimitive.Root
data-slot="switch"
data-size={size}
className={cn(
"peer group/switch relative inline-flex shrink-0 items-center rounded-full border border-transparent transition-all outline-none after:absolute after:-inset-x-3 after:-inset-y-2 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=default]:h-[18.4px] data-[size=default]:w-[32px] data-[size=sm]:h-[14px] data-[size=sm]:w-[24px] dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 data-checked:bg-primary data-unchecked:bg-input dark:data-unchecked:bg-input/80 data-disabled:cursor-not-allowed data-disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className="pointer-events-none block rounded-full bg-background ring-0 transition-transform group-data-[size=default]/switch:size-4 group-data-[size=sm]/switch:size-3 group-data-[size=default]/switch:data-checked:translate-x-[calc(100%-2px)] group-data-[size=sm]/switch:data-checked:translate-x-[calc(100%-2px)] dark:data-checked:bg-primary-foreground group-data-[size=default]/switch:data-unchecked:translate-x-0 group-data-[size=sm]/switch:data-unchecked:translate-x-0 dark:data-unchecked:bg-foreground"
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+116
View File
@@ -0,0 +1,116 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+88
View File
@@ -0,0 +1,88 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
className
)}
{...props}
/>
)
}
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",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}: React.ComponentProps<typeof TabsPrimitive.List> &
VariantProps<typeof tabsListVariants>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
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",
"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",
"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",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props}
/>
)
}
export { Textarea }
+9 -9
View File
@@ -9,26 +9,26 @@ import {
} from "../api/client"; } from "../api/client";
import type { DashboardShortcutInput } from "../types"; import type { DashboardShortcutInput } from "../types";
export function useCounts(machineId?: string) { export function useCounts(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "counts", machineId ?? "default"], queryKey: ["dashboard", "counts", jellyfinServiceId ?? "default"],
queryFn: () => fetchCounts(machineId), queryFn: () => fetchCounts(jellyfinServiceId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useLibraries(machineId?: string) { export function useLibraries(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "libraries", machineId ?? "default"], queryKey: ["dashboard", "libraries", jellyfinServiceId ?? "default"],
queryFn: () => fetchLibraries(machineId), queryFn: () => fetchLibraries(jellyfinServiceId),
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
export function useActivity(machineId?: string) { export function useActivity(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["dashboard", "activity", machineId ?? "default"], queryKey: ["dashboard", "activity", jellyfinServiceId ?? "default"],
queryFn: () => fetchActivity(machineId), queryFn: () => fetchActivity(jellyfinServiceId),
refetchInterval: 15_000, refetchInterval: 15_000,
}); });
} }
+10 -10
View File
@@ -7,10 +7,10 @@ import {
forceStopMediaIndexBuild, forceStopMediaIndexBuild,
} from "../api/client"; } from "../api/client";
export function useMediaStatus(machineId?: string) { export function useMediaStatus(jellyfinServiceId?: string) {
return useQuery({ return useQuery({
queryKey: ["media", "status", machineId ?? "default"], queryKey: ["media", "status", jellyfinServiceId ?? "default"],
queryFn: () => fetchMediaStatus(machineId), queryFn: () => fetchMediaStatus(jellyfinServiceId),
staleTime: 5_000, staleTime: 5_000,
refetchInterval: (query) => refetchInterval: (query) =>
query.state.data?.build_running ? 1000 : false, query.state.data?.build_running ? 1000 : false,
@@ -27,7 +27,7 @@ export function useMediaQuery(params: {
sort_order?: string; sort_order?: string;
limit?: number; limit?: number;
offset?: number; offset?: number;
machineId?: string; jellyfinServiceId?: string;
enabled?: boolean; enabled?: boolean;
}) { }) {
const { enabled = true, ...queryParams } = params; const { enabled = true, ...queryParams } = params;
@@ -44,30 +44,30 @@ function invalidateMedia(queryClient: ReturnType<typeof useQueryClient>) {
queryClient.invalidateQueries({ queryKey: ["media"] }); queryClient.invalidateQueries({ queryKey: ["media"] });
} }
export function useBuildIndex(machineId?: string) { export function useBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => buildMediaIndex(machineId), mutationFn: () => buildMediaIndex(jellyfinServiceId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
}); });
} }
export function useStopBuildIndex(machineId?: string) { export function useStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => stopMediaIndexBuild(machineId), mutationFn: () => stopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
}); });
} }
export function useForceStopBuildIndex(machineId?: string) { export function useForceStopBuildIndex(jellyfinServiceId?: string) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: () => forceStopMediaIndexBuild(machineId), mutationFn: () => forceStopMediaIndexBuild(jellyfinServiceId),
onSuccess: () => { onSuccess: () => {
invalidateMedia(queryClient); invalidateMedia(queryClient);
}, },
+47
View File
@@ -0,0 +1,47 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createServiceInstance,
deleteServiceInstance,
fetchServiceInstances,
fetchServiceTypes,
updateServiceInstance,
} from "../api/services";
import type { ServiceInstanceInput } from "../types";
export function useServiceTypes() {
return useQuery({
queryKey: ["services", "types"],
queryFn: fetchServiceTypes,
staleTime: 5 * 60 * 1000,
});
}
export function useServiceInstances(serviceType?: string) {
return useQuery({
queryKey: ["services", "instances", serviceType ?? "all"],
queryFn: () => fetchServiceInstances(serviceType),
refetchInterval: 60_000,
});
}
export function useSaveServiceInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: ServiceInstanceInput) =>
input.id ? updateServiceInstance(input) : createServiceInstance(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
},
});
}
export function useDeleteServiceInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (serviceId: string) => deleteServiceInstance(serviceId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["services", "instances"] });
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
},
});
}
+3 -3
View File
@@ -2,10 +2,10 @@ import { useQuery } from "@tanstack/react-query";
import { fetchUsers } from "../api/client"; import { fetchUsers } from "../api/client";
import type { UserDirectoryResponse } from "../types"; import type { UserDirectoryResponse } from "../types";
export function useUsers(machineId?: string) { export function useUsers(jellyfinServiceId?: string) {
return useQuery<UserDirectoryResponse>({ return useQuery<UserDirectoryResponse>({
queryKey: ["users", machineId ?? "default"], queryKey: ["users", jellyfinServiceId ?? "default"],
queryFn: () => fetchUsers(machineId), queryFn: () => fetchUsers(jellyfinServiceId),
staleTime: 30_000, staleTime: 30_000,
}); });
} }
+57
View File
@@ -0,0 +1,57 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createWidgetInstance,
deleteWidgetInstance,
fetchBuiltinWidgetKinds,
fetchWidgetData,
fetchWidgetInstances,
updateWidgetInstance,
} from "../api/widgets";
import type { WidgetInstanceInput } from "../types";
export function useWidgetInstances() {
return useQuery({
queryKey: ["widgets", "instances"],
queryFn: fetchWidgetInstances,
refetchInterval: 60_000,
});
}
export function useWidgetData(widgetId: string, refreshInterval: number) {
return useQuery({
queryKey: ["widgets", "data", widgetId],
queryFn: () => fetchWidgetData(widgetId),
refetchInterval: refreshInterval || false,
enabled: !!widgetId,
retry: 1,
});
}
export function useSaveWidgetInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (input: WidgetInstanceInput) =>
input.id ? updateWidgetInstance(input) : createWidgetInstance(input),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
},
});
}
export function useDeleteWidgetInstance() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (widgetId: string) => deleteWidgetInstance(widgetId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["widgets", "instances"] });
},
});
}
export function useBuiltinWidgetKinds() {
return useQuery({
queryKey: ["widgets", "builtin"],
queryFn: fetchBuiltinWidgetKinds,
staleTime: 5 * 60 * 1000,
});
}

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