Compare commits

...

28 Commits

Author SHA1 Message Date
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
130 changed files with 17127 additions and 6386 deletions
+3
View File
@@ -23,6 +23,8 @@ PROMETHEUS_ENABLED=true
PROMETHEUS_FILE_SD_DIR=/app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL=http://alertmanager:9093
ALERTMANAGER_WEBHOOK_URL=
GRAFANA_URL=http://grafana:3000
PROMETHEUS_URL=http://prometheus:9090
BACKEND_CACHE_DIR=./backend-cache
# Auth
@@ -41,6 +43,7 @@ VITE_OIDC_SCOPE=openid profile email
VITE_OIDC_REDIRECT_URI=https://manage.example.com/oidc/callback
VITE_OIDC_POST_LOGOUT_REDIRECT_URI=https://manage.example.com/
VITE_DEV_API_PROXY_TARGET=http://backend:8000
VITE_GRAFANA_URL=https://grafana.example.com
# SMTP
SMTP_HOST=smtp.example.com
+4 -1
View File
@@ -1,12 +1,14 @@
# AGENTS.md
## Layout
- 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`).
- Frontend entrypoint: `frontend/src/main.tsx`.
- Backend uses a `src/` layout; tests live in `backend/tests/`.
## Commands
- 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 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`
## Repo-Specific Gotchas
- 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).
- 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.
- 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.
- 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.
@@ -52,16 +52,13 @@ class Settings(BaseSettings):
ssh_password: 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
prometheus_enabled: bool = True
prometheus_file_sd_dir: str = "/app/backend/.cache/prometheus-file-sd"
alertmanager_url: str = "http://alertmanager:9093"
alertmanager_webhook_url: str = "" # Optional receiver for alertmanager webhook notifications
grafana_url: str = "http://grafana:3000"
prometheus_url: str = "http://prometheus:9090"
# Remote paths
remote_media_root: str = ""
@@ -21,12 +21,6 @@ from media_library_viewer_api.clients.ssh import RemoteSSHClient
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 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 get_settings_store as _get_settings_store
@@ -251,11 +245,6 @@ def get_mail_queue() -> MailQueue:
return _get_mail_queue()
def get_monitoring_poller() -> MonitoringPoller:
"""Return the singleton background monitoring poller."""
return _get_monitoring_poller()
def get_settings_store() -> SettingsStore:
"""Return the singleton persistent settings store."""
return _get_settings_store()
@@ -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,
)
+7 -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.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.observability import (
get_request_id,
@@ -23,6 +23,7 @@ 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 dashboard, files, jobs, media, monitoring, tasks, users
from media_library_viewer_api.routers import widgets as widgets_router
from media_library_viewer_api.routers.settings import router as settings_router
from .services.backup_poller import get_backup_poller
@@ -45,14 +46,15 @@ async def lifespan(app: FastAPI):
write_prometheus_targets(get_settings_store())
except Exception:
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()
monitoring_poller = get_monitoring_poller()
backup_poller = get_backup_poller()
mail_queue.start()
monitoring_poller.start()
backup_poller.start()
yield
monitoring_poller.stop()
backup_poller.stop()
mail_queue.stop()
logger.info("Backend shutdown complete")
@@ -140,6 +142,7 @@ app.include_router(users.router)
app.include_router(tasks.router)
app.include_router(settings_router)
app.include_router(backups_router.router)
app.include_router(widgets_router.router)
@app.get("/api/health")
@@ -0,0 +1,95 @@
"""Pydantic models for the dashboard widget system."""
from typing import Any
from pydantic import BaseModel, Field, field_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."""
addon_id: str
widget_type: str
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 {})
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 WidgetTypeInfo(BaseModel):
"""Metadata about a built-in widget type."""
addon_id: str
widget_type: str
name: str
description: str
source_type: str
config_schema: dict[str, Any]
class WidgetDataResponse(BaseModel):
"""Response from the per-widget data endpoint."""
widget_id: str
widget_type: str
data: dict[str, Any] | None = None
error: str | None = None
fetched_at: int
@@ -3,7 +3,6 @@
from __future__ import annotations
import logging
import time
from typing import Any
from fastapi import APIRouter, Depends
@@ -14,6 +13,10 @@ from media_library_viewer_api.dependencies import (
get_settings_store,
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.services.settings_store import SettingsStore
@@ -85,50 +88,6 @@ def delete_shortcut(
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")
def get_activity(
client: JellyfinClient = Depends(get_jellyfin_client),
@@ -154,38 +113,4 @@ def get_now_playing(
def get_backup_dashboard(
store: SettingsStore = Depends(get_settings_store),
) -> BackupDashboardSummary:
jobs = store.list_backup_jobs()
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,
)
return build_backup_dashboard_summary(store)
@@ -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
import logging
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.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.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"])
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")
def get_machines(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return enabled monitoring machines for the UI."""
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")
def get_prometheus_targets(store: SettingsStore = Depends(get_settings_store)) -> list[dict[str, Any]]:
"""Return Prometheus file-SD targets for remote Node Exporters.
@@ -12,7 +12,7 @@ from pydantic import BaseModel, Field
from media_library_viewer_api.clients.ssh import RemoteSSHClient
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.known_hosts import has_known_host
from media_library_viewer_api.services.media_index import MediaIndex
@@ -195,13 +195,8 @@ def post_machine(
) -> dict[str, Any]:
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine.id)
_write_prometheus_targets(store)
poller = get_monitoring_poller()
try:
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
finally:
poller.start()
poller.kick()
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@@ -215,13 +210,8 @@ def put_machine(
raise HTTPException(status_code=404, detail="Machine not found")
saved = store.upsert_machine(machine.model_dump(exclude_none=True), machine_id)
_write_prometheus_targets(store)
poller = get_monitoring_poller()
try:
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
finally:
poller.start()
poller.kick()
saved_machine = MonitoringMachineInput.model_validate(saved)
_validate_saved_machine_ssh(saved_machine, store)
return saved
@@ -0,0 +1,173 @@
"""REST API for dashboard widget instances and registry metadata."""
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.models.widgets import (
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
)
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.registry import (
get_widget_info,
list_source_types,
list_widget_types,
validate_config,
)
from media_library_viewer_api.widgets.sources import get_source_adapter
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
def _registry_for_type(widget_type: str) -> dict[str, Any]:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
info = WIDGET_REGISTRY.get(widget_type)
if not info:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown widget type: {widget_type}",
)
return info
def _validate_widget_input(body: WidgetInstanceInput) -> None:
"""Validate widget_type/addon_id match and config schema."""
info = _registry_for_type(body.widget_type)
expected_addon = info["addon_id"]
if body.addon_id != expected_addon:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Widget type '{body.widget_type}' belongs to addon "
f"'{expected_addon}', not '{body.addon_id}'"
),
)
try:
validate_config(body.widget_type, body.config)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.get("/sources")
def list_sources() -> list[str]:
"""Return all registered widget source types."""
return list_source_types()
@router.get("/types")
def list_types() -> list[dict[str, Any]]:
"""Return metadata for all registered widget types."""
return [info.model_dump() for info in list_widget_types()]
@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)
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)
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")
widget_type = widget["widget_type"]
info = get_widget_info(widget_type)
if info is None:
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=None,
error=f"Unknown widget type: {widget_type}",
fetched_at=int(time.time()),
).model_dump()
adapter = get_source_adapter(info.source_type)
if adapter is None:
# Defensive: registry should prevent this, but return a safe error.
return WidgetDataResponse(
widget_id=widget_id,
widget_type=widget_type,
data=None,
error=f"No adapter registered for source type: {info.source_type}",
fetched_at=int(time.time()),
).model_dump()
try:
data = await adapter.fetch(widget["config"])
except Exception as exc:
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,
widget_type=widget_type,
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
@@ -18,6 +18,7 @@ from typing import Any
import paramiko
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")
LOCAL_MACHINE_ID = "local"
@@ -85,25 +86,10 @@ class SettingsStore:
"""
)
conn.execute("CREATE INDEX IF NOT EXISTS idx_monitoring_machines_mode ON monitoring_machines(mode)")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS monitoring_machine_actions (
id TEXT PRIMARY KEY,
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
)
"""
)
# The legacy SSH-scraping monitor (MonitoringPoller) was decommissioned;
# metrics now live in Prometheus/node_exporter/Grafana. Drop the orphan
# table on startup so existing databases get a clean slate.
conn.execute("DROP TABLE IF EXISTS monitoring_machine_actions")
conn.execute(
"""
CREATE TABLE IF NOT EXISTS ssh_keys (
@@ -180,15 +166,21 @@ class SettingsStore:
)
conn.execute(
"""
CREATE INDEX IF NOT EXISTS idx_monitoring_machine_actions_machine_time
ON monitoring_machine_actions(machine_id, created_at DESC)
CREATE TABLE IF NOT EXISTS dashboard_widgets (
id TEXT PRIMARY KEY,
addon_id TEXT NOT NULL,
widget_type TEXT NOT NULL,
title TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
enabled INTEGER NOT NULL DEFAULT 1,
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_monitoring_machine_actions_action_status
ON monitoring_machine_actions(action, status)
"""
"CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_sort ON dashboard_widgets(sort_order)"
)
conn.execute("""
CREATE TABLE IF NOT EXISTS backup_jobs (
@@ -380,12 +372,8 @@ class SettingsStore:
"notes": notes,
}
def ensure_defaults(self) -> None:
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM monitoring_machines").fetchone()
if row and int(row[0]) > 0:
return
def _seed_local_machine(self) -> None:
"""Seed the default local machine if none exists."""
machine = _default_local_machine()
now = int(time.time())
config = {
@@ -428,6 +416,49 @@ class SettingsStore:
),
)
def _seed_dashboard_widgets(self) -> None:
"""Seed default dashboard widgets only when the table is empty."""
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
self.init_schema()
with self.connect() as conn:
row = conn.execute("SELECT COUNT(*) FROM dashboard_widgets").fetchone()
if row and int(row[0]) > 0:
return
defaults = [
{
"id": "jellyfin-activity-default",
"addon_id": "core",
"widget_type": "jellyfin",
"title": "Jellyfin activity",
"config": {"machine_id": ""},
"enabled": True,
"sort_order": 0,
},
{
"id": "backups-summary-default",
"addon_id": "backups",
"widget_type": "backups",
"title": "Backups",
"config": {},
"enabled": True,
"sort_order": 1,
},
]
for widget in defaults:
info = WIDGET_REGISTRY.get(widget["widget_type"])
if not info or info["addon_id"] != widget["addon_id"]:
continue
self.upsert_widget(widget)
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()
self._seed_dashboard_widgets()
def list_machines(self) -> list[dict[str, Any]]:
self.init_schema()
with self.connect() as conn:
@@ -563,88 +594,6 @@ class SettingsStore:
with self.connect() as conn:
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
def _private_key_summary(private_key: str) -> dict[str, str]:
if not private_key:
@@ -1415,6 +1364,119 @@ class SettingsStore:
)
# ------------------------------------------------------------------
# Dashboard widgets
# ------------------------------------------------------------------
def _row_to_widget(self, row: sqlite3.Row) -> dict[str, Any]:
return {
"id": row["id"],
"addon_id": row["addon_id"],
"widget_type": row["widget_type"],
"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]
)
addon_id = str(payload.get("addon_id") or (current or {}).get("addon_id", "")).strip()
widget_type = str(
payload.get("widget_type") or (current or {}).get("widget_type", "")
).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)
return {
"id": widget_id,
"addon_id": addon_id,
"widget_type": widget_type,
"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, 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,
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["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,))
_store: SettingsStore | None = None
@@ -0,0 +1 @@
"""Widget subsystem package."""
@@ -0,0 +1,187 @@
"""Closed, compile-time widget registry.
New widget types and source adapters require a code change in Phase 1.
There is no runtime plugin loading.
"""
from typing import Any
from media_library_viewer_api.models.widgets import WidgetTypeInfo
WIDGET_REGISTRY: dict[str, dict[str, Any]] = {
"jellyfin": {
"addon_id": "core",
"name": "Jellyfin activity",
"description": "Live sessions and idle users from a Jellyfin server.",
"source_type": "jellyfin",
"config_schema": {
"type": "object",
"properties": {
"machine_id": {
"type": "string",
"description": "Jellyfin machine id (empty = default)",
},
},
"required": ["machine_id"],
},
},
"backups": {
"addon_id": "backups",
"name": "Backups",
"description": "Backup job summary and active alerts.",
"source_type": "backups",
"config_schema": {
"type": "object",
"properties": {},
"required": [],
},
},
"grafana-link": {
"addon_id": "grafana",
"name": "Grafana link",
"description": "Deep-link to a Grafana dashboard or panel.",
"source_type": "grafana",
"config_schema": {
"type": "object",
"properties": {
"dashboard_uid": {
"type": "string",
"description": "Grafana dashboard UID",
},
"panel_id": {
"type": "integer",
"description": "Optional panel id",
},
},
"required": ["dashboard_uid"],
},
},
"prometheus-metric": {
"addon_id": "prometheus",
"name": "Prometheus metric",
"description": "Instant query result rendered as a metric.",
"source_type": "prometheus",
"config_schema": {
"type": "object",
"properties": {
"promql": {
"type": "string",
"description": "PromQL instant query",
},
},
"required": ["promql"],
},
},
"ssh-task": {
"addon_id": "ssh-tasks",
"name": "SSH task output",
"description": "Output of a saved task run on a machine.",
"source_type": "ssh_task",
"config_schema": {
"type": "object",
"properties": {
"task_id": {
"type": "string",
"description": "Saved task id",
},
},
"required": ["task_id"],
},
},
"static": {
"addon_id": "core",
"name": "Static text",
"description": "Plain text or markdown note.",
"source_type": "static",
"config_schema": {
"type": "object",
"properties": {
"text": {
"type": "string",
"description": "Text or markdown content",
},
},
"required": ["text"],
},
},
}
def list_source_types() -> list[str]:
"""Return all registered source type names."""
return sorted({info["source_type"] for info in WIDGET_REGISTRY.values()})
def list_widget_types() -> list[WidgetTypeInfo]:
"""Return metadata for all registered widget types."""
return [
WidgetTypeInfo(
addon_id=info["addon_id"],
widget_type=widget_type,
name=info["name"],
description=info["description"],
source_type=info["source_type"],
config_schema=info["config_schema"],
)
for widget_type, info in WIDGET_REGISTRY.items()
]
def get_widget_info(widget_type: str) -> WidgetTypeInfo | None:
"""Return metadata for a single widget type, or None if unknown."""
info = WIDGET_REGISTRY.get(widget_type)
if not info:
return None
return WidgetTypeInfo(
addon_id=info["addon_id"],
widget_type=widget_type,
name=info["name"],
description=info["description"],
source_type=info["source_type"],
config_schema=info["config_schema"],
)
def _validate_type(value: Any, expected: str) -> bool:
if expected == "string":
return isinstance(value, str)
if expected == "integer":
return isinstance(value, int) and not isinstance(value, bool)
if expected == "boolean":
return isinstance(value, bool)
if expected == "number":
return isinstance(value, (int, float)) and not isinstance(value, bool)
if expected == "object":
return isinstance(value, dict)
if expected == "array":
return isinstance(value, list)
return True
def validate_config(widget_type: str, config: dict[str, Any]) -> None:
"""Validate a widget config against its registered JSON schema.
Raises ValueError with a descriptive message if validation fails.
Phase 1 supports only required-field and primitive-type checks.
"""
info = WIDGET_REGISTRY.get(widget_type)
if not info:
raise ValueError(f"Unknown widget type: {widget_type}")
schema = info["config_schema"]
required = schema.get("required", [])
properties = schema.get("properties", {})
for key in required:
if key not in config:
raise ValueError(f"Missing required config field: {key}")
for key, value in config.items():
prop = properties.get(key)
if not prop:
# Unknown keys are allowed in Phase 1 unless they look like secrets
# (handled by the model validator). Skip type checks for unknowns.
continue
expected_type = prop.get("type")
if expected_type and not _validate_type(value, expected_type):
raise ValueError(f"Config field '{key}' must be of type {expected_type}")
@@ -0,0 +1,213 @@
"""Widget source adapters.
Each adapter implements a uniform async interface and translates widget
configuration into data for the dashboard. Adapters reuse existing clients,
machine registries, and environment settings; they never accept arbitrary
commands or store credentials.
"""
from __future__ import annotations
import asyncio
import logging
import shlex
from typing import Any, Protocol
import requests
from starlette.requests import Request
from media_library_viewer_api.config import get_settings
from media_library_viewer_api.dependencies import get_jellyfin_client
from media_library_viewer_api.domain.dashboard import (
_map_sessions_to_activity_rows,
build_backup_dashboard_summary,
)
from media_library_viewer_api.routers.tasks import _client_for_machine, _resolve_machine_for_task
from media_library_viewer_api.services.settings_store import get_settings_store
logger = logging.getLogger(__name__)
def _request_with_machine_id(machine_id: str | None = None) -> Request:
"""Build a minimal Starlette Request carrying a machine_id query param."""
query = f"machine_id={machine_id}".encode() if machine_id else b""
return Request({"type": "http", "query_string": query})
class WidgetSource(Protocol):
"""Protocol for widget source adapters."""
source_type: str
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]: ...
class JellyfinWidgetSource:
"""Fetch Jellyfin sessions and map them to activity rows."""
source_type = "jellyfin"
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try:
request = _request_with_machine_id(config.get("machine_id") or None)
client = await asyncio.wait_for(
asyncio.to_thread(get_jellyfin_client, request),
timeout=self.timeout,
)
sessions = await asyncio.wait_for(
asyncio.to_thread(client.sessions),
timeout=self.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 BackupsWidgetSource:
"""Compute the backup dashboard summary."""
source_type = "backups"
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try:
store = get_settings_store()
summary = build_backup_dashboard_summary(store)
return summary.model_dump()
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("backups adapter failed")
return {"error": f"Backup summary failed: {exc}"}
class GrafanaWidgetSource:
"""Build a Grafana deep-link (no embedding)."""
source_type = "grafana"
timeout = 5
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try:
settings = get_settings()
dashboard_uid = config.get("dashboard_uid")
if not dashboard_uid:
return {"error": "dashboard_uid is required"}
url = f"{settings.grafana_url.rstrip('/')}/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 Prometheus."""
source_type = "prometheus"
timeout = 10
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try:
settings = get_settings()
promql = config.get("promql")
if not promql:
return {"error": "promql is required"}
url = f"{settings.prometheus_url.rstrip('/')}/api/v1/query"
response = await asyncio.wait_for(
asyncio.to_thread(
requests.get,
url,
params={"query": promql},
timeout=self.timeout,
),
timeout=self.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 SshTaskWidgetSource:
"""Run a saved task from the registry and return its output."""
source_type = "ssh_task"
timeout = 30
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
try:
store = get_settings_store()
task_id = config.get("task_id")
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"}
machine = _resolve_machine_for_task(store, task, None)
if not machine:
return {"error": "No machine available for this task"}
client = _client_for_machine(store, machine)
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}"}
result = await asyncio.wait_for(
asyncio.to_thread(client.run, command, timeout=self.timeout),
timeout=self.timeout,
)
return {
"exit_status": result.exit_status,
"stdout": result.stdout or "",
"stderr": result.stderr or "",
}
except asyncio.TimeoutError:
return {"error": "Widget data fetch timed out"}
except Exception as exc:
logger.exception("ssh_task adapter failed")
return {"error": f"SSH task failed: {exc}"}
class StaticWidgetSource:
"""Return static text/markdown unchanged."""
source_type = "static"
async def fetch(self, config: dict[str, Any]) -> dict[str, Any]:
return {"text": config.get("text", "")}
SOURCE_REGISTRY: dict[str, WidgetSource] = {
"jellyfin": JellyfinWidgetSource(),
"backups": BackupsWidgetSource(),
"grafana": GrafanaWidgetSource(),
"prometheus": PrometheusWidgetSource(),
"ssh_task": SshTaskWidgetSource(),
"static": StaticWidgetSource(),
}
def get_source_adapter(source_type: str) -> WidgetSource | None:
"""Return the adapter for a source type, or None if unknown."""
return SOURCE_REGISTRY.get(source_type)
-28
View File
@@ -598,34 +598,6 @@ class TestJobs:
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):
response = test_client.get("/api/monitoring/prometheus-targets")
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"
+475
View File
@@ -0,0 +1,475 @@
"""Tests for the dashboard widget backend: registry, CRUD, validation, seeding, adapters."""
import asyncio
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
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 (
SOURCE_REGISTRY,
GrafanaWidgetSource,
SshTaskWidgetSource,
StaticWidgetSource,
)
@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 test_widget_sources(client):
response = client.get("/api/widgets/sources")
assert response.status_code == 200
assert set(response.json()) == {
"jellyfin",
"backups",
"grafana",
"prometheus",
"ssh_task",
"static",
}
def test_widget_types(client):
response = client.get("/api/widgets/types")
assert response.status_code == 200
types = {item["widget_type"] for item in response.json()}
assert types == {
"jellyfin",
"backups",
"grafana-link",
"prometheus-metric",
"ssh-task",
"static",
}
def test_create_and_read_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello"},
"enabled": True,
"sort_order": 5,
},
)
assert response.status_code == 201
widget = response.json()
assert widget["title"] == "Note"
assert widget["config"] == {"text": "hello"}
assert widget["enabled"] is True
assert widget["sort_order"] == 5
widget_id = widget["id"]
response = client.get("/api/widgets/instances")
assert response.status_code == 200
assert any(w["id"] == widget_id for w in response.json())
def test_update_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
widget_id = response.json()["id"]
response = client.put(
f"/api/widgets/instances/{widget_id}",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Updated",
"config": {"text": "world"},
"enabled": False,
"sort_order": 10,
},
)
assert response.status_code == 200
data = response.json()
assert data["title"] == "Updated"
assert data["config"] == {"text": "world"}
assert data["enabled"] is False
assert data["sort_order"] == 10
def test_delete_widget(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "To delete",
"config": {"text": "bye"},
},
)
widget_id = response.json()["id"]
response = client.delete(f"/api/widgets/instances/{widget_id}")
assert response.status_code == 200
response = client.get("/api/widgets/instances")
assert not any(w["id"] == widget_id for w in response.json())
def test_unknown_widget_type_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "unknown",
"title": "Bad",
"config": {},
},
)
assert response.status_code == 422
def test_addon_id_mismatch_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "grafana",
"widget_type": "static",
"title": "Bad",
"config": {"text": "x"},
},
)
assert response.status_code == 422
def test_credential_key_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Bad",
"config": {"api_key": "secret123"},
},
)
assert response.status_code == 422
def test_update_nonexistent_widget(client):
response = client.put(
"/api/widgets/instances/does-not-exist",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Bad",
"config": {"text": "x"},
},
)
assert response.status_code == 404
def test_delete_nonexistent_widget(client):
response = client.delete("/api/widgets/instances/does-not-exist")
assert response.status_code == 404
def test_default_widgets_seeded(client):
response = client.get("/api/widgets/instances")
assert response.status_code == 200
widgets = response.json()
types = [w["widget_type"] for w in widgets]
assert "jellyfin" in types
assert "backups" in types
def test_no_reseed_when_widgets_exist(tmp_path):
db_path = tmp_path / "settings.sqlite"
store = SettingsStore(db_path)
store.ensure_defaults()
widgets = store.list_widgets()
assert len(widgets) == 2
store.delete_widget(widgets[0]["id"])
store.ensure_defaults()
remaining = store.list_widgets()
assert len(remaining) == 1
def test_update_id_mismatch_returns_400(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello"},
},
)
widget_id = response.json()["id"]
response = client.put(
f"/api/widgets/instances/{widget_id}",
json={
"id": "different-id",
"addon_id": "core",
"widget_type": "static",
"title": "Updated",
"config": {"text": "world"},
},
)
assert response.status_code == 400
def test_empty_title_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "",
"config": {"text": "hello"},
},
)
assert response.status_code == 422
def test_config_type_error_rejected(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "grafana",
"widget_type": "grafana-link",
"title": "Grafana",
"config": {"panel_id": "not-an-integer"},
},
)
assert response.status_code == 422
def test_list_instances_respects_sort_order(client):
response = client.get("/api/widgets/instances")
assert response.status_code == 200
widgets = response.json()
orders = [w["sort_order"] for w in widgets]
assert orders == sorted(orders)
def test_enabled_round_trip(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Toggle",
"config": {"text": "x"},
"enabled": False,
},
)
widget_id = response.json()["id"]
response = client.put(
f"/api/widgets/instances/{widget_id}",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Toggle",
"config": {"text": "x"},
"enabled": True,
},
)
assert response.status_code == 200
assert response.json()["enabled"] is True
def test_fetch_static_widget_data(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "hello world"},
},
)
widget_id = response.json()["id"]
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_id"] == widget_id
assert data["widget_type"] == "static"
assert data["data"] == {"text": "hello world"}
assert data["error"] is None
assert isinstance(data["fetched_at"], int)
def test_fetch_grafana_widget_data(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "grafana",
"widget_type": "grafana-link",
"title": "Grafana",
"config": {"dashboard_uid": "overview", "panel_id": 3},
},
)
widget_id = response.json()["id"]
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_type"] == "grafana-link"
assert data["data"]["url"] == "http://grafana:3000/d/overview?viewPanel=3"
def test_fetch_prometheus_widget_data(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "prometheus",
"widget_type": "prometheus-metric",
"title": "CPU",
"config": {"promql": '100 - avg(irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100'},
},
)
widget_id = response.json()["id"]
fake_payload = {"data": {"resultType": "scalar", "result": [1718900000, "42.5"]}}
with patch("media_library_viewer_api.widgets.sources.requests.get") as mock_get:
mock_response = MagicMock()
mock_response.raise_for_status.return_value = None
mock_response.json.return_value = fake_payload
mock_get.return_value = mock_response
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_type"] == "prometheus-metric"
assert data["data"]["result"]["resultType"] == "scalar"
def test_fetch_jellyfin_widget_data_error(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "jellyfin",
"title": "Activity",
"config": {"machine_id": ""},
},
)
widget_id = response.json()["id"]
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 200
data = response.json()
assert data["widget_type"] == "jellyfin"
assert data["data"] is None
assert data["error"] is not None
assert "Jellyfin" in data["error"] or "machine" in data["error"].lower()
def test_fetch_widget_data_not_found(client):
response = client.get("/api/widgets/instances/does-not-exist/data")
assert response.status_code == 404
def test_fetch_widget_data_unhandled_exception_returns_500(client):
response = client.post(
"/api/widgets/instances",
json={
"addon_id": "core",
"widget_type": "static",
"title": "Note",
"config": {"text": "x"},
},
)
widget_id = response.json()["id"]
class _ExplodingAdapter:
source_type = "static"
async def fetch(self, config):
raise RuntimeError("boom")
with patch("media_library_viewer_api.routers.widgets.get_source_adapter", return_value=_ExplodingAdapter()):
response = client.get(f"/api/widgets/instances/{widget_id}/data")
assert response.status_code == 500
@pytest.mark.asyncio
async def test_static_adapter():
adapter = StaticWidgetSource()
result = await adapter.fetch({"text": "hello"})
assert result == {"text": "hello"}
@pytest.mark.asyncio
async def test_grafana_adapter():
adapter = GrafanaWidgetSource()
result = await adapter.fetch({"dashboard_uid": "overview", "panel_id": 2})
assert result["url"] == "http://grafana:3000/d/overview?viewPanel=2"
result = await adapter.fetch({"dashboard_uid": "overview"})
assert result["url"] == "http://grafana:3000/d/overview"
@pytest.mark.asyncio
async def test_ssh_task_adapter_timeout(tmp_path):
store = SettingsStore(tmp_path / "settings.sqlite")
store.ensure_defaults()
# Create a local machine and a simple shell task.
machine = store.list_machines()[0]
task = store.upsert_task(
{
"name": "slow-task",
"task_type": "shell",
"content": "echo hello",
"enabled": True,
"default_machine_id": machine["id"],
}
)
adapter = SshTaskWidgetSource()
with patch(
"media_library_viewer_api.widgets.sources.get_settings_store",
return_value=store,
), patch(
"media_library_viewer_api.widgets.sources.asyncio.wait_for",
side_effect=asyncio.TimeoutError,
):
result = await adapter.fetch({"task_id": task["id"]})
assert "error" in result
assert "timed out" in result["error"].lower()
def test_source_registry_closed():
assert set(SOURCE_REGISTRY.keys()) == {
"jellyfin",
"backups",
"grafana",
"prometheus",
"ssh_task",
"static",
}
+3
View File
@@ -16,7 +16,9 @@ services:
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
PROMETHEUS_FILE_SD_DIR: /app/backend/.cache/prometheus-file-sd
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
ports:
- "8000:8000"
volumes:
@@ -37,6 +39,7 @@ services:
VITE_API_URL: "/api"
VITE_OIDC_ENABLED: "false"
VITE_DEV_API_PROXY_TARGET: "http://backend:8000"
VITE_GRAFANA_URL: "http://localhost:3000"
ports:
- "5173:5173"
volumes:
+3
View File
@@ -27,7 +27,9 @@ services:
SSH_KNOWN_HOSTS_PATH: /app/backend/.cache/known_hosts
PROMETHEUS_FILE_SD_DIR: ${PROMETHEUS_FILE_SD_DIR:-/app/backend/.cache/prometheus-file-sd}
ALERTMANAGER_URL: ${ALERTMANAGER_URL:-http://alertmanager:9093}
ALERTMANAGER_WEBHOOK_URL: ${ALERTMANAGER_WEBHOOK_URL:-}
GRAFANA_URL: ${GRAFANA_URL:-http://grafana:3000}
PROMETHEUS_URL: ${PROMETHEUS_URL:-http://prometheus:9090}
volumes:
- ${BACKEND_CACHE_DIR:-./backend-cache}:/app/backend/.cache
restart: unless-stopped
@@ -69,6 +71,7 @@ services:
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_DEV_API_PROXY_TARGET: ${VITE_DEV_API_PROXY_TARGET:-http://backend:8000}
VITE_GRAFANA_URL: ${VITE_GRAFANA_URL:-https://grafana.example.com}
VITE_APP_VERSION: ${APP_VERSION:-0.1.0}
VITE_APP_BUILD_INFO: ${APP_BUILD_INFO:-dev}
depends_on:
+3 -3
View File
@@ -105,9 +105,9 @@ repo/
| `/api/dashboard/counts` | GET | `jellyfin.media_counts()` | Movie/series/episode totals |
| `/api/dashboard/libraries` | GET | `jellyfin.library_item_counts()` | Per-library breakdown |
| `/api/dashboard/now-playing` | GET | `jellyfin.active_sessions()` | Active sessions + transcode info |
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? |
| `/api/monitoring/metrics` | GET | `resources.read_resource_metrics()` | Last-hour JSONL samples |
| `/api/monitoring/disk` | GET | `resources.disk_space()` | df for media root |
| `/api/monitoring/status` | GET | `resources.resource_collector_status()` | Collector running? *(legacy/removed)* |
| `/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 *(removed 2026-06-17; metrics now in Prometheus/Grafana)* |
| `/api/monitoring/start` | POST | `resources.start_resource_collector()` | Start collector |
| `/api/monitoring/stop` | POST | `resources.stop_resource_collector()` | Stop collector |
| `/api/monitoring/restart` | POST | `resources.restart_resource_collector()` | Restart collector |
+118 -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.
## 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
### Jellyfin Library
@@ -82,7 +143,7 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- Support manual path entry and refresh.
- Remote file listing must be compact, structured, and navigable.
- 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 show a visible `selected` column.
- 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 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.
- 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`.
- 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.
@@ -195,8 +256,58 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
- Job templates should remain centralized in `jobs.py` for future extension.
- Remote job template values must be shell-quoted before execution.
## Configurable Dashboard Widgets
### Overview
The dashboard is composed of persisted widget instances stored in the backend SQLite
settings database. Each widget has a type, title, configuration, enabled flag, and
sort order. The frontend renders enabled widgets in sort order and fetches data
independently through the backend source adapters.
### Widget types
- **Jellyfin activity** — live sessions and idle users from a configured Jellyfin machine.
- **Backups** — backup job summary and active alerts.
- **Grafana link** — deep-link to a Grafana dashboard or panel (no iframe embedding).
- **Prometheus metric** — result of a PromQL instant query.
- **SSH task output** — output of a saved task run on a machine.
- **Static text** — plain text or markdown note.
### Security
- Widget `config` may not contain credential keys such as `password`, `token`,
`secret`, `api_key`, `private_key`, or `passphrase`, or values that look like
secrets (e.g., base64 blobs, `sk-` prefixes).
- Widgets reuse machine-level Jellyfin/SSH credentials and environment settings for
Grafana/Prometheus URLs; no secrets are stored in widget configuration.
- SSH task widgets only run tasks from the saved-task registry; arbitrary commands
are not accepted.
### Addon pages
Each non-core addon gets a dedicated page at `/addons/:addonId`:
- `/addons/grafana`
- `/addons/prometheus`
- `/addons/ssh-tasks`
Unknown addons render a "not installed" alert.
### API
- `GET /api/widgets/sources` — list source types.
- `GET /api/widgets/types` — list widget type metadata.
- `GET /api/widgets/instances` — list widget instances.
- `POST /api/widgets/instances` — create instance.
- `PUT /api/widgets/instances/{id}` — update instance.
- `DELETE /api/widgets/instances/{id}` — delete instance.
- `GET /api/widgets/instances/{id}/data` — fetch widget data.
## 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 (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.
@@ -289,9 +400,11 @@ Phase 1: Jellyfin media index, SSH-based remote filesystem inspection, server mo
## Backup Monitoring
### 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.
### API
- `POST /api/backups/report` — Submit backup run (Bearer token auth)
- `POST /api/backups/report/start` — Mark backup as in_progress
- `GET /api/backups/jobs` — List jobs
@@ -301,16 +414,19 @@ The system receives backup execution reports from an external backup tool via HT
- `GET /api/dashboard/backups` — Dashboard summary
### Data Model
- **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
- **BackupAlert**: id, job_id, run_id, alert_type, severity, message, acknowledged, resolved_at
### Alert Types
- `failed_status` — Backup reported failure (critical)
- `missed_schedule` — No run within 1.5x expected interval (warning)
- `anomaly_size` — Size is 0 or <10% / >300% of 7-day median (warning)
- `anomaly_duration` — Duration >300% of 7-day median (warning)
### Authentication
- Backup tool uses auto-generated Bearer API key
- 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
- `backend/src/media_library_viewer_api/clients/resources.py` deploys a POSIX shell collector to `/tmp` on each remote machine.
- 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`.
- `MonitoringPoller` (`monitoring_poller.py`) runs every 5 minutes, reads the remote JSONL, and stores snapshots in SQLite (`monitoring_machine_actions`).
- Retention defaults to 30 days with periodic pruning.
> **Historical note (2026-06-17):** The legacy Manage-side `MonitoringPoller` that
> SSH-scraped `/proc` + `df` into a local SQLite table (`monitoring_machine_actions`)
> has been **decommissioned**. System metrics now live entirely in the external
> 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
@@ -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 operations (SSH commands, media index builds, mail queue).
- 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
@@ -309,6 +315,7 @@ The existing POSIX remote collector will be removed, and the Python backup alert
- [x] Wire Grafana OAuth to Authentik.
**Phase 1 files**:
- `monitoring/prometheus/prometheus.yml`
- `monitoring/prometheus/rules/backup_alerts.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.
**Phase 2 files**:
- `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/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.
**Phase 3 files**:
- `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/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.
**Phase 4 files**:
- `frontend/src/components/ObservabilityPage.tsx` (page component).
- `frontend/src/hooks/useObservability.ts` (React Query hooks).
- `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.
**Phase 5 files**:
- `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/rules/backup_alerts.yml` (renamed scope to include observability health alerts).
+3
View File
@@ -15,6 +15,7 @@ ARG VITE_OIDC_SCOPE=openid profile email
ARG VITE_OIDC_REDIRECT_URI=
ARG VITE_OIDC_POST_LOGOUT_REDIRECT_URI=
ARG VITE_DEV_API_PROXY_TARGET=http://backend:8000
ARG VITE_GRAFANA_URL=https://grafana.example.com
ARG VITE_APP_VERSION=0.1.0
ARG VITE_APP_BUILD_INFO=dev
@@ -26,6 +27,7 @@ ENV VITE_API_URL=${VITE_API_URL} \
VITE_OIDC_REDIRECT_URI=${VITE_OIDC_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_GRAFANA_URL=${VITE_GRAFANA_URL} \
VITE_APP_VERSION=${VITE_APP_VERSION} \
VITE_APP_BUILD_INFO=${VITE_APP_BUILD_INFO}
@@ -50,6 +52,7 @@ COPY frontend/ ./
ENV VITE_API_URL=/api \
VITE_OIDC_ENABLED=false \
VITE_DEV_API_PROXY_TARGET=http://backend:8000 \
VITE_GRAFANA_URL=http://localhost:3000 \
VITE_APP_VERSION=0.1.0 \
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",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"test:node": "node --test tests/*.test.mjs"
},
"dependencies": {
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@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-table": "^8.21.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3": "^7.9.0",
"lucide-react": "^1.14.0",
"oidc-client-ts": "^3.5.0",
"radix-ui": "^1.4.3",
@@ -27,7 +25,6 @@
"react-dom": "^19.2.5",
"react-oidc-context": "^3.3.1",
"react-router-dom": "^7.14.2",
"recharts": "^3.8.1",
"shadcn": "^4.7.0",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
@@ -36,7 +33,9 @@
"@eslint/js": "^10.0.1",
"@tailwindcss/postcss": "^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/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
@@ -46,10 +45,12 @@
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.5.14",
"tailwindcss": "^4.3.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.10"
"vite": "^8.0.10",
"vitest": "^4.1.9"
}
}
+22 -5
View File
@@ -22,6 +22,7 @@ import { FileBrowser } from "./pages/FileBrowser";
import { Actions } from "./pages/Actions";
import BackupsPage from "./components/BackupsPage";
import { ObservabilityPage } from "./components/ObservabilityPage";
import { AddonPage } from "./pages/AddonPage";
import { getOidcConfig, isOidcConfigured, setAccessToken } from "./auth";
import { fetchAppVersion } from "./api/client";
import { FRONTEND_VERSION_LABEL } from "./version";
@@ -43,6 +44,7 @@ import {
import {
LayoutDashboard,
Activity,
DatabaseBackup,
Monitor,
Users,
Zap,
@@ -82,8 +84,9 @@ function useDarkMode() {
const navItems = [
{ path: "/", label: "Dashboard", icon: LayoutDashboard },
{ 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: "/backups", label: "Backups", icon: DatabaseBackup },
{ path: "/users", label: "Users", icon: Users },
{ path: "/actions", label: "Actions", icon: Zap },
{ path: "/settings", label: "Settings", icon: SettingsIcon },
@@ -432,15 +435,22 @@ function AppInner() {
<Routes>
<Route element={<AuthenticatedApp />}>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
<Route path="/applications" element={<Applications />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
</Route>
</Routes>
</BrowserRouter>
@@ -457,15 +467,22 @@ function AppInner() {
}
>
<Route path="/" element={<Dashboard />} />
<Route path="/monitoring" element={<Navigate to="/observability" replace />} />
<Route path="/applications" element={<Applications />} />
<Route
path="/monitoring"
element={<Navigate to="/observability" replace />}
/>
<Route path="/media" element={<Applications />} />
<Route
path="/applications"
element={<Navigate to="/media" replace />}
/>
<Route path="/users" element={<UsersPage />} />
<Route path="/actions" element={<Actions />} />
<Route path="/files" element={<FileBrowser />} />
<Route path="/backups" element={<BackupsPage />} />
<Route path="/observability" element={<ObservabilityPage />} />
<Route path="/settings" element={<Settings />} />
<Route path="/addons/:addonId" element={<AddonPage />} />
</Route>
</Routes>
</BrowserRouter>
+37
View File
@@ -0,0 +1,37 @@
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function GrafanaAddonPage() {
const grafanaUrl =
(import.meta.env.VITE_GRAFANA_URL as string | undefined) ||
"http://localhost:3000";
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Grafana</h2>
<Card>
<CardHeader>
<CardTitle>Metrics & logs</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open the full Grafana instance for dashboards, metrics, and log
exploration.
</p>
<Button asChild>
<a
href={grafanaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Grafana
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,36 @@
import { ExternalLink } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
export function PrometheusAddonPage() {
const prometheusUrl =
(import.meta.env.VITE_PROMETHEUS_URL as string | undefined) ||
"http://localhost:9090";
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">Prometheus</h2>
<Card>
<CardHeader>
<CardTitle>Metrics explorer</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Open Prometheus to run ad-hoc PromQL queries and inspect targets.
</p>
<Button asChild>
<a
href={prometheusUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center"
>
Open Prometheus
<ExternalLink className="ml-2 h-4 w-4" />
</a>
</Button>
</CardContent>
</Card>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
import { Terminal } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { useNavigate } from "react-router-dom";
export function SshTasksAddonPage() {
const navigate = useNavigate();
return (
<div className="flex flex-col gap-4">
<h2 className="text-xl font-semibold">SSH tasks</h2>
<Card>
<CardHeader>
<CardTitle>Saved actions</CardTitle>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<p className="text-sm text-muted-foreground">
Create, edit, and run saved shell or Python tasks against local or
remote machines.
</p>
<Button onClick={() => navigate("/actions")}>
<Terminal className="mr-2 h-4 w-4" />
Open Actions
</Button>
</CardContent>
</Card>
</div>
);
}
+3
View File
@@ -0,0 +1,3 @@
export { GrafanaAddonPage } from "./GrafanaAddonPage";
export { PrometheusAddonPage } from "./PrometheusAddonPage";
export { SshTasksAddonPage } from "./SshTasksAddonPage";
+69
View File
@@ -0,0 +1,69 @@
import type {
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
WidgetTypeInfo,
} from "../types";
const API_BASE = "/api";
export async function fetchWidgetSources(): Promise<string[]> {
const res = await fetch(`${API_BASE}/widgets/sources`);
if (!res.ok) throw new Error("Failed to fetch widget sources");
return res.json();
}
export async function fetchWidgetTypes(): Promise<WidgetTypeInfo[]> {
const res = await fetch(`${API_BASE}/widgets/types`);
if (!res.ok) throw new Error("Failed to fetch widget types");
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";
interface Props {
alerts: BackupAlert[];
onAcknowledge: (alertId: string) => void;
alerts: BackupAlert[];
onAcknowledge: (alertId: string) => void;
}
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) {
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Severity</TableCell>
<TableCell>Type</TableCell>
<TableCell>Message</TableCell>
<TableCell>Created</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{alerts.map((alert) => (
<TableRow key={alert.id} hover>
<TableCell>
<Chip
label={alert.severity}
color={alert.severity === "critical" ? "error" : "warning"}
size="small"
/>
</TableCell>
<TableCell>{alert.alert_type}</TableCell>
<TableCell>{alert.message}</TableCell>
<TableCell>{formatTimestamp(alert.created_at)}</TableCell>
<TableCell>
{!alert.acknowledged && (
<Button
size="small"
variant="outlined"
onClick={() => onAcknowledge(alert.id)}
>
Acknowledge
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
);
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup alerts">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Severity</TableHead>
<TableHead>Type</TableHead>
<TableHead>Message</TableHead>
<TableHead>Created</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alerts.map((alert) => (
<TableRow key={alert.id}>
<TableCell>
<Badge variant={severityVariant(alert.severity)}>
{alert.severity}
</Badge>
</TableCell>
<TableCell>{alert.alert_type}</TableCell>
<TableCell>{alert.message}</TableCell>
<TableCell>{formatTimestamp(alert.created_at)}</TableCell>
<TableCell>
{!alert.acknowledged && (
<Button
size="sm"
variant="outline"
onClick={() => onAcknowledge(alert.id)}
>
Acknowledge
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -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";
export default function BackupDashboardWidget() {
const { data, isLoading } = useBackupDashboard();
if (isLoading || !data) {
return (
<Card>
<CardContent>
<Typography variant="h6">Backups</Typography>
<Typography color="text.secondary">Loading...</Typography>
</CardContent>
</Card>
);
}
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>Backups</Typography>
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap" }}>
<Box>
<Typography variant="h4">{data.total_jobs}</Typography>
<Typography variant="body2" color="text.secondary">Jobs</Typography>
</Box>
<Box>
<Typography variant="h4">{data.success_rate_24h}%</Typography>
<Typography variant="body2" color="text.secondary">24h Success</Typography>
</Box>
<Box>
<Typography variant="h4">
{data.active_alerts > 0 ? (
<Chip label={data.active_alerts} color="error" size="small" />
) : (
0
)}
</Typography>
<Typography variant="body2" color="text.secondary">Alerts</Typography>
</Box>
{data.last_failed_at && (
<Box>
<Typography variant="body2" color="error">
Last failed: {new Date(data.last_failed_at * 1000).toLocaleString()}
</Typography>
</Box>
)}
</Box>
</CardContent>
</Card>
);
const { data, isLoading } = useBackupDashboard();
return (
<Card>
<CardHeader>
<CardTitle>Backups</CardTitle>
</CardHeader>
<CardContent>
{isLoading || !data ? (
<p className="text-sm text-muted-foreground">Loading</p>
) : (
<div className="flex flex-row flex-wrap gap-6">
<div>
<div className="text-2xl font-semibold">{data.total_jobs}</div>
<div className="text-xs text-muted-foreground">Jobs</div>
</div>
<div>
<div className="text-2xl font-semibold">
{data.success_rate_24h}%
</div>
<div className="text-xs text-muted-foreground">24h Success</div>
</div>
<div>
<div className="text-2xl font-semibold">
{data.active_alerts > 0 ? (
<Badge variant="destructive">{data.active_alerts}</Badge>
) : (
0
)}
</div>
<div className="text-xs text-muted-foreground">Alerts</div>
</div>
{data.last_failed_at && (
<div className="self-center text-xs text-destructive">
Last failed:{" "}
{new Date(data.last_failed_at * 1000).toLocaleString()}
</div>
)}
</div>
)}
</CardContent>
</Card>
);
}
+76 -70
View File
@@ -1,84 +1,90 @@
import { Badge } from "@/components/ui/badge";
import {
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
} from "@mui/material";
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import type { BackupJob, BackupRun } from "../types/backups";
interface Props {
jobs: BackupJob[];
latestRuns: Map<string, BackupRun>;
jobs: BackupJob[];
latestRuns: Map<string, BackupRun>;
}
function formatInterval(seconds: number | null): string {
if (!seconds) return "N/A";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
if (!seconds) return "N/A";
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h`;
return `${Math.floor(seconds / 86400)}d`;
}
function formatTimestamp(ts: number | null): string {
if (!ts) return "Never";
return new Date(ts * 1000).toLocaleString();
if (!ts) return "Never";
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) {
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Name</TableCell>
<TableCell>Source</TableCell>
<TableCell>Target</TableCell>
<TableCell>Schedule</TableCell>
<TableCell>Last Status</TableCell>
<TableCell>Last Run</TableCell>
<TableCell>Next Expected</TableCell>
</TableRow>
</TableHead>
<TableBody>
{jobs.map((job) => {
const run = latestRuns.get(job.id);
const status = run?.status ?? "unknown";
const nextExpected = run && job.schedule_interval_seconds
? run.started_at + job.schedule_interval_seconds
: null;
return (
<TableRow key={job.id} hover>
<TableCell>{job.name}</TableCell>
<TableCell>{job.source ?? "—"}</TableCell>
<TableCell>{job.target ?? "—"}</TableCell>
<TableCell>{formatInterval(job.schedule_interval_seconds)}</TableCell>
<TableCell>
<Chip
label={status}
color={
status === "success"
? "success"
: status === "failure"
? "error"
: status === "in_progress"
? "warning"
: "default"
}
size="small"
/>
</TableCell>
<TableCell>{formatTimestamp(run?.started_at ?? null)}</TableCell>
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</TableContainer>
);
return (
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup jobs">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Name</TableHead>
<TableHead>Source</TableHead>
<TableHead>Target</TableHead>
<TableHead>Schedule</TableHead>
<TableHead>Last Status</TableHead>
<TableHead>Last Run</TableHead>
<TableHead>Next Expected</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{jobs.map((job) => {
const run = latestRuns.get(job.id);
const status = run?.status ?? "unknown";
const nextExpected =
run && job.schedule_interval_seconds
? run.started_at + job.schedule_interval_seconds
: null;
return (
<TableRow key={job.id}>
<TableCell>{job.name}</TableCell>
<TableCell>{job.source ?? "—"}</TableCell>
<TableCell>{job.target ?? "—"}</TableCell>
<TableCell>
{formatInterval(job.schedule_interval_seconds)}
</TableCell>
<TableCell>
<Badge variant={statusVariant(status)}>{status}</Badge>
</TableCell>
<TableCell>
{formatTimestamp(run?.started_at ?? null)}
</TableCell>
<TableCell>{formatTimestamp(nextExpected)}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
+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 { 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";
interface Props {
runs: BackupRun[];
runs: BackupRun[];
}
function formatBytes(bytes: number | null): string {
if (bytes === null || bytes === undefined) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
if (bytes === null || bytes === undefined) return "—";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
function formatDuration(ms: number | null): string {
if (ms === null || ms === undefined) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
return `${(ms / 3600_000).toFixed(1)}h`;
if (ms === null || ms === undefined) return "—";
if (ms < 1000) return `${ms}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
if (ms < 3600_000) return `${(ms / 60_000).toFixed(1)}m`;
return `${(ms / 3600_000).toFixed(1)}h`;
}
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) {
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredRuns = statusFilter === "all"
? runs
: runs.filter((r) => r.status === statusFilter);
return (
<>
<FormControl sx={{ minWidth: 120, mb: 2 }}>
<InputLabel>Status</InputLabel>
<Select
value={statusFilter}
label="Status"
onChange={(e) => setStatusFilter(e.target.value)}
>
<MenuItem value="all">All</MenuItem>
<MenuItem value="success">Success</MenuItem>
<MenuItem value="failure">Failure</MenuItem>
<MenuItem value="in_progress">In Progress</MenuItem>
</Select>
</FormControl>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Job</TableCell>
<TableCell>Status</TableCell>
<TableCell>Duration</TableCell>
<TableCell>Size</TableCell>
<TableCell>Started</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filteredRuns.map((run) => (
<TableRow key={run.id} hover>
<TableCell>{run.job_id}</TableCell>
<TableCell>
<Chip
label={run.status}
color={
run.status === "success"
? "success"
: run.status === "failure"
? "error"
: "warning"
}
size="small"
/>
</TableCell>
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
const [statusFilter, setStatusFilter] = useState<string>("all");
const filteredRuns =
statusFilter === "all"
? runs
: runs.filter((r) => r.status === statusFilter);
return (
<div className="space-y-3">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[160px]" aria-label="Status filter">
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
<SelectItem value="success">Success</SelectItem>
<SelectItem value="failure">Failure</SelectItem>
<SelectItem value="in_progress">In Progress</SelectItem>
</SelectContent>
</Select>
<div className="overflow-hidden rounded-lg border border-border">
<Table aria-label="Backup runs">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead>Job</TableHead>
<TableHead>Status</TableHead>
<TableHead>Duration</TableHead>
<TableHead>Size</TableHead>
<TableHead>Started</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredRuns.map((run) => (
<TableRow key={run.id}>
<TableCell>{run.job_id}</TableCell>
<TableCell>
<Badge variant={statusVariant(run.status)}>
{run.status}
</Badge>
</TableCell>
<TableCell>{formatDuration(run.duration_ms)}</TableCell>
<TableCell>{formatBytes(run.bytes_transferred)}</TableCell>
<TableCell>{formatTimestamp(run.started_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
);
}
+63 -60
View File
@@ -1,69 +1,72 @@
import { Box, Tab, Tabs, Typography } from "@mui/material";
import { useState } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
useAcknowledgeAlert,
useBackupAlerts,
useBackupJobs,
useBackupRuns,
useAcknowledgeAlert,
useBackupAlerts,
useBackupJobs,
useBackupRuns,
} from "../hooks/useBackups";
import BackupAlertsTable from "./BackupAlertsTable";
import BackupJobsTable from "./BackupJobsTable";
import BackupRunsTable from "./BackupRunsTable";
export default function BackupsPage() {
const [tab, setTab] = useState(0);
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(undefined, false);
const acknowledgeMutation = useAcknowledgeAlert();
// Build a map of latest runs per job
const latestRuns = new Map();
if (runsData) {
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>
<Tabs value={tab} onChange={(_, v) => setTab(v)} sx={{ mb: 2 }}>
<Tab label="Jobs" />
<Tab label="Runs" />
<Tab label={`Alerts ${alertsData ? `(${alertsData.length})` : ""}`} />
</Tabs>
{tab === 0 && (
jobsLoading ? (
<Typography>Loading jobs...</Typography>
) : (
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
)
)}
{tab === 1 && (
runsLoading ? (
<Typography>Loading runs...</Typography>
) : (
<BackupRunsTable runs={runsData ?? []} />
)
)}
{tab === 2 && (
alertsLoading ? (
<Typography>Loading alerts...</Typography>
) : (
<BackupAlertsTable
alerts={alertsData ?? []}
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
/>
)
)}
</Box>
);
const [tab, setTab] = useState("jobs");
const { data: jobsData, isLoading: jobsLoading } = useBackupJobs();
const { data: runsData, isLoading: runsLoading } = useBackupRuns();
const { data: alertsData, isLoading: alertsLoading } = useBackupAlerts(
undefined,
false,
);
const acknowledgeMutation = useAcknowledgeAlert();
// Build a map of latest runs per job
const latestRuns = new Map();
if (runsData) {
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);
}
}
}
const alertsLabel = alertsData ? `Alerts (${alertsData.length})` : "Alerts";
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold tracking-tight">Backups</h1>
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="jobs">Jobs</TabsTrigger>
<TabsTrigger value="runs">Runs</TabsTrigger>
<TabsTrigger value="alerts">{alertsLabel}</TabsTrigger>
</TabsList>
<TabsContent value="jobs">
{jobsLoading ? (
<p className="text-sm text-muted-foreground">Loading jobs</p>
) : (
<BackupJobsTable jobs={jobsData ?? []} latestRuns={latestRuns} />
)}
</TabsContent>
<TabsContent value="runs">
{runsLoading ? (
<p className="text-sm text-muted-foreground">Loading runs</p>
) : (
<BackupRunsTable runs={runsData ?? []} />
)}
</TabsContent>
<TabsContent value="alerts">
{alertsLoading ? (
<p className="text-sm text-muted-foreground">Loading alerts</p>
) : (
<BackupAlertsTable
alerts={alertsData ?? []}
onAcknowledge={(id) => acknowledgeMutation.mutate(id)}
/>
)}
</TabsContent>
</Tabs>
</div>
);
}
+27 -19
View File
@@ -1,12 +1,17 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
Stack,
Typography,
} from "@mui/material";
} from "@/components/ui/dialog";
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({
open,
title,
@@ -25,23 +30,26 @@ export function ConfirmDialog({
busy?: boolean;
}) {
return (
<Dialog open={open} onClose={onCancel} fullWidth maxWidth="xs">
<DialogTitle>{title}</DialogTitle>
<DialogContent>
<Stack spacing={1}>
<Typography variant="body2" color="text.secondary">
{message}
</Typography>
</Stack>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onCancel();
}}
>
<DialogContent showCloseButton={false}>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{message}</DialogDescription>
</DialogHeader>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</DialogContent>
<DialogFooter
onCancel={onCancel}
onConfirm={onConfirm}
confirmLabel={confirmLabel}
confirmColor="error"
confirmBusyLabel={confirmLabel}
confirmDisabled={busy}
/>
</Dialog>
);
}
+41 -16
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Button, DialogActions } from "@mui/material";
import { Button } from "@/components/ui/button";
interface DialogFooterProps {
onCancel: () => void;
@@ -14,6 +14,28 @@ interface DialogFooterProps {
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({
onCancel,
cancelLabel = "Cancel",
@@ -27,20 +49,23 @@ export function DialogFooter({
secondaryAction,
}: DialogFooterProps) {
return (
<DialogActions sx={{ px: 3, py: 2 }}>
<Button onClick={onCancel}>{cancelLabel}</Button>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
{secondaryAction}
<Button
variant={confirmVariant}
color={confirmColor}
disabled={confirmDisabled}
startIcon={confirmStartIcon}
onClick={onConfirm}
>
{confirmBusyLabel ?? confirmLabel}
</Button>
</Box>
</DialogActions>
<div className="flex flex-row flex-wrap items-center justify-end gap-2">
<Button variant="ghost" onClick={onCancel}>
{cancelLabel}
</Button>
{secondaryAction ? (
<div className="flex flex-row items-center gap-2">
{secondaryAction}
</div>
) : null}
<Button
variant={resolveConfirmVariant(confirmColor, confirmVariant)}
disabled={confirmDisabled}
onClick={onConfirm}
>
{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 { IconButton } from "@mui/material";
import { Pencil } from "lucide-react";
import { Button } from "@/components/ui/button";
interface HoverEditButtonProps {
onClick: () => void;
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({
onClick,
label = "Edit",
}: HoverEditButtonProps) {
return (
<IconButton
className="rail-edit"
<Button
variant="ghost"
size="icon-sm"
className="rail-edit text-muted-foreground opacity-0 transition-opacity duration-100 ease-out"
aria-label={label}
size="small"
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
sx={{
opacity: 0,
transition: "opacity 120ms ease",
color: "text.secondary",
}}
>
<EditOutlinedIcon fontSize="inherit" />
</IconButton>
<Pencil />
</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";
interface Props {
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) {
const movieLibs = libraries.filter((l) => l.type === "movies");
const tvLibs = libraries.filter((l) => l.type === "tvshows");
return (
<Grid container spacing={2}>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="flex flex-col gap-4">
<h4 className="text-sm font-semibold text-muted-foreground">
Movie libraries
</Typography>
<Stack spacing={1.5}>
</h4>
<div className="flex flex-col gap-4">
{movieLibs.map((lib) => (
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
<Card key={lib.library}>
<CardContent className="flex flex-col gap-1">
<span className="text-base font-semibold">{lib.library}</span>
<span className="text-sm text-muted-foreground">
Total: {lib.total.toLocaleString()} | Movies:{" "}
{lib.movies.toLocaleString()}
</Typography>
</span>
</CardContent>
</Card>
))}
</Stack>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<Typography variant="subtitle2" color="text.secondary" sx={{ mb: 1 }}>
</div>
</div>
<div className="flex flex-col gap-4">
<h4 className="text-sm font-semibold text-muted-foreground">
TV libraries
</Typography>
<Stack spacing={1.5}>
</h4>
<div className="flex flex-col gap-4">
{tvLibs.map((lib) => (
<Card key={lib.library} variant="outlined">
<CardContent>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{lib.library}
</Typography>
<Typography variant="body2" color="text.secondary">
<Card key={lib.library}>
<CardContent className="flex flex-col gap-1">
<span className="text-base font-semibold">{lib.library}</span>
<span className="text-sm text-muted-foreground">
Total: {lib.total.toLocaleString()} | Series:{" "}
{lib.series.toLocaleString()}
</Typography>
</span>
</CardContent>
</Card>
))}
</Stack>
</Grid>
</Grid>
</div>
</div>
</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 {
label: string;
@@ -6,44 +6,24 @@ interface Props {
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) {
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent
sx={{
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 }}
>
<Card className="h-full">
<CardContent className="flex h-full flex-col gap-1.5">
<span className="text-sm uppercase leading-tight tracking-wide text-muted-foreground">
{label}
</Typography>
<Typography
variant="h5"
sx={{
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 }}
>
</span>
<span className="text-lg font-semibold leading-tight">{value}</span>
{subtext ? (
<span className="whitespace-pre-line text-xs leading-relaxed text-muted-foreground">
{subtext}
</Typography>
)}
</span>
) : null}
</CardContent>
</Card>
);
+20 -27
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Stack, Typography } from "@mui/material";
import { Card, CardContent } from "@/components/ui/card";
interface SectionCardProps {
title: string;
@@ -8,6 +8,13 @@ interface SectionCardProps {
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({
title,
description,
@@ -15,32 +22,18 @@ export function SectionCard({
children,
}: SectionCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.25}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 1,
flexWrap: "wrap",
}}
>
<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>
<Card className="gap-4">
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<h3 className="text-base font-semibold">{title}</h3>
{description ? (
<p className="text-sm text-muted-foreground">{description}</p>
) : null}
</div>
{action}
</div>
{children}
</CardContent>
</Card>
);
+19 -46
View File
@@ -1,5 +1,5 @@
import type { ReactNode } from "react";
import { Box, Card, CardContent, Typography } from "@mui/material";
import { Card } from "@/components/ui/card";
interface SelectionRailCardProps {
title: string;
@@ -7,65 +7,38 @@ interface SelectionRailCardProps {
children: ReactNode;
footer?: ReactNode;
minHeight?: number;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
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({
title,
description,
children,
footer,
minHeight = 420,
contentSx,
bodySx,
}: SelectionRailCardProps) {
return (
<Card variant="outlined" sx={{ alignSelf: "start", height: "fit-content" }}>
<CardContent
sx={{
p: 0,
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>
<Card className="h-fit self-start py-0" style={{ minHeight }}>
<div className="flex flex-col" style={{ minHeight }}>
<div className="border-b bg-muted/50 px-4 py-3">
<h4 className="text-sm font-semibold tracking-wide">{title}</h4>
{description ? (
<Typography variant="body2" color="text.secondary">
{description}
</Typography>
<p className="text-xs text-muted-foreground">{description}</p>
) : null}
</Box>
<Box sx={{ flex: 1, overflowY: "auto", ...bodySx }}>{children}</Box>
{footer ? (
<Box
sx={{
p: 1,
borderTop: 1,
borderColor: "divider",
bgcolor: "background.paper",
}}
>
{footer}
</Box>
) : null}
</CardContent>
</div>
<div className="flex-1 overflow-y-auto">{children}</div>
{footer ? <div className="border-t bg-card p-3">{footer}</div> : null}
</div>
</Card>
);
}
+64 -135
View File
@@ -1,15 +1,13 @@
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Button,
Chip,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableHeader,
TableRow,
Typography,
} from "@mui/material";
} from "@/components/ui/table";
import type { NowPlayingSession } from "../types";
interface Props {
@@ -19,6 +17,23 @@ interface Props {
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 {
const normalized = String(state || "")
.trim()
@@ -68,177 +83,91 @@ export function SessionActivityPanel({
const userFallback = selectedUserLabel || "Unknown user";
if (!sessions.length) {
return (
<Typography variant="body2" color="text.secondary">
{emptyMessage}
</Typography>
);
return <p className="text-sm text-muted-foreground">{emptyMessage}</p>;
}
return (
<TableContainer
component={Paper}
variant="outlined"
sx={{
maxHeight: 280,
borderColor: "divider",
borderRadius: 1,
overflowX: "auto",
}}
>
<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>
<div className="max-h-[280px] overflow-auto rounded-lg border border-border">
<Table aria-label="Session activity details" className="min-w-[880px]">
<TableHeader>
<TableRow className="bg-card hover:bg-card">
<TableHead className="min-w-[160px]">User</TableHead>
<TableHead className="w-[82px]">State</TableHead>
<TableHead className="min-w-[140px]">Title / Type</TableHead>
<TableHead className="min-w-[140px]">Device</TableHead>
<TableHead className="w-[118px]">Transcoding</TableHead>
{onSelectSession ? (
<TableCell
sx={{
fontWeight: 700,
bgcolor: "background.default",
width: 150,
}}
>
Action
</TableCell>
<TableHead className="w-[150px]">Action</TableHead>
) : null}
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
<TableRow>
<TableRow className="bg-card hover:bg-card">
<TableCell
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)}
</Typography>
</span>
</TableCell>
</TableRow>
{sessions.map((session) => {
const state = String(session.state || "")
.trim()
.toLowerCase();
const sessionLabel = formatStateLabel(session.state);
return (
<TableRow
key={session.session_id}
hover
sx={{ cursor: onSelectSession ? "pointer" : "default" }}
className={onSelectSession ? "cursor-pointer" : undefined}
onClick={
onSelectSession ? () => onSelectSession(session) : undefined
}
>
<TableCell sx={{ py: 0.75, minWidth: 160 }}>
<Typography
variant="body2"
noWrap
<TableCell className="min-w-[160px]">
<div
className="truncate text-sm"
title={session.user || userFallback}
>
{session.user || userFallback}
</Typography>
<Typography
variant="caption"
color="text.secondary"
noWrap
</div>
<div
className="truncate text-xs text-muted-foreground"
title={session.session_id}
>
{session.session_id}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Chip
size="small"
label={sessionLabel}
color={
state === "playing"
? "primary"
: state === "paused"
? "warning"
: "default"
}
variant={
state === "playing" || state === "paused"
? "filled"
: "outlined"
}
/>
<TableCell className="whitespace-nowrap">
<Badge variant={sessionStateVariant(session.state)}>
{sessionLabel}
</Badge>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography
variant="body2"
noWrap
title={session.title || ""}
>
<TableCell className="min-w-[140px]">
<div className="truncate text-sm" title={session.title || ""}>
{session.title || "(idle)"}
</Typography>
<Typography variant="caption" color="text.secondary" noWrap>
</div>
<div className="truncate text-xs text-muted-foreground">
{session.type || "—"}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, minWidth: 140 }}>
<Typography variant="body2" noWrap>
<TableCell className="min-w-[140px]">
<div className="truncate text-sm">
{session.device || "Unknown device"}
</Typography>
</div>
</TableCell>
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<Typography variant="body2" noWrap>
<TableCell className="whitespace-nowrap">
<span className="text-sm">
{session.transcoding === "yes"
? session.transcoding_type
? `yes (${session.transcoding_type})`
: "yes"
: "no"}
</Typography>
</span>
</TableCell>
{onSelectSession ? (
<TableCell sx={{ py: 0.75, whiteSpace: "nowrap" }}>
<TableCell className="whitespace-nowrap">
<Button
size="small"
variant="outlined"
variant="outline"
size="sm"
onClick={(event) => {
event.stopPropagation();
onSelectSession(session);
@@ -253,6 +182,6 @@ export function SessionActivityPanel({
})}
</TableBody>
</Table>
</TableContainer>
</div>
);
}
+18 -17
View File
@@ -1,38 +1,39 @@
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 {
value: string;
onChange: (value: string) => void;
tabs: ReactElement[];
children: ReactNode;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
contentSx?: object;
/** Legacy MUI `sx` passthroughs; retained for API compatibility (no-op). */
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({
value,
onChange,
tabs,
children,
contentSx,
tabsSx,
}: TabbedCardProps) {
return (
<Card variant="outlined">
<CardContent sx={{ p: 0 }}>
<Tabs
value={value}
onChange={(_, next) => onChange(String(next))}
variant="scrollable"
scrollButtons="auto"
allowScrollButtonsMobile
sx={{ px: 1, borderBottom: 1, borderColor: "divider", ...tabsSx }}
>
{tabs}
</Tabs>
<Box sx={{ p: 1.5, ...contentSx }}>{children}</Box>
</CardContent>
<Card className="gap-0 py-0">
<Tabs value={value} onValueChange={(next) => onChange(String(next))}>
<div className="border-b px-2">
<TabsList variant="line">{tabs}</TabsList>
</div>
<div className="p-4">{children}</div>
</Tabs>
</Card>
);
}
@@ -0,0 +1,464 @@
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,
useWidgetTypes,
} from "../hooks/useWidgets";
import { useMonitoringSettings, useTasks } from "../hooks/useSettings";
import type {
MonitoringMachine,
SavedTask,
WidgetInstance,
WidgetInstanceInput,
} from "../types";
import {
getWidgetDefinition,
listWidgetTypes,
type WidgetDefinition,
} from "../widgets/registry";
interface Props {
open: boolean;
onClose: () => void;
}
function emptyDraft(widgetType: string): WidgetInstanceInput {
const def = getWidgetDefinition(widgetType);
return {
addon_id: def?.addonId ?? "",
widget_type: widgetType,
title: def?.name ?? "",
config: { ...(def?.defaultConfig ?? {}) },
enabled: true,
sort_order: 0,
};
}
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 WidgetConfigFields({
definition,
config,
onChange,
machines,
tasks,
}: {
definition: WidgetDefinition;
config: Record<string, unknown>;
onChange: (config: Record<string, unknown>) => void;
machines: MonitoringMachine[];
tasks: SavedTask[];
}) {
return (
<div className="flex flex-col gap-3">
{definition.configFields.map((field) => {
const value = config[field.key] ?? "";
if (
definition.widgetType === "jellyfin" &&
field.key === "machine_id"
) {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Select
value={String(value)}
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
>
<SelectTrigger id={field.key}>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="">Default</SelectItem>
{machines
.filter((m) => m.enabled && m.services.includes("jellyfin"))
.map((m) => (
<SelectItem key={m.id} value={m.id}>
{m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (definition.widgetType === "ssh-task" && field.key === "task_id") {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Select
value={String(value)}
onValueChange={(v) => onChange({ ...config, [field.key]: v })}
>
<SelectTrigger id={field.key}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{tasks
.filter((t) => t.enabled)
.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
);
}
if (field.type === "number") {
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Input
id={field.key}
type="number"
value={String(value)}
onChange={(e) =>
onChange({
...config,
[field.key]:
e.target.value === ""
? undefined
: Number(e.target.value),
})
}
/>
</Field>
);
}
return (
<Field
key={field.key}
label={field.label}
htmlFor={field.key}
helper={field.helper}
>
<Input
id={field.key}
value={String(value)}
onChange={(e) =>
onChange({
...config,
[field.key]: e.target.value,
})
}
/>
</Field>
);
})}
</div>
);
}
export function WidgetConfigDialog({ open, onClose }: Props) {
const { data: instances = [] } = useWidgetInstances();
const { data: types = [] } = useWidgetTypes();
const { data: machines = [] } = useMonitoringSettings();
const { data: tasks = [] } = useTasks();
const saveWidget = useSaveWidgetInstance();
const deleteWidget = useDeleteWidgetInstance();
const [draft, setDraft] = useState<WidgetInstanceInput | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const registryDefinitions = useMemo(() => listWidgetTypes(), []);
const sortedInstances = useMemo(
() =>
[...instances].sort(
(a, b) => a.sort_order - b.sort_order || a.created_at - b.created_at,
),
[instances],
);
function startAdd(widgetType: string) {
setDraft(emptyDraft(widgetType));
setEditingId(null);
}
function startEdit(instance: WidgetInstance) {
setDraft({
id: instance.id,
addon_id: instance.addon_id,
widget_type: instance.widget_type,
title: instance.title,
config: instance.config,
enabled: instance.enabled,
sort_order: instance.sort_order,
});
setEditingId(instance.id);
}
function reset() {
setDraft(null);
setEditingId(null);
}
async function saveDraft() {
if (!draft) return;
await saveWidget.mutateAsync(draft);
reset();
}
async function toggleEnabled(instance: WidgetInstance) {
await saveWidget.mutateAsync({
...instance,
enabled: !instance.enabled,
});
}
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 definition = draft ? getWidgetDefinition(draft.widget_type) : undefined;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>
{draft
? editingId
? "Edit widget"
: "Add widget"
: "Dashboard widgets"}
</DialogTitle>
</DialogHeader>
{draft && definition ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted-foreground">
{definition.description}
</p>
<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.sort_order)}
onChange={(e) =>
setDraft({
...draft,
sort_order:
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>
<WidgetConfigFields
definition={definition}
config={draft.config}
onChange={(config) => setDraft({ ...draft, config })}
machines={machines}
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 typeDef = getWidgetDefinition(instance.widget_type);
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">
{typeDef?.name ?? instance.widget_type}
</Badge>
{!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">
{registryDefinitions.map((def) => (
<Button
key={def.widgetType}
variant="outline"
size="sm"
onClick={() => startAdd(def.widgetType)}
>
<Plus className="mr-1 h-3 w-3" />
{def.name}
</Button>
))}
</div>
</div>
{types.length === 0 ? (
<Alert>
<AlertDescription>
Widget registry is empty. Backend may not be running.
</AlertDescription>
</Alert>
) : null}
</div>
)}
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,26 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { getWidgetDefinition } from "../widgets/registry";
import type { WidgetInstance } from "../types";
import { SectionCard } from "./SectionCard";
interface Props {
widget: WidgetInstance;
}
export function WidgetInstance({ widget }: Props) {
const def = getWidgetDefinition(widget.widget_type);
if (!def) {
return (
<SectionCard title={widget.title}>
<Alert>
<AlertDescription>
Unknown widget type: {widget.widget_type}
</AlertDescription>
</Alert>
</SectionCard>
);
}
const Component = def.component;
return <Component widget={widget} />;
}
@@ -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",
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",
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:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
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 }
+64
View File
@@ -0,0 +1,64 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createWidgetInstance,
deleteWidgetInstance,
fetchWidgetData,
fetchWidgetInstances,
fetchWidgetSources,
fetchWidgetTypes,
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 useWidgetSources() {
return useQuery({
queryKey: ["widgets", "sources"],
queryFn: fetchWidgetSources,
});
}
export function useWidgetTypes() {
return useQuery({
queryKey: ["widgets", "types"],
queryFn: fetchWidgetTypes,
});
}
+6
View File
@@ -23,6 +23,9 @@
--color-border: #e2e8f0;
--color-input: #e2e8f0;
--color-ring: #4f8cff;
/* Status / Grafana-link semantic cues — single source of truth for Badges.
chart-1=info/brand, chart-2=success, chart-3=warning,
chart-4=destructive, chart-5=neutral-accent. */
--color-chart-1: #4f8cff;
--color-chart-2: #22c55e;
--color-chart-3: #f59e0b;
@@ -59,6 +62,9 @@
--color-border: #334155;
--color-input: #334155;
--color-ring: #4f8cff;
/* Status / Grafana-link semantic cues — single source of truth for Badges.
chart-1=info/brand, chart-2=success, chart-3=warning,
chart-4=destructive, chart-5=neutral-accent. */
--color-chart-1: #4f8cff;
--color-chart-2: #22c55e;
--color-chart-3: #f59e0b;
+338 -433
View File
@@ -1,26 +1,6 @@
import type { ReactNode } from "react";
import { useMemo, useState } from "react";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
MenuItem,
Select,
Stack,
Tab,
Tabs,
TextField,
Typography,
} from "@mui/material";
import type { MonitoringMachine, SavedTaskInput } from "../types";
import type { MonitoringMachine, SavedTask, SavedTaskInput } from "../types";
import {
useDeleteTask,
useMonitoringSettings,
@@ -31,10 +11,63 @@ import {
} from "../hooks/useSettings";
import { DialogFooter } from "../components/DialogFooter";
import { HoverEditButton } from "../components/HoverEditButton";
import { SectionCard } from "../components/SectionCard";
import { SelectionRailCard } from "../components/SelectionRailCard";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
// Radix Select disallows empty-string item values; the "None" option maps to
// this sentinel and converts back to "" at the draft boundary.
const NONE = "__none__";
type ActionTab = "new" | string;
/** Small labeled-field wrapper replacing the MUI `<TextField label>` shell. */
function FormField({
label,
htmlFor,
helperText,
children,
}: {
label: string;
htmlFor?: string;
helperText?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col">
<Label htmlFor={htmlFor} className="mb-1">
{label}
</Label>
{children}
{helperText ? (
<p className="mt-1 text-xs text-muted-foreground">{helperText}</p>
) : null}
</div>
);
}
function emptyTask(): SavedTaskInput {
return {
id: null,
@@ -59,6 +92,18 @@ function sameTask(a: SavedTaskInput, b: SavedTaskInput) {
);
}
function initialFromTask(task: SavedTask): SavedTaskInput {
return {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
}
function TaskEditor({
task,
machines,
@@ -72,101 +117,98 @@ function TaskEditor({
(machine) => machine.id === task.default_machine_id,
);
return (
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
<p className="text-sm font-semibold">
{task.id ? "Edit action" : "New action"}
</Typography>
<Chip size="small" variant="outlined" label={task.task_type} />
<Chip
size="small"
variant="outlined"
label={task.enabled ? "enabled" : "disabled"}
/>
</p>
<Badge variant="outline">{task.task_type}</Badge>
<Badge variant="outline">{task.enabled ? "enabled" : "disabled"}</Badge>
{selectedMachine && (
<Chip
size="small"
variant="outlined"
label={`default: ${selectedMachine.name}`}
/>
<Badge variant="outline">{`default: ${selectedMachine.name}`}</Badge>
)}
</Stack>
</div>
<Stack spacing={1.25}>
<TextField
fullWidth
size="small"
label="Name"
value={task.name}
onChange={(e) => onChange({ ...task, name: e.target.value })}
/>
<Stack direction="row" spacing={1.25} sx={{ flexWrap: "wrap" }}>
<FormControl size="small" sx={{ minWidth: 180, flex: "1 1 180px" }}>
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={task.task_type}
onChange={(e) =>
onChange({
...task,
task_type: e.target.value as SavedTaskInput["task_type"],
})
}
>
<MenuItem value="shell">Shell</MenuItem>
<MenuItem value="python">Python</MenuItem>
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 220, flex: "1 1 220px" }}>
<InputLabel>Default machine</InputLabel>
<Select
label="Default machine"
value={task.default_machine_id}
onChange={(e) =>
onChange({
...task,
default_machine_id: String(e.target.value),
})
}
>
<MenuItem value="">None</MenuItem>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<TextField
fullWidth
size="small"
label="Notes"
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
<TextField
fullWidth
multiline
minRows={9}
size="small"
<div className="flex flex-col gap-2">
<FormField label="Name" htmlFor="task-name">
<Input
id="task-name"
value={task.name}
onChange={(e) => onChange({ ...task, name: e.target.value })}
/>
</FormField>
<div className="flex flex-row flex-wrap gap-2">
<div className="min-w-[180px] flex-1">
<FormField label="Type">
<Select
value={task.task_type}
onValueChange={(value) =>
onChange({
...task,
task_type: value as SavedTaskInput["task_type"],
})
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="shell">Shell</SelectItem>
<SelectItem value="python">Python</SelectItem>
</SelectContent>
</Select>
</FormField>
</div>
<div className="min-w-[220px] flex-1">
<FormField label="Default machine">
<Select
value={task.default_machine_id || NONE}
onValueChange={(value) =>
onChange({
...task,
default_machine_id: value === NONE ? "" : value,
})
}
>
<SelectTrigger className="w-full" size="sm">
<SelectValue placeholder="None" />
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE}>None</SelectItem>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</div>
<FormField label="Notes">
<Input
value={task.notes}
onChange={(e) => onChange({ ...task, notes: e.target.value })}
/>
</FormField>
<FormField
label={
task.task_type === "python" ? "Python script" : "Shell command"
}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
helperText={
task.task_type === "python"
? "Python is run as `python3 -c`."
: "Shell commands are run through `/bin/sh -c`."
}
/>
</Stack>
</Stack>
>
<Textarea
rows={9}
value={task.content}
onChange={(e) => onChange({ ...task, content: e.target.value })}
/>
</FormField>
</div>
</div>
);
}
@@ -200,25 +242,36 @@ function TaskDialog({
};
return (
<Dialog open={open} onClose={requestClose} fullWidth maxWidth="md">
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
<DialogContent dividers>
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) requestClose();
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{task.id ? "Edit action" : "New action"}</DialogTitle>
<DialogDescription>
Save a reusable server task. Shell commands run via{" "}
<code>/bin/sh -c</code>; Python runs via <code>python3 -c</code>.
</DialogDescription>
</DialogHeader>
<TaskEditor task={task} machines={machines} onChange={onChange} />
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
onConfirm={onSave}
confirmLabel="Save action"
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
) : undefined
}
/>
</DialogContent>
<DialogFooter
onCancel={requestClose}
cancelLabel="Cancel"
onConfirm={onSave}
confirmLabel="Save action"
confirmBusyLabel="Save action"
secondaryAction={
onDelete ? (
<Button variant="outlined" color="error" onClick={onDelete}>
Delete
</Button>
) : undefined
}
/>
</Dialog>
);
}
@@ -243,6 +296,12 @@ export function Actions() {
);
const selectedRuns = useTaskRuns(selectedTask?.id);
const openEdit = (initial: SavedTaskInput) => {
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
};
const createNew = () => {
const initial = emptyTask();
setDraft(initial);
@@ -271,51 +330,43 @@ export function Actions() {
const editingTask = selectedTask;
return (
<Stack spacing={2.25}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
}}
>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Actions
</Typography>
<Typography variant="body2" color="text.secondary">
<div className="flex flex-col gap-6">
<div className="flex flex-row flex-wrap items-center justify-between gap-2">
<div>
<h1 className="text-lg font-semibold">Actions</h1>
<p className="text-xs text-muted-foreground">
Save reusable server tasks and switch between them with tabs.
</Typography>
</Box>
<Chip label={`${tasks.length} saved`} variant="outlined" />
</Stack>
</p>
</div>
<Badge variant="outline">{`${tasks.length} saved`}</Badge>
</div>
{saveTask.error && (
<Alert severity="error">{String(saveTask.error)}</Alert>
<Alert variant="destructive">
<AlertDescription>{String(saveTask.error)}</AlertDescription>
</Alert>
)}
{deleteTask.error && (
<Alert severity="error">{String(deleteTask.error)}</Alert>
<Alert variant="destructive">
<AlertDescription>{String(deleteTask.error)}</AlertDescription>
</Alert>
)}
{runTask.error && (
<Alert variant="destructive">
<AlertDescription>{String(runTask.error)}</AlertDescription>
</Alert>
)}
{runTask.error && <Alert severity="error">{String(runTask.error)}</Alert>}
<Box
sx={{
display: "grid",
gridTemplateColumns: { xs: "1fr", md: "280px minmax(0, 1fr)" },
gap: 2,
}}
>
<div className="grid grid-cols-1 gap-4 md:grid-cols-[280px_minmax(0,1fr)]">
<SelectionRailCard
title="Saved actions"
description="Pick a saved task, then edit or run it from the detail pane."
contentSx={{ maxHeight: { xs: 520, md: 620 } }}
contentSx={{}}
footer={
<Button
variant="outlined"
size="small"
fullWidth
variant="outline"
size="sm"
className="w-full"
onClick={createNew}
>
Add action
@@ -324,301 +375,155 @@ export function Actions() {
>
<Tabs
value={tab}
onChange={(_, value) => setTab(value)}
onValueChange={(value) => setTab(value)}
orientation="vertical"
variant="scrollable"
sx={{ borderRight: 1, borderColor: "divider" }}
className="w-full"
>
{tasks.map((task) => (
<Box
key={task.id}
sx={{
position: "relative",
width: "100%",
"&:hover .rail-edit": { opacity: 1 },
}}
>
<Tab
value={task.id}
label={task.name}
sx={{
alignItems: "flex-start",
justifyContent: "flex-start",
width: 1,
pr: 5,
}}
onClick={() => setTab(task.id)}
onDoubleClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
<Box
sx={{
position: "absolute",
right: 4,
top: "50%",
transform: "translateY(-50%)",
}}
<TabsList variant="line" className="h-fit w-full justify-start">
{tasks.map((task) => (
<div
key={task.id}
className="group relative w-full group-hover:[&_.rail-edit]:opacity-100"
>
<HoverEditButton
onClick={() => {
const initial = {
id: task.id,
name: task.name,
task_type: task.task_type,
content: task.content,
enabled: task.enabled,
default_machine_id: task.default_machine_id,
notes: task.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
/>
</Box>
</Box>
))}
<TabsTrigger
value={task.id}
className="w-full justify-start pr-9"
onClick={() => setTab(task.id)}
onDoubleClick={() => openEdit(initialFromTask(task))}
>
{task.name}
</TabsTrigger>
<div className="absolute top-1/2 right-1 -translate-y-1/2">
<HoverEditButton
onClick={() => openEdit(initialFromTask(task))}
/>
</div>
</div>
))}
</TabsList>
</Tabs>
</SelectionRailCard>
<Stack spacing={2}>
<div className="flex flex-col gap-4">
{editingTask ? (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1.5}>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
flexWrap: "wrap",
<SectionCard
title={editingTask.name}
description="Open the editor popup to modify this action."
action={
<div className="flex flex-row flex-wrap items-center gap-2">
<Button
variant="outline"
onClick={() => openEdit(initialFromTask(editingTask))}
>
Edit
</Button>
<Button
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}}
>
<Box>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
{editingTask.name}
</Typography>
<Typography variant="body2" color="text.secondary">
Open the editor popup to modify this action.
</Typography>
</Box>
<Stack
direction="row"
spacing={1}
sx={{ flexWrap: "wrap" }}
>
<Button
variant="outlined"
onClick={() => {
const initial = {
id: editingTask.id,
name: editingTask.name,
task_type: editingTask.task_type,
content: editingTask.content,
enabled: editingTask.enabled,
default_machine_id: editingTask.default_machine_id,
notes: editingTask.notes,
};
setDraft(initial);
setDraftBaseline(initial);
setEditOpen(true);
}}
>
Edit
</Button>
<Button
variant="contained"
disabled={runTask.isPending || !runMachineId}
onClick={async () => {
await runTask.mutateAsync({
taskId: editingTask.id,
machineId: runMachineId,
});
}}
>
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</Stack>
</Stack>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
{runTask.isPending ? "Running..." : "Run action"}
</Button>
</div>
}
>
<div className="flex flex-wrap items-center gap-2">
<FormField label="Run on machine">
<Select
value={runMachineId}
onValueChange={(value) => setRunMachineId(value)}
>
<FormControl size="small" sx={{ minWidth: 240 }}>
<InputLabel>Run on machine</InputLabel>
<Select
label="Run on machine"
value={runMachineId}
onChange={(e) =>
setRunMachineId(String(e.target.value))
}
>
{machines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
</Select>
</FormControl>
</Stack>
<Divider />
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
Recent runs
</Typography>
{selectedRuns.data?.items?.length ? (
<Stack spacing={1.25}>
{selectedRuns.data.items.map((run) => (
<Card key={run.id} variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Chip
size="small"
variant="outlined"
label={run.status}
/>
<Typography
variant="body2"
color="text.secondary"
>
{run.machine_name} ·{" "}
{new Date(
run.created_at * 1000,
).toLocaleString()}
</Typography>
</Stack>
{run.stdout_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stdout
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stdout_tail}
</Typography>
</Box>
)}
{run.stderr_tail && (
<Box
sx={{
px: 1,
py: 0.75,
border: 1,
borderColor: "divider",
borderRadius: 1,
}}
>
<Typography
variant="caption"
color="text.secondary"
>
stderr
</Typography>
<Typography
variant="body2"
sx={{
fontFamily: "monospace",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
}}
>
{run.stderr_tail}
</Typography>
</Box>
)}
{run.error && (
<Alert severity="error">{run.error}</Alert>
)}
</Stack>
</CardContent>
</Card>
<SelectTrigger className="min-w-[240px]" size="sm">
<SelectValue placeholder="Select machine" />
</SelectTrigger>
<SelectContent>
{machines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</Stack>
) : (
<Alert severity="info">No runs yet.</Alert>
)}
</Stack>
</CardContent>
</Card>
</SelectContent>
</Select>
</FormField>
</div>
<Separator />
<p className="text-sm font-semibold">Recent runs</p>
{selectedRuns.data?.items?.length ? (
<div className="flex flex-col gap-2">
{selectedRuns.data.items.map((run) => (
<Card key={run.id}>
<CardContent className="flex flex-col gap-2 p-3">
<div className="flex flex-row flex-wrap items-center gap-2">
<Badge variant="outline">{run.status}</Badge>
<p className="text-xs text-muted-foreground">
{run.machine_name} ·{" "}
{new Date(run.created_at * 1000).toLocaleString()}
</p>
</div>
{run.stdout_tail && (
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">
stdout
</p>
<p className="whitespace-pre-wrap break-words font-mono text-sm">
{run.stdout_tail}
</p>
</div>
)}
{run.stderr_tail && (
<div className="rounded-lg border border-border px-3 py-2">
<p className="text-xs text-muted-foreground">
stderr
</p>
<p className="whitespace-pre-wrap break-words font-mono text-sm">
{run.stderr_tail}
</p>
</div>
)}
{run.error && (
<Alert variant="destructive">
<AlertDescription>{run.error}</AlertDescription>
</Alert>
)}
</CardContent>
</Card>
))}
</div>
) : (
<Alert>
<AlertDescription>No runs yet.</AlertDescription>
</Alert>
)}
</SectionCard>
) : (
<Stack spacing={2}>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1.25}>
<Typography variant="subtitle1" sx={{ fontWeight: 700 }}>
No action selected
</Typography>
<Typography variant="body2" color="text.secondary">
Select a saved action from the list on the left to view
its details, run it, or open the editor popup. Use the
button at the bottom to add a new action.
</Typography>
<div className="flex flex-col gap-4">
<SectionCard
title="No action selected"
description="Select a saved action from the list on the left to view its details, run it, or open the editor popup. Use the button at the bottom to add a new action."
>
{tasks[0] && (
<Button
variant="outlined"
onClick={() => setTab(tasks[0].id)}
>
<Button variant="outline" onClick={() => setTab(tasks[0].id)}>
Select first action
</Button>
)}
</Stack>
</CardContent>
</Card>
</SectionCard>
<Card variant="outlined">
<CardContent sx={{ p: 2 }}>
<Stack spacing={1}>
<Typography variant="subtitle2" sx={{ fontWeight: 700 }}>
What this panel shows
</Typography>
<Typography variant="body2" color="text.secondary">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</Typography>
</Stack>
</CardContent>
</Card>
</Stack>
<SectionCard title="What this panel shows">
<p className="text-xs text-muted-foreground">
Saved actions stay on the left rail, while details, run
controls, and recent history appear here.
</p>
</SectionCard>
</div>
)}
</Stack>
</Box>
</div>
</div>
<TaskDialog
open={editOpen}
@@ -632,6 +537,6 @@ export function Actions() {
draft.id ? () => deleteTask.mutate(String(draft.id)) : undefined
}
/>
</Stack>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { useParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
GrafanaAddonPage,
PrometheusAddonPage,
SshTasksAddonPage,
} from "../addons";
const ADDON_PAGES: Record<string, React.ComponentType> = {
grafana: GrafanaAddonPage,
prometheus: PrometheusAddonPage,
"ssh-tasks": SshTasksAddonPage,
};
export function AddonPage() {
const { addonId } = useParams<{ addonId: string }>();
const Page = addonId ? ADDON_PAGES[addonId] : undefined;
if (!Page) {
return (
<Alert>
<AlertDescription>Addon "{addonId}" is not installed.</AlertDescription>
</Alert>
);
}
return <Page />;
}
+76 -125
View File
@@ -1,16 +1,8 @@
import { useMemo, useState } from "react";
import {
Alert,
Box,
Card,
CardContent,
Chip,
Grid,
Stack,
Tab,
Typography,
} from "@mui/material";
import { useSearchParams } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { TabsTrigger } from "@/components/ui/tabs";
import { Media } from "./Media";
import { useCounts, useLibraries } from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
@@ -37,110 +29,67 @@ function JellyfinLibraryStats() {
title="Library stats"
description="Compact Jellyfin summary for the selected machine."
action={
<Chip
label={selectedMachineId ? "Selected machine" : "Default machine"}
variant="outlined"
size="small"
/>
<Badge variant="outline">
{selectedMachineId ? "Selected machine" : "Default machine"}
</Badge>
}
>
<Stack spacing={1.25}>
<div className="flex flex-col gap-2">
{counts ? (
<Grid container spacing={1}>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Total
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Movies
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.movies.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Series
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.series.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 6, md: 3 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.25, px: 1.5, textAlign: "center" }}>
<Typography variant="caption" color="text.secondary">
Episodes
</Typography>
<Typography
variant="h6"
sx={{ fontWeight: 800, lineHeight: 1.1 }}
>
{counts.episodes.toLocaleString()}
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
<div className="grid grid-cols-2 gap-2 md:grid-cols-4">
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Total</span>
<div className="text-base leading-tight font-extrabold">
{(
counts.movies +
counts.series +
counts.episodes
).toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Movies</span>
<div className="text-base leading-tight font-extrabold">
{counts.movies.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Series</span>
<div className="text-base leading-tight font-extrabold">
{counts.series.toLocaleString()}
</div>
</div>
<div className="rounded-lg border bg-card px-3 py-2 text-center">
<span className="text-xs text-muted-foreground">Episodes</span>
<div className="text-base leading-tight font-extrabold">
{counts.episodes.toLocaleString()}
</div>
</div>
</div>
) : null}
{libraries?.length ? (
<Grid container spacing={1}>
<div className="grid grid-cols-1 gap-2 md:grid-cols-2">
{libraries.map((library) => (
<Grid key={library.library} size={{ xs: 12, md: 6 }}>
<Card variant="outlined">
<CardContent sx={{ py: 1.1, px: 1.5 }}>
<Stack spacing={0.5}>
<Typography
variant="subtitle2"
sx={{ fontWeight: 700 }}
noWrap
>
{library.library}
</Typography>
<Typography variant="body2" color="text.secondary">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</Typography>
</Stack>
</CardContent>
</Card>
</Grid>
<div
key={library.library}
className="rounded-lg border bg-card px-3 py-2"
>
<div className="flex flex-col gap-1">
<span className="truncate text-sm font-semibold">
{library.library}
</span>
<span className="text-sm text-muted-foreground">
Total {library.total.toLocaleString()} · Movies{" "}
{library.movies.toLocaleString()} · Series{" "}
{library.series.toLocaleString()}
</span>
</div>
</div>
))}
</Grid>
</div>
) : null}
</Stack>
</div>
</SectionCard>
);
}
@@ -149,39 +98,41 @@ export function Applications() {
const [tab, setTab] = useState("jellyfin");
return (
<Stack spacing={2.25}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 800 }}>
Applications
</Typography>
<Typography variant="body2" color="text.secondary">
<div className="flex flex-col gap-4">
<div>
<h1 className="text-lg font-semibold">Applications</h1>
<p className="text-sm text-muted-foreground">
Browse application-specific tools from a compact tabbed workspace.
</Typography>
</Box>
</p>
</div>
<TabbedCard
value={tab}
onChange={setTab}
tabs={[
<Tab key="jellyfin" value="jellyfin" label="Jellyfin" />,
<Tab key="nextcloud" value="nextcloud" label="Nextcloud" />,
<TabsTrigger key="jellyfin" value="jellyfin">
Jellyfin
</TabsTrigger>,
<TabsTrigger key="nextcloud" value="nextcloud">
Nextcloud
</TabsTrigger>,
]}
>
{tab === "jellyfin" ? (
<Stack spacing={2}>
<div className="flex flex-col gap-4">
<JellyfinLibraryStats />
<Media />
</Stack>
</div>
) : (
<Card variant="outlined">
<CardContent sx={{ p: 1.5 }}>
<Alert severity="info">
<div className="rounded-lg border bg-card p-3">
<Alert>
<AlertDescription>
Nextcloud support will be added in a future update.
</Alert>
</CardContent>
</Card>
</AlertDescription>
</Alert>
</div>
)}
</TabbedCard>
</Stack>
</div>
);
}
+260 -284
View File
@@ -1,39 +1,37 @@
import { useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Alert,
Box,
Button,
Card,
CardContent,
Chip,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
FormControl,
FormControlLabel,
FormHelperText,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
Switch,
TextField,
Typography,
} from "@mui/material";
import { useNavigate } from "react-router-dom";
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import {
useActivity,
useDashboardShortcuts,
useDeleteDashboardShortcut,
useSaveDashboardShortcut,
} from "../hooks/useDashboard";
import { useMonitoringSettings } from "../hooks/useSettings";
import { useWidgetInstances } from "../hooks/useWidgets";
import type { DashboardShortcut, DashboardShortcutInput } from "../types";
import { NowPlaying } from "../components/NowPlaying";
import { SectionCard } from "../components/SectionCard";
import { ConfirmDialog } from "../components/ConfirmDialog";
import { DialogFooter } from "../components/DialogFooter";
import BackupDashboardWidget from "../components/BackupDashboardWidget";
import { WidgetInstance } from "../components/WidgetInstance";
import { WidgetConfigDialog } from "../components/WidgetConfigDialog";
function emptyShortcut(): DashboardShortcutInput {
return {
@@ -71,6 +69,28 @@ function shortcutHref(shortcut: DashboardShortcut): string {
return `/users?user=${encodeURIComponent(shortcut.user_id)}`;
}
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 ShortcutDialog({
open,
draft,
@@ -85,129 +105,155 @@ function ShortcutDialog({
onSave: () => void;
}) {
return (
<Dialog open={open} onClose={onClose} fullWidth maxWidth="md">
<DialogTitle>{draft.id ? "Edit shortcut" : "New shortcut"}</DialogTitle>
<DialogContent dividers>
<Stack spacing={1.25} sx={{ pt: 0.25 }}>
<Grid container spacing={1}>
<Grid size={{ xs: 12, sm: 6, md: 5 }}>
<TextField
fullWidth
size="small"
label="Label"
value={draft.label}
onChange={(e) => onChange({ ...draft, label: e.target.value })}
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 2 }}>
<TextField
fullWidth
size="small"
label="Icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
helperText="Emoji or glyph"
/>
</Grid>
<Grid size={{ xs: 12, sm: 3, md: 5 }}>
<FormControl fullWidth size="small">
<InputLabel>Type</InputLabel>
<Select
label="Type"
value={draft.shortcut_type}
<Dialog
open={open}
onOpenChange={(next) => {
if (!next) onClose();
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>
{draft.id ? "Edit shortcut" : "New shortcut"}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-12">
<div className="flex flex-col gap-1.5 sm:col-span-5">
<Field label="Label" htmlFor="shortcut-label">
<Input
id="shortcut-label"
value={draft.label}
onChange={(e) =>
onChange({ ...draft, label: e.target.value })
}
/>
</Field>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<Field
label="Icon"
htmlFor="shortcut-icon"
helper="Emoji or glyph"
>
<Input
id="shortcut-icon"
value={draft.icon}
onChange={(e) => onChange({ ...draft, icon: e.target.value })}
/>
</Field>
</div>
<div className="flex flex-col gap-1.5 sm:col-span-5">
<Field
label="Type"
htmlFor="shortcut-type"
helper="Website opens a URL. Saved actions jump to a task. Users deep-link."
>
<Select
value={draft.shortcut_type}
onValueChange={(value) =>
onChange({
...draft,
shortcut_type: e.target
.value as DashboardShortcutInput["shortcut_type"],
shortcut_type:
value as DashboardShortcutInput["shortcut_type"],
})
}
>
<MenuItem value="website">Website</MenuItem>
<MenuItem value="action">Saved action</MenuItem>
<MenuItem value="user">User</MenuItem>
<SelectTrigger id="shortcut-type" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="website">Website</SelectItem>
<SelectItem value="action">Saved action</SelectItem>
<SelectItem value="user">User</SelectItem>
</SelectContent>
</Select>
<FormHelperText>
Website opens a URL. Saved actions jump to a task. Users
deep-link.
</FormHelperText>
</FormControl>
</Grid>
</Grid>
</Field>
</div>
</div>
{draft.shortcut_type === "website" ? (
<TextField
fullWidth
size="small"
<Field
label="Website URL"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
helperText="https:// is added if missing."
/>
htmlFor="shortcut-url"
helper="https:// is added if missing."
>
<Input
id="shortcut-url"
value={draft.url}
onChange={(e) => onChange({ ...draft, url: e.target.value })}
/>
</Field>
) : draft.shortcut_type === "action" ? (
<Grid container spacing={1.25}>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Task ID"
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
<Field
label="Task ID"
htmlFor="shortcut-task"
helper="Saved action ID."
>
<Input
id="shortcut-task"
value={draft.task_id}
onChange={(e) =>
onChange({ ...draft, task_id: e.target.value })
}
helperText="Saved action ID."
/>
</Grid>
<Grid size={{ xs: 12, md: 6 }}>
<TextField
fullWidth
size="small"
label="Machine ID"
</Field>
<Field
label="Machine ID"
htmlFor="shortcut-machine"
helper="Optional machine target."
>
<Input
id="shortcut-machine"
value={draft.machine_id}
onChange={(e) =>
onChange({ ...draft, machine_id: e.target.value })
}
helperText="Optional machine target."
/>
</Grid>
</Grid>
</Field>
</div>
) : (
<TextField
fullWidth
size="small"
<Field
label="User ID"
value={draft.user_id}
onChange={(e) => onChange({ ...draft, user_id: e.target.value })}
helperText="Jellyfin user ID."
/>
)}
<TextField
fullWidth
size="small"
label="Notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
<FormControlLabel
control={
<Switch
checked={draft.enabled}
htmlFor="shortcut-user"
helper="Jellyfin user ID."
>
<Input
id="shortcut-user"
value={draft.user_id}
onChange={(e) =>
onChange({ ...draft, enabled: e.target.checked })
onChange({ ...draft, user_id: e.target.value })
}
/>
}
label="Enabled"
/>
</Stack>
</Field>
)}
<Field label="Notes" htmlFor="shortcut-notes">
<Input
id="shortcut-notes"
value={draft.notes}
onChange={(e) => onChange({ ...draft, notes: e.target.value })}
/>
</Field>
<div className="flex items-center gap-2">
<Switch
id="shortcut-enabled"
checked={draft.enabled}
onCheckedChange={(checked) =>
onChange({ ...draft, enabled: checked })
}
/>
<Label htmlFor="shortcut-enabled">Enabled</Label>
</div>
</div>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</DialogContent>
<DialogFooter
onCancel={onClose}
onConfirm={onSave}
confirmLabel="Save shortcut"
confirmBusyLabel="Save shortcut"
/>
</Dialog>
);
}
@@ -237,72 +283,42 @@ function ShortcutCard({
: shortcut.user_id || "No user configured";
return (
<Card variant="outlined" sx={{ height: "100%" }}>
<CardContent sx={{ p: 1.25 }}>
<Stack spacing={1}>
<Stack
direction="row"
spacing={1}
sx={{ justifyContent: "space-between", alignItems: "flex-start" }}
<Card className="h-full">
<CardContent className="flex flex-col gap-3 p-3">
<div className="flex flex-row items-start justify-between gap-2">
<div className="min-w-0">
<div className="truncate font-semibold">{shortcut.label}</div>
<div className="truncate text-sm text-muted-foreground">
{subtitle}
</div>
</div>
<div className="flex flex-row items-center gap-2">
{shortcut.icon ? (
<div className="grid size-8 place-items-center rounded-md bg-muted text-lg">
{shortcut.icon}
</div>
) : null}
<Badge variant="outline">{shortcut.shortcut_type}</Badge>
</div>
</div>
{shortcut.notes ? (
<p className="text-xs text-muted-foreground">{shortcut.notes}</p>
) : null}
<div className="flex flex-row flex-wrap gap-2">
<Button
size="sm"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }} noWrap>
{shortcut.label}
</Typography>
<Typography variant="body2" color="text.secondary" noWrap>
{subtitle}
</Typography>
</Box>
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
{shortcut.icon ? (
<Box
sx={{
width: 32,
height: 32,
borderRadius: 1.5,
display: "grid",
placeItems: "center",
bgcolor: "action.hover",
fontSize: 18,
}}
>
{shortcut.icon}
</Box>
) : null}
<Chip
size="small"
variant="outlined"
label={shortcut.shortcut_type}
/>
</Stack>
</Stack>
{shortcut.notes ? (
<Typography variant="caption" color="text.secondary">
{shortcut.notes}
</Typography>
) : null}
<Stack direction="row" spacing={1} sx={{ flexWrap: "wrap" }}>
<Button
size="small"
variant="contained"
disabled={!shortcut.enabled || !href}
onClick={onOpen}
>
Open
</Button>
<Button size="small" variant="outlined" onClick={onEdit}>
Edit
</Button>
<Button
size="small"
variant="outlined"
color="error"
onClick={onDelete}
>
Delete
</Button>
</Stack>
</Stack>
Open
</Button>
<Button size="sm" variant="outline" onClick={onEdit}>
Edit
</Button>
<Button size="sm" variant="destructive" onClick={onDelete}>
Delete
</Button>
</div>
</CardContent>
</Card>
);
@@ -310,19 +326,6 @@ function ShortcutCard({
export function Dashboard() {
const navigate = useNavigate();
const { data: machines = [] } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
machines.filter(
(machine) => machine.enabled && machine.services.includes("jellyfin"),
),
[machines],
);
const [activeJellyfinMachineId, setActiveJellyfinMachineId] =
useState<string>("");
const selectedJellyfinId =
activeJellyfinMachineId || jellyfinMachines[0]?.id || "";
const { data: activity } = useActivity(selectedJellyfinId || undefined);
const { data: shortcuts = [] } = useDashboardShortcuts();
const saveShortcut = useSaveDashboardShortcut();
const deleteShortcut = useDeleteDashboardShortcut();
@@ -331,6 +334,16 @@ export function Dashboard() {
emptyShortcut(),
);
const [deleteShortcutId, setDeleteShortcutId] = useState<string | null>(null);
const [widgetDialogOpen, setWidgetDialogOpen] = useState(false);
const { data: widgetInstances = [] } = useWidgetInstances();
const visibleWidgets = useMemo(
() =>
widgetInstances
.filter((w) => w.enabled)
.sort((a, b) => a.sort_order - b.sort_order),
[widgetInstances],
);
const openCreateShortcut = () => {
setShortcutDraft(emptyShortcut());
@@ -360,82 +373,53 @@ export function Dashboard() {
};
return (
<Stack spacing={2.25}>
<div className="flex flex-col gap-4">
<SectionCard
title="Shortcuts"
description="Quick links to websites today, with room for action and user shortcuts later."
action={
<Button variant="outlined" onClick={openCreateShortcut}>
Add shortcut
</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => setWidgetDialogOpen(true)}>
Edit dashboard
</Button>
<Button variant="outline" onClick={openCreateShortcut}>
Add shortcut
</Button>
</div>
}
>
{shortcuts.length ? (
<Grid container spacing={1.25}>
<div className="grid grid-cols-1 gap-3 md:grid-cols-2 lg:grid-cols-3">
{shortcuts.map((shortcut) => (
<Grid key={shortcut.id} size={{ xs: 12, md: 6, lg: 4 }}>
<ShortcutCard
shortcut={shortcut}
onOpen={() => {
const href = shortcutHref(shortcut);
if (shortcut.shortcut_type === "website") {
window.open(href, "_blank", "noopener,noreferrer");
} else if (href) {
navigate(href);
}
}}
onEdit={() => openEditShortcut(shortcut)}
onDelete={() => setDeleteShortcutId(shortcut.id)}
/>
</Grid>
<ShortcutCard
key={shortcut.id}
shortcut={shortcut}
onOpen={() => {
const href = shortcutHref(shortcut);
if (shortcut.shortcut_type === "website") {
window.open(href, "_blank", "noopener,noreferrer");
} else if (href) {
navigate(href);
}
}}
onEdit={() => openEditShortcut(shortcut)}
onDelete={() => setDeleteShortcutId(shortcut.id)}
/>
))}
</Grid>
</div>
) : (
<Alert severity="info">
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
<Alert>
<AlertDescription>
No shortcuts yet. Add a website now, then add action or user
shortcuts later.
</AlertDescription>
</Alert>
)}
</SectionCard>
<SectionCard
title="Jellyfin activity"
description="Live sessions and idle users from Jellyfin."
action={
jellyfinMachines.length > 1 ? (
<FormControl size="small" sx={{ minWidth: 180 }}>
<Select
value={selectedJellyfinId}
onChange={(e) => setActiveJellyfinMachineId(e.target.value)}
sx={{ fontSize: "0.8rem" }}
>
{jellyfinMachines.map((m) => (
<MenuItem key={m.id} value={m.id}>
{m.name}
</MenuItem>
))}
</Select>
</FormControl>
) : jellyfinMachines.length === 1 ? (
<Chip
label={jellyfinMachines[0].name}
size="small"
variant="outlined"
/>
) : null
}
>
{activity ? (
<NowPlaying
sessions={activity}
onSelectSession={(session) =>
navigate(`/users?user=${encodeURIComponent(session.user)}`)
}
/>
) : null}
</SectionCard>
<BackupDashboardWidget />
{visibleWidgets.map((widget) => (
<WidgetInstance key={widget.id} widget={widget} />
))}
<ShortcutDialog
open={shortcutDialogOpen}
@@ -444,31 +428,23 @@ export function Dashboard() {
onClose={() => setShortcutDialogOpen(false)}
onSave={saveShortcutDraft}
/>
<Dialog
<ConfirmDialog
open={Boolean(deleteShortcutId)}
onClose={() => setDeleteShortcutId(null)}
fullWidth
maxWidth="xs"
>
<DialogTitle>Delete shortcut?</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">
This cannot be undone. The shortcut will be removed from the
dashboard.
</Typography>
</DialogContent>
<DialogFooter
onCancel={() => setDeleteShortcutId(null)}
onConfirm={() => {
if (deleteShortcutId) {
deleteShortcut.mutate(deleteShortcutId);
}
setDeleteShortcutId(null);
}}
confirmLabel="Delete"
confirmColor="error"
/>
</Dialog>
</Stack>
title="Delete shortcut?"
message="This cannot be undone. The shortcut will be removed from the dashboard."
confirmLabel="Delete"
onCancel={() => setDeleteShortcutId(null)}
onConfirm={() => {
if (deleteShortcutId) {
deleteShortcut.mutate(deleteShortcutId);
}
setDeleteShortcutId(null);
}}
/>
<WidgetConfigDialog
open={widgetDialogOpen}
onClose={() => setWidgetDialogOpen(false)}
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
+370 -311
View File
@@ -1,24 +1,28 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { DataGrid } from "@mui/x-data-grid";
import type { GridColDef } from "@mui/x-data-grid";
import type {
ColumnDef,
OnChangeFn,
PaginationState,
RowSelectionState,
VisibilityState,
} from "@tanstack/react-table";
import { DataTable } from "@/components/ui/data-table";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Progress } from "@/components/ui/progress";
import {
Alert,
Box,
Button,
Card,
CardContent,
LinearProgress,
FormControl,
Grid,
InputLabel,
MenuItem,
Select,
Stack,
TextField,
Typography,
useMediaQuery,
} from "@mui/material";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
useMediaStatus,
useMediaQuery as useMediaDataQuery,
@@ -42,7 +46,49 @@ function formatDuration(seconds: number | null | undefined): string {
return `${secs}s`;
}
// Design §3.2 + §3.4: the 15 locked media columns. Module-level constant so the
// TanStack table instance stays stable — an unstable columns array drops the
// controlled selection/visibility state (7a discovery). Visibility-only parity
// (design §3.3): NO sorting, NO sizing/resizing is wired anywhere.
const mediaColumns: ColumnDef<MediaItem>[] = [
{ accessorKey: "title", header: "Title" },
{ accessorKey: "series", header: "Series" },
{ accessorKey: "season", header: "Season" },
{ accessorKey: "episode", header: "Episode" },
{ accessorKey: "type", header: "Type" },
{ accessorKey: "year", header: "Year" },
{ accessorKey: "runtime_min", header: "Runtime" },
{ accessorKey: "size", header: "Size" },
{ accessorKey: "bitrate", header: "Bitrate" },
{ accessorKey: "hdr", header: "HDR" },
{ accessorKey: "video", header: "Video codec" },
{ accessorKey: "resolution", header: "Resolution" },
{ accessorKey: "date_added", header: "Date added" },
{ accessorKey: "library", header: "Library" },
{ accessorKey: "path", header: "Path" },
];
// Stable path-derived identity so row selection survives server-driven paging
// (design §3.4): the id is the item's filesystem path, which is stable across
// limit/offset page changes.
function getMediaRowId(row: MediaItem): string {
return row.path;
}
const MEDIA_TAB_STATE_KEY = "manage.media.tabState";
const SMALL_BREAKPOINT = "(max-width: 900px)";
// Mirrors the pre-rework DataGrid `columnVisibilityModel` mobile override.
const MOBILE_HIDDEN_COLUMNS = [
"series",
"season",
"episode",
"bitrate",
"video",
"resolution",
"date_added",
"library",
"path",
];
type MediaTabState = {
search: string;
@@ -51,6 +97,8 @@ type MediaTabState = {
sortKey: string;
sortOrder: string;
offset: number;
pageSize: number;
columnVisibility: Record<string, boolean>;
};
function defaultMediaTabState(): MediaTabState {
@@ -61,13 +109,75 @@ function defaultMediaTabState(): MediaTabState {
sortKey: "title",
sortOrder: "Ascending",
offset: 0,
pageSize: 100,
columnVisibility: {},
};
}
function usePrefersSmallScreen(): boolean {
const supportsMatchMedia =
typeof window !== "undefined" && typeof window.matchMedia === "function";
const [small, setSmall] = useState(() =>
supportsMatchMedia ? window.matchMedia(SMALL_BREAKPOINT).matches : false,
);
useEffect(() => {
if (!supportsMatchMedia) return;
const mql = window.matchMedia(SMALL_BREAKPOINT);
const onChange = () => setSmall(mql.matches);
mql.addEventListener("change", onChange);
return () => mql.removeEventListener("change", onChange);
}, [supportsMatchMedia]);
return small;
}
function FilterSelect({
id,
label,
value,
onChange,
options,
}: {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
options: { value: string; label: string }[];
}) {
return (
<div className="flex flex-col gap-1.5">
<Label htmlFor={id}>{label}</Label>
<Select value={value} onValueChange={onChange}>
<SelectTrigger id={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}
// LinearProgress → Progress: determinate value drives the shadcn Progress; the
// indeterminate (null) case renders a pulsing bar, preserving the pre-rework
// "indeterminate" affordance for unknown build progress.
function BuildProgress({ value }: { value: number | null }) {
if (value == null) {
return (
<div className="h-1 w-full animate-pulse rounded-full bg-muted-foreground/30" />
);
}
return <Progress value={Math.max(0, Math.min(100, value * 100))} />;
}
export function Media() {
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const isMobile = useMediaQuery("(max-width: 900px)");
const isSmall = usePrefersSmallScreen();
const { data: machines } = useMonitoringSettings();
const jellyfinMachines = useMemo(
() =>
@@ -87,14 +197,22 @@ export function Media() {
selectedMachineId || undefined,
);
const [mediaState, setMediaState] = usePersistentState<MediaTabState>(
const [rawMediaState, setMediaState] = usePersistentState<MediaTabState>(
MEDIA_TAB_STATE_KEY,
defaultMediaTabState,
);
const { search, types, hdrFilter, sortKey, sortOrder, offset } = mediaState;
// Backward-compat: merge defaults so older persisted state (pre-7b shape,
// without pageSize/columnVisibility) never yields undefined fields.
const mediaState: MediaTabState = {
...defaultMediaTabState(),
...rawMediaState,
};
const { search, types, hdrFilter, sortKey, sortOrder, offset, pageSize } =
mediaState;
const updateMediaState = (patch: Partial<MediaTabState>) =>
setMediaState((current) => ({ ...current, ...patch }));
const limit = 100;
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
useEffect(() => {
if (!searchParams.get("machine_id") && selectedMachineId) {
@@ -115,43 +233,64 @@ export function Media() {
hdr_filter: hdrFilter,
sort_key: sortKey,
sort_order: sortOrder,
limit,
limit: pageSize,
offset,
machineId: selectedMachineId || undefined,
enabled: status?.exists ?? false,
});
const columns: GridColDef<MediaItem>[] = [
{ field: "title", headerName: "Title", minWidth: 180, flex: 1.2 },
{ field: "series", headerName: "Series", minWidth: 140, flex: 1 },
{ field: "season", headerName: "Season", width: 90 },
{ field: "episode", headerName: "Episode", width: 100 },
{ field: "type", headerName: "Type", width: 100 },
{ field: "year", headerName: "Year", width: 90 },
{ field: "runtime_min", headerName: "Runtime", width: 110 },
{ field: "size", headerName: "Size", width: 120 },
{ field: "bitrate", headerName: "Bitrate", width: 130 },
{ field: "hdr", headerName: "HDR", width: 80 },
{ field: "video", headerName: "Video codec", width: 130 },
{ field: "resolution", headerName: "Resolution", width: 120 },
{ field: "date_added", headerName: "Date added", width: 120 },
{ field: "library", headerName: "Library", width: 140 },
{ field: "path", headerName: "Path", minWidth: 240, flex: 1.2 },
];
// Server-driven pagination (design §3.4): pageIndex/pageSize lift into the
// persistent media state and drive useMediaQuery { limit, offset }.
const pageIndex = Math.floor(offset / pageSize);
const pagination: PaginationState = { pageIndex, pageSize };
const rows = useMemo(
() =>
(queryResult?.items ?? []).map((item) => ({
...item,
id: item.id || item.path,
})),
[queryResult],
);
const handlePaginationChange: OnChangeFn<PaginationState> = (updater) => {
const next =
typeof updater === "function"
? updater({ pageIndex, pageSize })
: updater;
const nextPageSize = next.pageSize || pageSize;
// Restart at page 0 whenever the page size changes (keeps offset sane
// under server-driven paging).
const nextOffset =
nextPageSize !== pageSize ? 0 : next.pageIndex * nextPageSize;
setMediaState((current) => ({
...current,
offset: nextOffset,
pageSize: nextPageSize,
}));
};
const handleColumnVisibilityChange: OnChangeFn<VisibilityState> = (
updater,
) => {
setMediaState((current) => {
const prev = current.columnVisibility ?? {};
const next = typeof updater === "function" ? updater(prev) : updater;
return { ...current, columnVisibility: next };
});
};
// On small screens force the same set of columns hidden as the pre-rework
// DataGrid `columnVisibilityModel` mobile override; on desktop the user
// toggles freely (the toggleable set still equals the locked 15).
const effectiveColumnVisibility = useMemo(() => {
const base = mediaState.columnVisibility ?? {};
if (!isSmall) return base;
const merged = { ...base };
for (const key of MOBILE_HIDDEN_COLUMNS) merged[key] = false;
return merged;
}, [mediaState.columnVisibility, isSmall]);
// Preserved exactly from the DataGrid onRowClick: opens the file browser at
// the item's path.
const handleRowClick = (row: MediaItem) => {
navigate(`/files?path=${encodeURIComponent(row.path)}`);
};
const total = queryResult?.total ?? 0;
const totalPages = queryResult ? Math.max(1, Math.ceil(total / pageSize)) : 1;
const page = Math.floor(offset / limit) + 1;
const totalPages = queryResult
? Math.max(1, Math.ceil(queryResult.total / limit))
: 1;
const buildRunning = status?.build_running ?? false;
const buildProgress = status?.build_progress ?? null;
const buildLibraryProgress = status?.build_library_progress ?? null;
@@ -180,58 +319,61 @@ export function Media() {
: "Current library");
return (
<Stack spacing={2}>
<Stack
direction="row"
spacing={1.5}
sx={{ alignItems: "center", flexWrap: "wrap" }}
>
<Typography variant="h5">Jellyfin</Typography>
<FormControl size="small" sx={{ minWidth: 220 }}>
<InputLabel>Machine</InputLabel>
<div className="flex flex-col gap-4">
<div className="flex flex-row flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold">Jellyfin</h2>
<div className="flex flex-col gap-1.5">
<Label htmlFor="media-machine">Machine</Label>
<Select
label="Machine"
value={selectedMachineId}
onChange={(e) =>
onValueChange={(value) =>
setSearchParams(
(current) => {
const next = new URLSearchParams(current);
next.set("machine_id", String(e.target.value));
next.set("machine_id", value);
return next;
},
{ replace: true },
)
}
>
{jellyfinMachines.map((machine) => (
<MenuItem key={machine.id} value={machine.id}>
{machine.name}
</MenuItem>
))}
<SelectTrigger id="media-machine" className="w-full md:w-[220px]">
<SelectValue placeholder="Select a machine" />
</SelectTrigger>
<SelectContent>
{jellyfinMachines.map((machine) => (
<SelectItem key={machine.id} value={machine.id}>
{machine.name}
</SelectItem>
))}
</SelectContent>
</Select>
</FormControl>
</div>
{status?.exists ? (
<Typography variant="body2" color="text.secondary">
<p className="text-sm text-muted-foreground">
Index: {status.item_count.toLocaleString()} items
{status.updated_at_label
? ` | updated ${status.updated_at_label}`
: ""}
</Typography>
</p>
) : (
<Alert severity="warning" sx={{ py: 0 }}>
No index built yet.
<Alert variant="destructive" className="py-0">
<AlertDescription>No index built yet.</AlertDescription>
</Alert>
)}
{counts && (
<Typography variant="body2" color="text.secondary">
<p className="text-sm text-muted-foreground">
Library stats: {counts.movies.toLocaleString()} movies ·{" "}
{counts.series.toLocaleString()} series ·{" "}
{counts.episodes.toLocaleString()} episodes ·{" "}
{(libraries?.length ?? 0).toLocaleString()} libraries
</Typography>
</p>
)}
<Button
variant="outlined"
variant="outline"
onClick={() => buildIndex.mutate()}
disabled={
buildIndex.isPending || buildRunning || buildCancelRequested
@@ -242,8 +384,7 @@ export function Media() {
{buildRunning && (
<>
<Button
variant="outlined"
color="error"
variant="destructive"
onClick={() => stopBuildIndex.mutate()}
disabled={stopBuildIndex.isPending || buildCancelRequested}
>
@@ -252,8 +393,8 @@ export function Media() {
: "Stop build"}
</Button>
<Button
variant="outlined"
color="warning"
variant="outline"
className="border-chart-3/40 text-chart-3 hover:bg-chart-3/10"
onClick={() => forceStopBuildIndex.mutate()}
disabled={forceStopBuildIndex.isPending}
>
@@ -263,248 +404,166 @@ export function Media() {
</Button>
</>
)}
{(buildRunning || status?.build_error) && (
<Box sx={{ width: "100%", minWidth: 260, flexBasis: "100%" }}>
<Stack spacing={1}>
<Typography
variant="body2"
color={status?.build_error ? "error" : "text.secondary"}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</Typography>
</div>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildProgress != null ? "determinate" : "indeterminate"
}
value={
buildProgress != null
? Math.max(0, Math.min(100, buildProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</Typography>
</Stack>
{(buildRunning || status?.build_error) && (
<div className="flex w-full min-w-[260px] flex-col gap-2">
<p
className={
status?.build_error
? "text-sm text-destructive"
: "text-sm text-muted-foreground"
}
>
{buildLabel ||
(buildRunning
? "Building media index..."
: status?.build_error || "")}
</p>
<Stack spacing={0.35}>
<Typography variant="caption" color="text.secondary">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</Typography>
<LinearProgress
variant={
buildLibraryProgress != null
? "determinate"
: "indeterminate"
}
value={
buildLibraryProgress != null
? Math.max(0, Math.min(100, buildLibraryProgress * 100))
: undefined
}
/>
<Typography variant="caption" color="text.secondary">
{status?.build_library_items_processed?.toLocaleString() ?? 0}
/{status?.build_library_items_total?.toLocaleString() ?? 0}{" "}
items
</Typography>
</Stack>
</Stack>
</Box>
)}
</Stack>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
Overall:{" "}
{buildProgress != null
? `${Math.round(buildProgress * 100)}%`
: "pending"}
{buildRunning
? ` • elapsed ${elapsedLabel} • eta ${etaLabel}`
: ""}
</p>
<BuildProgress value={buildProgress} />
<p className="text-xs text-muted-foreground">
{status?.build_items_processed?.toLocaleString() ?? 0}/
{status?.build_items_total?.toLocaleString() ?? 0} items
</p>
</div>
<Card variant="outlined">
<CardContent>
<Grid container spacing={1.5}>
<Grid size={{ xs: 12, md: 4 }}>
<TextField
fullWidth
label="Search"
size="small"
value={search}
onChange={(e) => {
updateMediaState({ search: e.target.value, offset: 0 });
}}
/>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Types</InputLabel>
<Select
label="Types"
value={types}
onChange={(e) => {
updateMediaState({ types: e.target.value, offset: 0 });
}}
>
<MenuItem value="Movie,Episode">Movies + Episodes</MenuItem>
<MenuItem value="Movie">Movies only</MenuItem>
<MenuItem value="Episode">Episodes only</MenuItem>
<MenuItem value="Movie,Episode,Video">All video</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>HDR</InputLabel>
<Select
label="HDR"
value={hdrFilter}
onChange={(e) => {
updateMediaState({ hdrFilter: e.target.value, offset: 0 });
}}
>
<MenuItem value="All">All</MenuItem>
<MenuItem value="HDR only">HDR only</MenuItem>
<MenuItem value="SDR/unknown only">SDR/unknown only</MenuItem>
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Sort</InputLabel>
<Select
label="Sort"
value={sortKey}
onChange={(e) =>
updateMediaState({ sortKey: e.target.value })
}
>
{[
["title", "Title"],
["series", "Series"],
["size", "Size"],
["bitrate", "Bitrate"],
["runtime", "Runtime"],
["year", "Year"],
["date_added", "Date added"],
["resolution", "Resolution"],
].map(([k, l]) => (
<MenuItem key={k} value={k}>
{l}
</MenuItem>
))}
</Select>
</FormControl>
</Grid>
<Grid size={{ xs: 6, md: 2 }}>
<FormControl fullWidth size="small">
<InputLabel>Order</InputLabel>
<Select
label="Order"
value={sortOrder}
onChange={(e) =>
updateMediaState({ sortOrder: e.target.value })
}
>
<MenuItem value="Ascending">Ascending</MenuItem>
<MenuItem value="Descending">Descending</MenuItem>
</Select>
</FormControl>
</Grid>
</Grid>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">
Current: {libraryLabel}
{buildRunning
? ` • elapsed ${libraryElapsedLabel} • eta ${libraryEtaLabel}`
: ""}
</p>
<BuildProgress value={buildLibraryProgress} />
<p className="text-xs text-muted-foreground">
{status?.build_library_items_processed?.toLocaleString() ?? 0}/
{status?.build_library_items_total?.toLocaleString() ?? 0} items
</p>
</div>
</div>
)}
<Card>
<CardContent className="grid grid-cols-1 gap-4 md:grid-cols-12">
<div className="col-span-1 flex flex-col gap-1.5 md:col-span-4">
<Label htmlFor="media-search">Search</Label>
<Input
id="media-search"
value={search}
onChange={(e) =>
updateMediaState({ search: e.target.value, offset: 0 })
}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-types"
label="Types"
value={types}
onChange={(value) =>
updateMediaState({ types: value, offset: 0 })
}
options={[
{ value: "Movie,Episode", label: "Movies + Episodes" },
{ value: "Movie", label: "Movies only" },
{ value: "Episode", label: "Episodes only" },
{ value: "Movie,Episode,Video", label: "All video" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-hdr"
label="HDR"
value={hdrFilter}
onChange={(value) =>
updateMediaState({ hdrFilter: value, offset: 0 })
}
options={[
{ value: "All", label: "All" },
{ value: "HDR only", label: "HDR only" },
{ value: "SDR/unknown only", label: "SDR/unknown only" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-sort"
label="Sort"
value={sortKey}
onChange={(value) => updateMediaState({ sortKey: value })}
options={[
{ value: "title", label: "Title" },
{ value: "series", label: "Series" },
{ value: "size", label: "Size" },
{ value: "bitrate", label: "Bitrate" },
{ value: "runtime", label: "Runtime" },
{ value: "year", label: "Year" },
{ value: "date_added", label: "Date added" },
{ value: "resolution", label: "Resolution" },
]}
/>
</div>
<div className="col-span-6 md:col-span-2">
<FilterSelect
id="media-order"
label="Order"
value={sortOrder}
onChange={(value) => updateMediaState({ sortOrder: value })}
options={[
{ value: "Ascending", label: "Ascending" },
{ value: "Descending", label: "Descending" },
]}
/>
</div>
</CardContent>
</Card>
{queryResult && (
<Typography variant="caption" color="text.secondary">
Showing {queryResult.items.length} of{" "}
{queryResult.total.toLocaleString()} items | Page {page} of{" "}
{totalPages}
</Typography>
<p className="text-xs text-muted-foreground">
Showing {queryResult.items.length} of {total.toLocaleString()} items |
Page {pageIndex + 1} of {totalPages}
</p>
)}
{status?.exists && (
<Box
sx={{
height: 640,
bgcolor: "background.paper",
border: 1,
borderColor: "divider",
borderRadius: 2,
}}
>
<DataGrid
rows={rows}
columns={columns}
loading={isLoading}
checkboxSelection={false}
disableRowSelectionOnClick
onRowClick={(params) => {
const row = params.row as MediaItem;
navigate(`/files?path=${encodeURIComponent(row.path)}`);
}}
pageSizeOptions={[100]}
columnVisibilityModel={
isMobile
? {
series: false,
season: false,
episode: false,
bitrate: false,
video: false,
resolution: false,
date_added: false,
library: false,
path: false,
}
: undefined
<div className="rounded-lg border bg-card">
<DataTable
columns={mediaColumns}
data={queryResult?.items ?? []}
getRowId={getMediaRowId}
enableRowSelection
rowSelection={rowSelection}
onRowSelectionChange={setRowSelection}
onRowClick={handleRowClick}
enableColumnVisibilityToggle
columnVisibility={effectiveColumnVisibility}
onColumnVisibilityChange={handleColumnVisibilityChange}
enablePagination
manualPagination
pagination={pagination}
onPaginationChange={handlePaginationChange}
pageSizeOptions={[50, 100, 200]}
rowCount={total}
emptyMessage={
isLoading
? "Loading media..."
: "No media items match these filters."
}
hideFooter
sx={{
"& .MuiDataGrid-columnHeaders": {
fontWeight: 700,
backgroundColor: "action.hover",
},
}}
/>
</Box>
</div>
)}
{queryResult && totalPages > 1 && (
<Stack direction="row" spacing={1} sx={{ alignItems: "center" }}>
<Button
variant="outlined"
size="small"
onClick={() =>
updateMediaState({ offset: Math.max(0, offset - limit) })
}
disabled={page <= 1}
>
Prev
</Button>
<Typography variant="body2">
Page {page} / {totalPages}
</Typography>
<Button
variant="outlined"
size="small"
onClick={() => updateMediaState({ offset: offset + limit })}
disabled={page >= totalPages}
>
Next
</Button>
</Stack>
)}
</Stack>
</div>
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Actions } from "../Actions";
import type { MonitoringMachine, SavedTask } from "../../types";
const saveTaskMutate = vi.fn().mockResolvedValue({
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "",
enabled: true,
default_machine_id: "",
notes: "",
});
const deleteTaskMutate = vi.fn();
const runTaskMutate = vi.fn().mockResolvedValue({});
let machines: MonitoringMachine[] = [];
let tasks: SavedTask[] = [];
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
useTasks: () => ({ data: tasks }),
useSaveTask: () => ({ mutateAsync: saveTaskMutate, isPending: false }),
useDeleteTask: () => ({ mutate: deleteTaskMutate }),
useRunTask: () => ({ mutateAsync: runTaskMutate, isPending: false }),
useTaskRuns: () => ({ data: { items: [] } }),
}));
function machine(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "m1",
name: "This machine",
mode: "local",
enabled: true,
services: ["monitoring", "files"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
} as MonitoringMachine;
}
function task(overrides: Partial<SavedTask> = {}): SavedTask {
return {
id: "t1",
name: "Restart svc",
task_type: "shell",
content: "systemctl restart foo",
enabled: true,
default_machine_id: "",
notes: "",
created_at: 0,
updated_at: 0,
...overrides,
} as SavedTask;
}
beforeEach(() => {
saveTaskMutate.mockClear();
deleteTaskMutate.mockClear();
runTaskMutate.mockClear();
machines = [];
tasks = [];
});
describe("Actions", () => {
it("shows the empty state and creates a task via the editor dialog", async () => {
render(<Actions />);
expect(screen.getByText("No action selected")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add action" }),
).toBeInTheDocument();
await userEvent.click(screen.getByRole("button", { name: "Add action" }));
// Editor dialog opened (Name field is unique to the editor).
expect(screen.getByLabelText("Name")).toBeInTheDocument();
// Controlled input parity: name + default shell type flow through.
await userEvent.type(screen.getByLabelText("Name"), "Restart svc");
await userEvent.click(screen.getByRole("button", { name: "Save action" }));
expect(saveTaskMutate).toHaveBeenCalledTimes(1);
const saved = saveTaskMutate.mock.calls[0][0];
expect(saved.name).toBe("Restart svc");
expect(saved.task_type).toBe("shell");
});
it("disables the Run button until a run machine is selected", async () => {
machines = [machine()];
tasks = [task()];
render(<Actions />);
// Selecting a saved task tab exposes the detail + Run control.
await userEvent.click(screen.getByRole("tab", { name: "Restart svc" }));
const runButton = screen.getByRole("button", { name: "Run action" });
expect(runButton).toBeDisabled();
});
});
@@ -0,0 +1,67 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { Applications } from "../Applications";
// Applications still embeds the MUI Media child (migrated in slice 7). Mock it
// so this slice-4 test stays focused on the migrated Applications shell and
// does not pull the still-MUI DataGrid into the jsdom render.
vi.mock("../Media", () => ({
Media: () => <div data-testid="media-child">Media</div>,
}));
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({
data: [
{
id: "m1",
name: "Main",
enabled: true,
services: ["jellyfin"],
},
],
}),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({
data: { movies: 10, series: 5, episodes: 100 },
}),
useLibraries: () => ({
data: [
{ library: "Movies", total: 10, movies: 10, series: 0 },
{ library: "Shows", total: 5, movies: 0, series: 5 },
],
}),
}));
describe("Applications", () => {
it("renders the Jellyfin library counts grid and tabs, and keeps the Media child", () => {
render(<Applications />);
// Library stats header.
expect(screen.getByText("Library stats")).toBeInTheDocument();
// Counts: Total = 10 + 5 + 100 = 115, plus the per-type counts.
expect(screen.getByText("115")).toBeInTheDocument();
expect(screen.getByText("Episodes")).toBeInTheDocument();
// Library rows render their per-library totals (unique strings).
expect(
screen.getByText(/Total 10 · Movies 10 · Series 0/),
).toBeInTheDocument();
expect(
screen.getByText(/Total 5 · Movies 0 · Series 5/),
).toBeInTheDocument();
// Tabs present.
expect(screen.getByRole("tab", { name: "Jellyfin" })).toBeInTheDocument();
expect(screen.getByRole("tab", { name: "Nextcloud" })).toBeInTheDocument();
// The still-MUI Media child is rendered unchanged inside the Jellyfin tab.
expect(screen.getByTestId("media-child")).toBeInTheDocument();
});
});
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Dashboard } from "../Dashboard";
import type { DashboardShortcut } from "../../types";
// Stub the composed widgets so the test exercises Dashboard's own behavior
// (shortcut CRUD) without rendering the session panel or the backup query.
vi.mock("../../components/NowPlaying", () => ({
NowPlaying: () => <div data-testid="now-playing-stub" />,
}));
vi.mock("../../components/BackupDashboardWidget", () => ({
default: () => <div data-testid="backup-widget-stub" />,
}));
const navigate = vi.fn();
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [] }),
}));
const saveShortcutMutate = vi.fn().mockResolvedValue({});
const deleteShortcutMutate = vi.fn();
let shortcuts: DashboardShortcut[] = [];
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: undefined }),
useDashboardShortcuts: () => ({ data: shortcuts }),
useSaveDashboardShortcut: () => ({ mutateAsync: saveShortcutMutate }),
useDeleteDashboardShortcut: () => ({ mutate: deleteShortcutMutate }),
}));
function websiteShortcut(
overrides: Partial<DashboardShortcut> = {},
): DashboardShortcut {
return {
id: "s1",
label: "Wiki",
shortcut_type: "website",
enabled: true,
icon: "📚",
url: "example.com",
task_id: "",
machine_id: "",
user_id: "",
notes: "Team wiki",
created_at: 0,
updated_at: 0,
...overrides,
} as DashboardShortcut;
}
beforeEach(() => {
navigate.mockReset();
saveShortcutMutate.mockClear();
deleteShortcutMutate.mockClear();
shortcuts = [];
});
describe("Dashboard", () => {
it("shows the empty-state alert when there are no shortcuts", () => {
render(<Dashboard />);
expect(screen.getByText(/No shortcuts yet/)).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Add shortcut" }),
).toBeInTheDocument();
});
it("renders a shortcut card and deletes it via the confirm dialog", async () => {
shortcuts = [websiteShortcut()];
render(<Dashboard />);
expect(screen.getByText("Wiki")).toBeInTheDocument();
// Open the delete confirm.
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(screen.getByText("Delete shortcut?")).toBeInTheDocument();
// Confirm deletion -> delete mutation fires with the shortcut id.
const dialogs = screen.getAllByRole("button", { name: "Delete" });
// The card "Delete" plus the confirm "Delete"; confirm is the last one.
await userEvent.click(dialogs[dialogs.length - 1]);
expect(deleteShortcutMutate).toHaveBeenCalledTimes(1);
expect(deleteShortcutMutate).toHaveBeenCalledWith("s1");
});
it("creates a shortcut via the dialog and saves it", async () => {
render(<Dashboard />);
await userEvent.click(screen.getByRole("button", { name: "Add shortcut" }));
// Edit dialog opens in "New shortcut" mode.
expect(screen.getByText("New shortcut")).toBeInTheDocument();
// Fill the label and save.
await userEvent.type(screen.getByLabelText("Label"), "Grafana");
await userEvent.click(
screen.getByRole("button", { name: "Save shortcut" }),
);
expect(saveShortcutMutate).toHaveBeenCalledTimes(1);
const saved = saveShortcutMutate.mock.calls[0][0];
expect(saved.label).toBe("Grafana");
expect(saved.shortcut_type).toBe("website");
});
});
@@ -0,0 +1,127 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileBrowser } from "../FileBrowser.impl";
import type { DirectoryListing, MonitoringMachine } from "../../types";
// usePersistentState (browserState) reads/writes localStorage; clear between tests
// so the selectedPath / currentDir state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
});
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["files", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
}
function listingFixture(
entries: {
name: string;
type: string;
size: number;
mtime: number;
}[],
): DirectoryListing {
return { path: "/", entries, count: entries.length };
}
let listing: DirectoryListing;
let machines: MonitoringMachine[];
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(), vi.fn()],
useNavigate: () => vi.fn(),
}));
vi.mock("../../hooks/useFiles", () => ({
useDirectoryListing: () => ({
data: listing,
isLoading: false,
error: null,
refetch: vi.fn(),
}),
useFfprobe: () => ({ data: undefined, isLoading: false, error: null }),
useJobTemplates: () => ({ data: [] }),
useRunJob: () => ({ isPending: false, mutate: vi.fn(), data: undefined }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
}));
beforeEach(() => {
machines = [machineFixture()];
listing = listingFixture([
{ name: "movies", type: "d", size: 0, mtime: 1_700_000_000 },
{ name: "video.mkv", type: "f", size: 1_500_000_000, mtime: 1_700_000_000 },
{ name: "notes.txt", type: "f", size: 12, mtime: 1_700_000_000 },
]);
});
describe("FileBrowser (slice 7a — TanStack DataTable parity)", () => {
it("renders the 5 locked columns (type/name/ext/size/modified)", () => {
render(<FileBrowser />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => h.textContent);
// The leading selection column header is empty (checkbox); the 5 data
// columns are Type, Name, Ext, Size, Modified in that order.
expect(headers).toEqual(
expect.arrayContaining(["Type", "Name", "Ext", "Size", "Modified"]),
);
expect(headers.filter((h) => h === "Type").length).toBe(1);
expect(headers.filter((h) => h === "Modified").length).toBe(1);
});
it("clicking a file row selects it for ffprobe preview (Media info)", async () => {
render(<FileBrowser />);
// The selected-file path surfaces in the Browser status caption once chosen.
expect(screen.queryByText(/Selected: \/video\.mkv/)).toBeNull();
await userEvent.click(screen.getByText("video.mkv"));
expect(screen.getByText(/Selected: \/video\.mkv/)).toBeInTheDocument();
// A recognized video file enters the ffprobe branch; with empty ffprobe
// data it shows the "No ffprobe data available." status (proving the
// selected file routed into the Media info preview flow).
expect(screen.getByText("No ffprobe data available.")).toBeInTheDocument();
});
it("clicking a directory row navigates into it (no ffprobe selection)", async () => {
render(<FileBrowser />);
await userEvent.click(screen.getByText("movies"));
// After navigating into /movies, the status caption shows the new cwd and
// NO "Selected:" segment (directories are opened, not selected for preview).
expect(screen.getByText(/Current: \/movies\b/)).toBeInTheDocument();
expect(screen.queryByText(/Selected:/)).toBeNull();
});
});
+259
View File
@@ -0,0 +1,259 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Media } from "../Media";
import type {
MediaIndexStatus,
MediaItem,
MediaQueryResponse,
MonitoringMachine,
} from "../../types";
// Shared navigate mock so the row-click test can assert the call. The vi.mock
// factory is hoisted above this const, but it only closes over `navigate`
// lazily (the arrow runs at render time, well after init) — no TDZ access.
const navigate = vi.fn();
function machineFixture(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "local",
name: "Local",
mode: "local",
enabled: true,
services: ["jellyfin", "monitoring"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "",
...overrides,
};
}
function statusFixture(
overrides: Partial<MediaIndexStatus> = {},
): MediaIndexStatus {
return {
exists: true,
item_count: 2,
updated_at: 1,
updated_at_label: "now",
build_duration_seconds: null,
build_running: false,
build_stage: "",
build_message: "",
build_progress: null,
build_items_processed: 0,
build_items_total: 0,
build_current_library: "",
build_library_index: 0,
build_libraries_total: 0,
build_library_progress: null,
build_library_items_processed: 0,
build_library_items_total: 0,
build_elapsed_seconds: null,
build_eta_seconds: null,
build_library_elapsed_seconds: null,
build_library_eta_seconds: null,
build_cancel_requested: false,
build_pid: null,
build_error: "",
...overrides,
};
}
function mediaItem(overrides: Partial<MediaItem> = {}): MediaItem {
return {
id: "1",
title: "Inception",
series: "",
season: "",
episode: null,
type: "Movie",
year: 2010,
runtime_min: 148,
size: "12.4 GB",
bitrate: "35.0 Mbps",
hdr: "HDR10",
video: "HEVC",
resolution: "4K",
date_added: "2024-01-01",
library: "Movies",
path: "/media/movies/Inception.mkv",
...overrides,
};
}
let status: MediaIndexStatus;
let queryResult: MediaQueryResponse;
vi.mock("react-router-dom", () => ({
useNavigate: () => navigate,
useSearchParams: () => [new URLSearchParams("machine_id=local"), vi.fn()],
}));
vi.mock("../../hooks/useMedia", () => ({
useMediaStatus: () => ({ data: status }),
useMediaQuery: () => ({ data: queryResult, isLoading: false }),
useBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
useForceStopBuildIndex: () => ({ isPending: false, mutate: vi.fn() }),
}));
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: [machineFixture()] }),
}));
vi.mock("../../hooks/useDashboard", () => ({
useCounts: () => ({ data: undefined }),
useLibraries: () => ({ data: undefined }),
}));
// usePersistentState reads/writes localStorage; clear between tests so the
// offset/pageSize/columnVisibility state never leaks across cases.
beforeEach(() => {
window.localStorage.clear();
navigate.mockClear();
status = statusFixture();
queryResult = {
items: [
mediaItem({
id: "1",
title: "Inception",
path: "/media/movies/Inception.mkv",
}),
mediaItem({
id: "2",
title: "Matrix",
path: "/media/movies/Matrix.mkv",
}),
],
total: 2,
limit: 100,
offset: 0,
};
});
describe("Media (slice 7b — TanStack DataTable + server-driven pagination)", () => {
it("exposes exactly the 15 locked toggleable columns", async () => {
render(<Media />);
await userEvent.click(screen.getByRole("button", { name: /Columns/ }));
const toggleable = screen
.getAllByRole("menuitemcheckbox")
.map((item) => (item.textContent ?? "").trim());
expect([...toggleable].sort()).toEqual(
[
"title",
"series",
"season",
"episode",
"type",
"year",
"runtime_min",
"size",
"bitrate",
"hdr",
"video",
"resolution",
"date_added",
"library",
"path",
].sort(),
);
// The leading selection column is never toggleable (enableHiding=false).
expect(toggleable).toHaveLength(15);
expect(toggleable).not.toContain("__select__");
});
it("renders the 15 data column headers", () => {
render(<Media />);
const headers = screen
.getAllByRole("columnheader")
.map((h) => (h.textContent ?? "").trim());
for (const expected of [
"Title",
"Series",
"Season",
"Episode",
"Type",
"Year",
"Runtime",
"Size",
"Bitrate",
"HDR",
"Video codec",
"Resolution",
"Date added",
"Library",
"Path",
]) {
expect(headers).toContain(expected);
}
});
it("navigates to the file browser at the item path on row click", async () => {
render(<Media />);
await userEvent.click(screen.getByText("Inception"));
expect(navigate).toHaveBeenCalledTimes(1);
expect(navigate).toHaveBeenCalledWith(
`/files?path=${encodeURIComponent("/media/movies/Inception.mkv")}`,
);
});
it("does NOT navigate when toggling a row selection checkbox", async () => {
render(<Media />);
const firstCheckbox = screen.getAllByRole("checkbox", {
name: "Select row",
})[0];
await userEvent.click(firstCheckbox);
expect(firstCheckbox).toBeChecked();
expect(navigate).not.toHaveBeenCalled();
});
it("renders the server-driven pagination total + page controls", () => {
render(<Media />);
// DataTable manual-pagination footer surfaces the server total + pager.
// ("Page 1 of 1" also appears in the page caption, so match all and assert
// the pager footer text is present alongside the unique total.)
expect(screen.getByText("2 rows")).toBeInTheDocument();
expect(screen.getAllByText(/Page 1 of 1/).length).toBeGreaterThan(0);
expect(
screen.getByRole("button", { name: "Previous page" }),
).toBeDisabled();
});
it("disables Build index while a build is running", () => {
status = statusFixture({ build_running: true });
render(<Media />);
expect(screen.getByRole("button", { name: "Building..." })).toBeDisabled();
// Stop + Force stop surface only while running.
expect(
screen.getByRole("button", { name: "Stop build" }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Force stop" }),
).toBeInTheDocument();
});
});
@@ -0,0 +1,122 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Settings } from "../Settings";
import type { MonitoringMachine } from "../../types";
const saveMachineMutate = vi.fn().mockResolvedValue({});
const deleteMachineMutate = vi.fn();
const testSSHMutate = vi
.fn()
.mockResolvedValue({
message: "SSH auth succeeded",
known_hosts_updated: true,
});
let machines: MonitoringMachine[] = [];
vi.mock("../../hooks/useSettings", () => ({
useMonitoringSettings: () => ({ data: machines }),
useSSHKeys: () => ({ data: [] }),
useSaveMonitoringMachine: () => ({
mutateAsync: saveMachineMutate,
isPending: false,
}),
useDeleteMonitoringMachine: () => ({ mutate: deleteMachineMutate }),
useTestMonitoringMachineSSH: () => ({
mutateAsync: testSSHMutate,
isPending: false,
}),
useResetLocalDatabase: () => ({}),
useSaveSSHKey: () => ({ mutateAsync: vi.fn() }),
useGenerateSSHKey: () => ({ mutateAsync: vi.fn(), isPending: false }),
useDeleteSSHKey: () => ({ mutate: vi.fn() }),
}));
function localMachine(
overrides: Partial<MonitoringMachine> = {},
): MonitoringMachine {
return {
id: "m1",
name: "This machine",
mode: "local",
enabled: true,
services: ["monitoring", "files", "jellyfin"],
host: "",
port: 22,
username: "",
key_directory: "",
key_name: "",
ssh_key_id: "",
ssh_private_key_set: false,
ssh_private_key_passphrase_set: false,
password_set: false,
media_root: "/mnt/media",
path_prefix: "",
jellyfin_url: "",
jellyfin_user_id: "",
jellyfin_api_key_set: false,
jellyseerr_url: "",
jellyseerr_api_key_set: false,
notes: "Primary node",
...overrides,
} as MonitoringMachine;
}
beforeEach(() => {
saveMachineMutate.mockClear();
deleteMachineMutate.mockClear();
testSSHMutate.mockClear();
machines = [];
});
describe("Settings", () => {
it("renders the machine list from the mocked store", () => {
machines = [localMachine()];
render(<Settings />);
// The rail row caption (mode · enabled) is unique to the selection rail.
expect(screen.getByText("local · Enabled")).toBeInTheDocument();
});
it("saves a machine via the editor dialog (controlled useState parity)", async () => {
machines = [localMachine()];
render(<Settings />);
// The detail-pane "Edit" has visible text "Edit"; the rail hover edit
// affordance is icon-only (aria-label "Edit") — disambiguate by text.
const detailEdit = screen
.getAllByRole("button", { name: "Edit" })
.find((button) => button.textContent === "Edit") as HTMLButtonElement;
await userEvent.click(detailEdit);
expect(screen.getByText("Edit machine")).toBeInTheDocument();
// Rename through the labeled field, then save.
const nameInput = screen.getByLabelText("Name");
await userEvent.clear(nameInput);
await userEvent.type(nameInput, "Worker node");
await userEvent.click(screen.getByRole("button", { name: "Save machine" }));
expect(saveMachineMutate).toHaveBeenCalledTimes(1);
const saved = saveMachineMutate.mock.calls[0][0];
expect(saved.name).toBe("Worker node");
expect(saved.mode).toBe("local");
});
it("deletes a machine through the confirm dialog", async () => {
machines = [localMachine()];
render(<Settings />);
// Detail-pane "Delete" opens the confirm dialog.
await userEvent.click(screen.getByRole("button", { name: "Delete" }));
expect(screen.getByText("Delete machine?")).toBeInTheDocument();
// Confirm (the confirm dialog's "Delete" is the last one rendered).
const deletes = screen.getAllByRole("button", { name: "Delete" });
await userEvent.click(deletes[deletes.length - 1]);
expect(deleteMachineMutate).toHaveBeenCalledTimes(1);
expect(deleteMachineMutate).toHaveBeenCalledWith("m1");
});
});
@@ -0,0 +1,285 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { UsersPage } from "../UsersPage.impl";
import { TooltipProvider } from "../../components/ui/tooltip";
import type {
NowPlayingSession,
UserDirectoryItem,
UserDirectoryResponse,
} from "../../types";
// jsdom has no window.matchMedia; MUI `useMediaQuery` (still used by the
// compose dialog, slice 6b) must not blow up during render. Stub to "desktop".
beforeEach(() => {
if (!window.matchMedia) {
window.matchMedia = ((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: () => {},
removeEventListener: () => {},
addListener: () => {},
removeListener: () => {},
dispatchEvent: () => false,
})) as unknown as typeof window.matchMedia;
}
// The compose formatting actions defer a focus/selection restore via
// requestAnimationFrame (see insertMarkup). jsdom may not flush rAF
// synchronously, so make it synchronous so the slice-6b compose test can
// observe the html-body value update.
window.requestAnimationFrame = ((cb: FrameRequestCallback) => {
cb(0);
return 0;
}) as typeof window.requestAnimationFrame;
});
// Keep the drawer's nested session panel out of the DOM under test.
vi.mock("../../components/SessionActivityPanel", () => ({
SessionActivityPanel: ({
selectedUserLabel,
}: {
selectedUserLabel: string;
}) => <div data-testid="session-panel-stub">{selectedUserLabel}</div>,
}));
let users: UserDirectoryItem[] = [];
let activity: NowPlayingSession[] = [];
function directoryResponse(): UserDirectoryResponse {
return {
items: users,
total: users.length,
jellyseerr_configured: true,
jellyseerr_available: true,
jellyseerr_error: "",
jellyseerr_jellyfin_user_count: 0,
jellyseerr_user_count: 0,
enriched_count: 0,
};
}
vi.mock("../../hooks/useUsers", () => ({
useUsers: () => ({ data: directoryResponse(), isError: false, error: null }),
}));
vi.mock("../../hooks/useDashboard", () => ({
useActivity: () => ({ data: activity }),
}));
vi.mock("../../hooks/useUserMessageQueueStatus", () => ({
useUserMessageQueueStatus: () => ({ data: undefined, isError: false }),
}));
vi.mock("../../hooks/useSendUserMessage", () => ({
useSendUserMessage: () => ({
isPending: false,
isError: false,
isSuccess: false,
reset: vi.fn(),
mutateAsync: vi.fn(),
}),
}));
// useSearchParams backs `?user=<id>` (drawer open) on a module-level object.
// `setSearchParams({ user })` opens the drawer; `setSearchParams({})` closes it.
let currentParams: Record<string, string> = {};
const setSearchParams = vi.fn((next: Record<string, string>) => {
currentParams = { ...next };
});
vi.mock("react-router-dom", () => ({
useSearchParams: () => [new URLSearchParams(currentParams), setSearchParams],
}));
function userFixture(
overrides: Partial<UserDirectoryItem> = {},
): UserDirectoryItem {
return {
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
email: "alice@example.com",
email_source: "jellyfin",
avatar: "",
avatar_source: "",
contactable: true,
source: "jellyfin",
source_summary: "",
name_source: "jellyfin",
access_source: "jellyfin",
jellyseerr_user_id: null,
jellyseerr_username: "",
user_type: 1,
user_type_label: "User",
role: "admin",
permissions: 1,
permissions_label: "Administrator",
request_count: 0,
...overrides,
};
}
beforeEach(() => {
users = [];
activity = [];
currentParams = {};
setSearchParams.mockClear();
});
describe("UsersPage (slice 6a — directory surface + drawer)", () => {
it("renders the directory table and metric counts", () => {
users = [userFixture()];
render(<UsersPage />);
expect(screen.getByText("Total users")).toBeInTheDocument();
expect(screen.getByText("User list")).toBeInTheDocument();
expect(screen.getByText("Alice")).toBeInTheDocument();
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("toggles row selection and reflects the selected-count badge", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
expect(screen.getByText("0 selected")).toBeInTheDocument();
// Selection-across-pagination: toggling a row updates the selected-id set.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("1 selected")).toBeInTheDocument();
// Toggling again removes it (the set survives, membership flips).
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
expect(screen.getByText("0 selected")).toBeInTheDocument();
});
it("selects all visible rows via the header select-all checkbox", async () => {
users = [
userFixture({ jellyfin_id: "u1" }),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
render(<UsersPage />);
await userEvent.click(
screen.getByRole("checkbox", { name: "Select all visible users" }),
);
expect(screen.getByText("2 selected")).toBeInTheDocument();
});
it("opens the user drawer when a row is clicked (setSearchParams user)", async () => {
users = [userFixture({ jellyfin_id: "u1" })];
render(<UsersPage />);
// Clicking the row body (not the checkbox) opens the detail drawer.
await userEvent.click(screen.getByText("Alice"));
expect(setSearchParams).toHaveBeenCalledWith({ user: "u1" });
});
it("maps activity status to Badge variants (Playing→success, Paused→warning)", () => {
users = [
userFixture({
jellyfin_id: "u1",
username: "alice",
display_name: "Alice",
}),
userFixture({
jellyfin_id: "u2",
username: "bob",
display_name: "Bob",
email: "bob@example.com",
}),
];
activity = [
{
user: "alice",
title: "Movie",
type: "Movie",
state: "playing",
transcoding: "no",
transcoding_type: "",
device: "Web",
session_id: "s1",
},
{
user: "bob",
title: "Show",
type: "Episode",
state: "paused",
transcoding: "no",
transcoding_type: "",
device: "TV",
session_id: "s2",
},
];
render(<UsersPage />);
// Design §2.3: healthy/active (Playing) = success (chart-2); Paused = warning.
expect(screen.getByText("Playing").getAttribute("data-variant")).toBe(
"success",
);
expect(screen.getByText("Paused").getAttribute("data-variant")).toBe(
"warning",
);
});
it("renders the user detail drawer (Sheet) when a user is selected", () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
currentParams = { user: "u1" };
render(<UsersPage />);
// buildUserDrawerModel title = display name; rendered as the drawer heading.
expect(screen.getByRole("heading", { name: "Alice" })).toBeInTheDocument();
// Drawer sections (identity / contact actions) + the activity panel render.
expect(screen.getByText("Identity")).toBeInTheDocument();
expect(screen.getByText("Contact actions")).toBeInTheDocument();
expect(screen.getByTestId("session-panel-stub")).toBeInTheDocument();
});
});
describe("UsersPage (slice 6b — compose dialog formatting actions)", () => {
it("opens compose and inserts bold markup into the html body", async () => {
users = [userFixture({ jellyfin_id: "u1", display_name: "Alice" })];
// The formatting toolbar renders <Tooltip> (shadcn), which in the app is
// wrapped by a global <TooltipProvider> in App.tsx; supply it here.
render(
<TooltipProvider>
<UsersPage />
</TooltipProvider>,
);
// Select a deliverable user so the "Message selected" button enables.
await userEvent.click(
screen.getByRole("checkbox", { name: "Select Alice" }),
);
await userEvent.click(
screen.getByRole("button", { name: "Message selected" }),
);
// Compose dialog opens (shadcn Dialog family).
expect(
screen.getByRole("heading", { name: "Message selected users" }),
).toBeInTheDocument();
// Bold action wraps the cursor selection in <strong></strong> via the
// preserved insertMarkup helper (markup insertion actions parity).
await userEvent.click(screen.getByRole("button", { name: "Bold" }));
const body = screen.getByRole("textbox", {
name: "HTML message body",
}) as HTMLTextAreaElement;
expect(body.value).toContain("<strong>");
});
});
+25
View File
@@ -0,0 +1,25 @@
// Vitest global setup: registers @testing-library/jest-dom matchers
// (toBeInTheDocument, toHaveAttribute, …) for the jsdom environment.
// The `/vitest` entry both registers the matchers at runtime and provides the
// TypeScript module augmentation for vitest's `expect` so tsc typechecks them.
import "@testing-library/jest-dom/vitest";
// jsdom does not implement ResizeObserver, but several Radix primitives that
// shadcn wraps (ScrollArea, Select via react-popper/react-use-size, DropdownMenu,
// Tabs, etc.) reference it at module-load or render time. Without a stub, any
// component test whose render tree pulls one of these in fails with
// `ReferenceError: ResizeObserver is not defined`. Stub a no-op observer so the
// whole suite (and future component tests) is resilient to cross-test module
// loading in the Vitest pool.
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
globalThis.ResizeObserver =
ResizeObserverStub as unknown as typeof ResizeObserver;
// Radix popper also probes `requestAnimationFrame`; jsdom provides it, but some
// primitives defer layout reads through rAF that never flush in jsdom. Keep the
// default rAF; this guard is intentionally minimal.
-7
View File
@@ -1,7 +0,0 @@
export function getAppTheme(mode: "light" | "dark") {
void mode;
// Theme is now handled by Tailwind CSS + CSS variables in index.css
// This function is kept as a no-op shim for backward compatibility
// during the MUI → Tailwind migration.
return {} as unknown;
}

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