From 7665ef4d10f12dcdee5867494ec477fa202c78c6 Mon Sep 17 00:00:00 2001 From: Developer Date: Sun, 12 Jul 2026 17:18:21 +0000 Subject: [PATCH] feat(jellyseer): sortable/filterable requests table on the Requests tab Replace the static "recent requests" list with a proper table of all Jellyseerr requests, sorted by date added (newest first by default) with standard sorting and filtering. Backend: - JellyseerrClient.requests(max_count=500): paginated GET /api/v1/request (sort=added), mapped with type (movie/tv), status, media_status, and created_at labels. Returns up to 500 so the table can sort/filter client-side. - fetch_jellyseer_requests(service) reuses the per-service cached client (shared with the stats widgets). - new GET /api/jellyseerr/requests endpoint. Frontend: - JellyseerRequestsTable: TanStack Table (sorting via getSortedRowModel, pagination via getPaginationRowModel) reusing the Table primitives + TablePagination. Columns: Name / Type / Status / Media / Requested, all sortable; default sort Requested desc. A search box filters by name and a status dropdown defaults to "Open" (pending+approved+processing) with All/Pending/Approved/Declined options. (The shared DataTable is deliberately visibility-only, so this is a dedicated sortable table.) - RequestsTab renders the stats grid + the new table (the compact recent list stays on the Requests overview widget). - useJellyseerRequests hook + fetchJellyseerRequests API client. Tests: client requests() mapping + single-page stop; fetch helper not-configured; RequestsTab test mocks both hooks. 404/404 backend + 184/184 frontend pass; build (tsc -b && vite build) + ESLint clean. --- .../clients/.pi-map.index.md | 2 +- .../clients/.pi-map.md | 8 +- .../clients/jellyseerr.py | 39 +++ .../routers/.pi-map.index.md | 2 +- .../routers/.pi-map.md | 8 +- .../routers/jellyseerr.py | 18 ++ .../widgets/.pi-map.index.md | 2 +- .../widgets/.pi-map.md | 12 +- .../widgets/jellyseerr_stats.py | 25 +- backend/tests/test_jellyseerr_stats.py | 46 ++++ frontend/src/api/.pi-map.index.md | 2 +- frontend/src/api/.pi-map.md | 6 +- frontend/src/api/jellyseerr.ts | 20 ++ frontend/src/components/.pi-map.index.md | 3 +- frontend/src/components/.pi-map.md | 13 +- .../src/components/JellyseerRequestsTable.tsx | 229 ++++++++++++++++++ frontend/src/hooks/useJellyseer.ts | 12 + .../src/pages/service-tabs/.pi-map.index.md | 2 +- frontend/src/pages/service-tabs/.pi-map.md | 6 +- .../src/pages/service-tabs/RequestsTab.tsx | 44 +--- .../__tests__/RequestsTab.test.tsx | 25 +- 21 files changed, 447 insertions(+), 77 deletions(-) create mode 100644 frontend/src/components/JellyseerRequestsTable.tsx diff --git a/backend/src/media_library_viewer_api/clients/.pi-map.index.md b/backend/src/media_library_viewer_api/clients/.pi-map.index.md index e3db1b0..6ce8674 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/clients/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/clients ## role -Provides API and system client wrappers for external service integration (media servers, identity providers, torrent clients, and remote/local hosts). +Provides API and system client wrappers for external services (Authentik, Jellyfin, Jellyseerr, qBittorrent, SSH/local) used by the media library viewer backend. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md diff --git a/backend/src/media_library_viewer_api/clients/.pi-map.md b/backend/src/media_library_viewer_api/clients/.pi-map.md index 364817b..9b242b1 100644 --- a/backend/src/media_library_viewer_api/clients/.pi-map.md +++ b/backend/src/media_library_viewer_api/clients/.pi-map.md @@ -4,20 +4,20 @@ dir: backend/src/media_library_viewer_api/clients index: backend/src/media_library_viewer_api/clients/.pi-map.index.md ## role -Provides API and system client wrappers for external service integration (media servers, identity providers, torrent clients, and remote/local hosts). +Provides API and system client wrappers for external services (Authentik, Jellyfin, Jellyseerr, qBittorrent, SSH/local) used by the media library viewer backend. ## files - __init__.py | Swaps the position of two tmux panes within a window or between windows | dep: tmux, sh - authentik.py | API client wrapper for Authentik directory service providing paginated user browsing and search via REST API. | exp: class:AuthentikClient, method:__init__(self, base_url: str, api_token: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self, search, page, page_size) → dict[str, Any], call:self.get, call:isinstance, call:logger.warning, call:type, call:payload.get, call:int, call:pagination.get, call:logger.info, call:len | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout - http_timeout.py | Provides a helper function to build decoupled (connect, read) timeout tuples for the `requests` library, allowing different timeout budgets for connection and read phases. | exp: func:http_timeout(read_timeout, connect_timeout) → tuple[float, float], call:float - jellyfin.py | Wraps the Jellyfin/Emby HTTP API to provide methods for fetching users, libraries, media items, playback sessions, and image URLs as plain Python dictionaries. | exp: class:JellyfinClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:users(self) → list[dict[str, Any]], call:self.get, call:logger.info, call:len, method:resolve_user_id(self, identifier: str | None) → str, call:self.users, call:any, call:str, call:u.get, call:next, call:logger.info, call:logger.warning, raise:RuntimeError, method:libraries(self, user_id: str) → list[dict[str, Any]], call:self.get(f"/Users/{user_id}/Views").get, call:logger.info, call:len, method:items(self, user_id: str, parent_id, start_index, limit, search, include_item_types, recursive, sort_by, sort_order) → dict[str, Any], call:logger.debug, call:self.get, call:str(recursive).lower, method:item_count(self, user_id: str, include_item_types: str, parent_id) → int, call:self.get, call:int, call:response.get, call:logger.debug, method:media_counts(self, user_id: str) → dict[str, int], call:self.item_count, method:library_item_counts(self, user_id: str, libraries: list[dict[str, Any]]) → list[dict[str, Any]], call:lib.get, call:self.item_count, call:results.append, method:sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.get, call:cast, call:isinstance, method:active_sessions(self, active_within_seconds) → list[dict[str, Any]], call:self.sessions, call:session.get, call:logger.info, call:len, method:image_url(self, item_id: str, image_type) → str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout -- jellyseerr.py | HTTP API client wrapper for Jellyseerr that fetches enriched user data, request counts, and recent media requests. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:mapped.append, call:media.get, call:_label, call:(media or {}).get, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout +- jellyseerr.py | HTTP API client wrapper for Jellyseerr that fetches users, request counts, and media request metadata to enrich Jellyfin user data. | exp: class:JellyseerrClient, method:__init__(self, base_url: str, api_key: str, timeout), call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:self.session.headers.update, raise:ValueError, method:get(self, path: str, **params: Any) → Any, call:params.items, call:logger.debug, call:sorted, call:clean_params.keys, call:self.session.get, call:response.raise_for_status, call:logger.warning, call:response.json, raise:requests.HTTPError, method:absolute_url(self, path: str | None) → str, call:path.startswith, method:jellyfin_users(self) → list[dict[str, Any]], call:self.get, call:isinstance, call:logger.info, call:len, call:payload.get, method:users(self, page_size) → list[dict[str, Any]], call:max, call:int, call:self.get, call:isinstance, call:payload.get, call:results.extend, call:page_info.get, call:logger.debug, call:len, call:logger.info, method:request_count(self) → dict[str, int], call:self.get, call:isinstance, call:int, call:payload.get, call:logger.info, method:recent_requests(self, take) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:mapped.append, call:media.get, call:_label, call:(media or {}).get, method:requests(self, max_count) → list[dict[str, Any]], call:max, call:min, call:int, call:self.get, call:isinstance, call:payload.get, call:r.get, call:results.append, call:_label, call:(media or {}).get, call:len, call:logger.info, func:_label(value: Any, table: dict[int, str]) → str, call:table.get, call:int, call:str | dep: logging, typing, requests, media_library_viewer_api.clients.http_timeout - local.py | Provides a local command execution client that mirrors remote SSH helpers to run POSIX shell commands, list directories, stat paths, and run ffprobe on the API host for built-in local monitoring. | exp: class:CommandResult, class:LocalCommandClient, method:__init__(self, timeout), method:run(self, command: str, timeout) → CommandResult, call:logger.debug, call:subprocess.run, call:CommandResult, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, method:ffprobe_json(self, path: str) → dict[str, object], call:shlex.quote, call:self.run, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, subprocess, dataclasses - qbittorrent.py | Minimal read-only qBittorrent Web API client that authenticates via username/password and fetches/merges incremental sync/maindata snapshots with caching, locking, and exponential backoff. | exp: class:QbittorrentClient, method:__init__(self, base_url: str, username: str, password: str, timeout) → None, call:base_url.rstrip, call:self.base_url.endswith, call:http_timeout, call:requests.Session, call:threading.Lock, raise:ValueError, method:_login(self) → None, call:self._session.post, call:resp.raise_for_status, call:resp.text.strip, call:name.strip().upper, call:upper.startswith, call:resp.headers.get, call:set_cookie_hdr.split("=", 1)[0].strip, call:any, call:_is_session_cookie, call:resp.cookies.keys, call:bool, call:logger.info, call:sorted, raise:RuntimeError, method:_get(self, path: str, **params: Any) → dict[str, Any], call:self._login, call:self._session.get, call:logger.debug, call:resp.raise_for_status, call:resp.json, method:maindata(self) → dict[str, Any], call:time.time, call:self._snapshot.get, call:self._copy_snapshot, call:self._fetch_maindata_incremental, call:self._apply_update, call:min, call:logger.warning, raise:RuntimeError, method:_fetch_maindata_incremental(self) → dict[str, Any], call:self._get, method:_apply_update(self, update: dict[str, Any]) → None, call:bool, call:update.get, call:snap.clear, call:dict, call:list, call:isinstance, call:snap["server_state"].update, call:changed.items, call:snap["torrents"].pop, call:snap["categories"].update, call:snap["categories"].pop, method:_copy_snapshot(self) → dict[str, Any], call:dict, call:snap.get, call:list | dep: logging, threading, time, typing, requests, media_library_viewer_api.clients.http_timeout - ssh.py | Provides an SSH client wrapper for remote filesystem inspection and media analysis using paramiko, with POSIX shell command execution and host key management. | exp: class:CommandResult, class:RemoteSSHClient, method:__init__(self, host: str, username: str, port, key_filename, private_key, private_key_passphrase, password, known_hosts_path, timeout), raise:ValueError, method:connect(self) → paramiko.SSHClient, call:paramiko.SSHClient, call:client.load_system_host_keys, call:Path, call:bool, call:has_known_host, call:known_hosts_file.is_file, call:client.load_host_keys, call:client.set_missing_host_key_policy, call:paramiko.RejectPolicy, call:paramiko.AutoAddPolicy, call:self._load_private_key, call:client.connect, call:str(exc).lower, call:known_hosts_file.parent.mkdir, call:client.save_host_keys, raise:RuntimeError, method:close(self) → None, call:self._client.close, method:run(self, command: str, timeout) → CommandResult, call:self.connect, call:shlex.quote, call:logger.debug, call:client.exec_command, call:stdout.channel.recv_exit_status, call:CommandResult, call:stdout.read().decode, call:stderr.read().decode, call:logger.warning, call:result.stderr.strip, call:result.stdout.strip, method:list_dir(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:stat_path(self, path: str) → CommandResult, call:shlex.quote, call:self.run, call:logger.info, method:ffprobe_json(self, path: str) → dict[str, Any], call:shlex.quote, call:self.run, call:logger.info, call:json.loads, raise:RuntimeError | dep: json, logging, posixpath, shlex, dataclasses, io, pathlib, typing, paramiko, media_library_viewer_api.services.known_hosts ## arch -Adapter pattern with per-service client classes wrapping REST/SSH APIs into standardized Python dictionaries; shared HTTP timeout helper and mix-and-match local/remote execution clients. +Adapter pattern with consistent dictionary-based return types across heterogeneous clients (REST APIs, SSH, local shell), each encapsulating authentication, pagination, and service-specific protocol details. ## tags -call:logger.info, error, call:self.get, call:self., call:logger.debug, call:logger.warning, client, init +call:logger.info, error, call:self.get, call:self., call:logger.debug, call:logger.warning, call:isinstance, client ## symbols - AuthentikClient - JellyfinClient diff --git a/backend/src/media_library_viewer_api/clients/jellyseerr.py b/backend/src/media_library_viewer_api/clients/jellyseerr.py index 71a3155..8692ba3 100644 --- a/backend/src/media_library_viewer_api/clients/jellyseerr.py +++ b/backend/src/media_library_viewer_api/clients/jellyseerr.py @@ -24,6 +24,7 @@ _MEDIA_STATUS: dict[int, str] = { 4: "partially_available", 5: "available", } +_REQUEST_TYPE: dict[int, str] = {1: "movie", 2: "tv"} def _label(value: Any, table: dict[int, str]) -> str: @@ -195,3 +196,41 @@ class JellyseerrClient: } ) return mapped + + def requests(self, max_count: int = 500) -> list[dict[str, Any]]: + """Return requests (paginated), mapped for the requests table. + + Fetches up to ``max_count`` requests (no status filter, so the table + can filter/sort client-side). The table defaults to showing "open" + (pending/approved/processing) sorted by date added (newest first). + """ + max_count = max(1, min(int(max_count), 1000)) + results: list[dict[str, Any]] = [] + take = 100 + skip = 0 + while skip < max_count: + payload = self.get("/request", sort="added", skip=skip, take=take) + if not isinstance(payload, dict): + break + page = payload.get("results") or [] + items = [r for r in page if isinstance(r, dict)] if isinstance(page, list) else [] + for r in items: + media = r.get("media") or {} + results.append( + { + "id": r.get("id"), + "type": _label(r.get("type"), _REQUEST_TYPE), + "name": r.get("title") or (media or {}).get("title") or (media or {}).get("name") or "—", + "status": _label(r.get("status"), _REQUEST_STATUS), + "media_status": _label((media or {}).get("status"), _MEDIA_STATUS), + "created_at": r.get("createdAt"), + } + ) + if len(items) < take: + break + skip += len(items) + if len(results) >= max_count: + results = results[:max_count] + break + logger.info("Jellyseerr returned %s requests", len(results)) + return results diff --git a/backend/src/media_library_viewer_api/routers/.pi-map.index.md b/backend/src/media_library_viewer_api/routers/.pi-map.index.md index 9646ad8..341331e 100644 --- a/backend/src/media_library_viewer_api/routers/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/routers/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/routers ## role -FastAPI router package that defines all REST API endpoint modules for the media library viewer backend. +FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets). ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md diff --git a/backend/src/media_library_viewer_api/routers/.pi-map.md b/backend/src/media_library_viewer_api/routers/.pi-map.md index 17b0257..08ad440 100644 --- a/backend/src/media_library_viewer_api/routers/.pi-map.md +++ b/backend/src/media_library_viewer_api/routers/.pi-map.md @@ -4,7 +4,7 @@ dir: backend/src/media_library_viewer_api/routers index: backend/src/media_library_viewer_api/routers/.pi-map.index.md ## role -FastAPI router package that defines all REST API endpoint modules for the media library viewer backend. +FastAPI router package that defines all HTTP API endpoints for the media library viewer backend, organized by domain (auth, backups, dashboards, files, jobs, media, monitoring, services, settings, tasks, widgets). ## files - __init__.py | Marks the directory as a Python package for routers. - authentik_users.py | Provides a FastAPI router that proxies paginated user directory queries and email message enqueueing through an Authentik service client. | exp: class:MessageRequest, func:_build_client(service: ServiceRecord) → AuthentikClient, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:float, call:AuthentikClient, func:_empty(error: str) → dict[str, Any], func:get_authentik_users(service_id: str, search, page, page_size, store) → dict[str, Any], call:resolve_service_record, call:logger.info, call:_empty, call:_build_client, call:client.users, call:logger.exception, func:get_authentik_message_status(service_id: str, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:mail_queue.status, func:post_authentik_message(service_id: str, body: MessageRequest, store, mail_queue) → dict[str, Any], call:resolve_service_record, call:r.strip, call:get_settings, call:validate_smtp_settings, call:mail_queue.enqueue, call:logger.info, call:len | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.authentik, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.services.mail_queue, media_library_viewer_api.services.mailer, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.sources @@ -12,7 +12,7 @@ FastAPI router package that defines all REST API endpoint modules for the media - dashboard.py | FastAPI router providing dashboard endpoints for media counts, library breakdowns, shortcuts CRUD, activity sessions, and backup summaries. | exp: func:get_counts(client, user_id) → dict[str, int], call:client.media_counts, call:logger.info, func:get_library_counts(client, user_id) → list[dict[str, Any]], call:client.libraries, call:logger.info, call:len, call:client.library_item_counts, func:get_shortcuts() → list[dict[str, Any]], call:store.list_shortcuts, call:logger.info, call:len, func:create_shortcut(payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:update_shortcut(shortcut_id: str, payload: dict[str, Any]) → dict[str, Any], call:store.upsert_shortcut, call:logger.info, call:shortcut.get, func:delete_shortcut(shortcut_id: str) → dict[str, str], call:store.delete_shortcut, call:logger.info, func:get_activity(client) → list[dict[str, Any]], call:client.sessions, call:_map_sessions_to_activity_rows, call:rows.sort, call:state_rank.get, call:r.get, call:str(r.get("user", "")).lower, call:logger.info, call:len, func:get_now_playing(client) → list[dict[str, Any]], call:get_activity, func:get_backup_dashboard(store) → BackupDashboardSummary, call:build_backup_dashboard_summary | dep: logging, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.domain.dashboard, media_library_viewer_api.models.backups, media_library_viewer_api.services.settings_store - dashboards.py | Provides CRUD API endpoints for managing named dashboards via a FastAPI router. | exp: func:list_dashboards(store) → list[NamedDashboard], call:store.list_dashboards, call:NamedDashboard, func:get_dashboard_by_slug(slug: str, store) → NamedDashboard, call:store.get_dashboard_by_slug, call:NamedDashboard, raise:HTTPException, func:create_dashboard(body: NamedDashboardInput, store) → NamedDashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, func:update_dashboard(dashboard_id: str, body: NamedDashboardInput, store) → NamedDashboard, call:store.get_dashboard, call:store.upsert_dashboard, call:body.model_dump, call:NamedDashboard, raise:HTTPException, func:delete_dashboard(dashboard_id: str, store) → dict[str, str], call:store.get_dashboard, call:store.delete_dashboard, raise:HTTPException | dep: fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.models.dashboards, media_library_viewer_api.services.settings_store - files.py | FastAPI router providing endpoints for remote file operations including directory listing, ffprobe media analysis, stat, and path resolution via SSH. | exp: func:list_directory(path, ssh) → dict[str, Any], call:ssh.list_dir, call:logger.warning, call:json.loads, call:logger.info, call:len, raise:HTTPException, func:get_ffprobe(path, ssh) → dict[str, Any], call:ssh.ffprobe_json, call:logger.warning, call:logger.info, raise:HTTPException, func:get_stat(path, ssh) → dict[str, str], call:ssh.stat_path, call:logger.warning, call:logger.info, raise:HTTPException, func:resolve_path(path) → dict[str, str], call:get_settings, call:resolve_remote_media_path, call:logger.info | dep: json, logging, typing, fastapi, media_library_viewer_api.clients.ssh, media_library_viewer_api.config, media_library_viewer_api.dependencies, media_library_viewer_api.path_utils -- jellyseerr.py | FastAPI router that resolves a Jellyfin service instance and delegates to the Jellyseerr stats provider to return request counts and recent requests. | exp: func:_serialize(result) → dict, func:get_jellyseerr_stats(jellyfin_service_id, store) → dict, call:resolve_service_record, call:get_stats_provider, call:provider.fetch_stats, call:logger.exception, call:_serialize, raise:HTTPException | dep: logging, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets, media_library_viewer_api.widgets.stats_provider, media_library_viewer_api.widgets.jellyseerr_stats +- jellyseerr.py | FastAPI router providing Jellyseerr request stats and recent requests endpoints for the Jellyfin page. | exp: func:_serialize(result) → dict, func:get_jellyseerr_stats(jellyfin_service_id, store) → dict, call:resolve_service_record, call:get_stats_provider, call:provider.fetch_stats, call:logger.exception, call:_serialize, raise:HTTPException, func:get_jellyseerr_requests(jellyfin_service_id, store) → dict, call:resolve_service_record, call:fetch_jellyseer_requests, call:logger.exception, raise:HTTPException | dep: logging, fastapi, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets, media_library_viewer_api.widgets.jellyseerr_stats, media_library_viewer_api.widgets.stats_provider - jobs.py | FastAPI router that exposes endpoints to list available job templates and execute them on remote paths via SSH. | exp: class:RunJobRequest, func:get_templates() → list[dict[str, str]], call:JOB_TEMPLATES.items, call:logger.info, call:len, func:post_run_job(request: RunJobRequest, ssh) → dict[str, Any], call:logger.warning, call:logger.info, call:run_job, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.clients.ssh, media_library_viewer_api.dependencies, media_library_viewer_api.jobs - media.py | FastAPI router providing endpoints to manage media index lifecycle operations including status checks, building (via subprocess workers), stopping, force-stopping, and querying the media library index. | exp: func:get_media_index() → MediaIndex, call:MediaIndex, func:_set_build_metadata(index: MediaIndex, state: dict[str, Any]) → None, call:state.items, call:index.set_metadata, func:_staging_db_path(index: MediaIndex) → Path, call:index.db_path.with_name, func:_pid_is_alive(pid: int | None) → bool, call:os.kill, func:_clean_stale_build_state(index: MediaIndex) → Any, call:index.status, call:_pid_is_alive, call:logger.warning, call:_set_build_metadata, func:_serialize_status(status: Any) → dict[str, Any], func:_worker_command(final_db_path: Path, staging_db_path: Path, service_id) → list[str], call:str, func:_start_worker(index: MediaIndex, service_id) → subprocess.Popen[bytes], call:_staging_db_path, call:staging_path.unlink, call:subprocess.Popen, call:_worker_command, call:os.environ.copy, func:get_index_status(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.info, call:_serialize_status, func:post_build_index(jellyfin_service_id, index) → dict[str, Any], call:_clean_stale_build_state, call:_pid_is_alive, call:logger.warning, call:logger.info, call:_start_worker, call:_set_build_metadata, call:index.status, call:record_media_index_build, call:_serialize_status, raise:HTTPException, func:stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:logger.info, call:_set_build_metadata, call:index.status, call:_serialize_status, raise:HTTPException, func:force_stop_build(index) → dict[str, Any], call:_clean_stale_build_state, call:logger.warning, call:_pid_is_alive, call:_set_build_metadata, call:index.status, call:_serialize_status, call:logger.info, call:os.killpg, call:time.time, call:time.sleep, call:record_media_index_build, raise:HTTPException, func:query_media(libraries, types, search, hdr_filter, sort_key, sort_order, limit, offset, jellyfin_service_id, client, user_id, index) → dict[str, Any], call:lid.strip, call:libraries.split, call:client.libraries, call:t.strip, call:types.split, call:logger.info, call:len, call:",".join, call:index.query | dep: logging, os, signal, subprocess, sys, threading, time, pathlib, typing, fastapi, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.dependencies, media_library_viewer_api.observability, media_library_viewer_api.services.media_index - monitoring.py | FastAPI router providing observability endpoints for monitoring machines, Alertmanager alerts/status, Prometheus targets/status, and webhook ingestion. | exp: func:_base_url(service: ServiceRecord) → str, call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, func:_timeout(service: ServiceRecord, default: int) → tuple[float, float], call:int, call:service.config.get, call:http_timeout, func:_auth_headers(service: ServiceRecord) → dict[str, str], call:str, call:service.secrets.get, func:_status_response(service: ServiceRecord | None, version, error) → dict[str, Any], func:_summary_from_alerts(alerts: list[dict[str, Any]]) → dict[str, Any], call:summarize_alerts, func:get_machines(store) → list[dict[str, Any]], call:store.list_machines, call:m.get, func:get_prometheus_targets(store) → list[dict[str, Any]], call:build_node_exporter_targets, call:logger.info, call:len, func:get_alertmanager_alerts(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get, call:_summary_from_alerts, call:logger.info, func:get_alertmanager_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:requests.get, call:_base_url, call:_auth_headers, call:_timeout, call:response.raise_for_status, call:response.json, call:logger.exception, call:data.get("versionInfo", {}).get, call:status.get, call:p.get, call:cluster.get, func:get_prometheus_status(service_id, store) → dict[str, Any], call:resolve_service_record, call:_status_response, call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:requests.post, call:http_timeout, call:resp.raise_for_status, call:logger.exception, func:receive_alertmanager_webhook(payload) → dict[str, str], call:payload.get, call:logger.info, call:len | dep: logging, typing, requests, fastapi, media_library_viewer_api.clients.http_timeout, media_library_viewer_api.dependencies, media_library_viewer_api.services.service_resolution, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.targets, media_library_viewer_api.widgets.sources, media_library_viewer_api.integrations.alertmanager, fastapi.APIRouter @@ -21,9 +21,9 @@ FastAPI router package that defines all REST API endpoint modules for the media - tasks.py | FastAPI router providing CRUD endpoints and execution for saved server tasks with SSH service resolution | exp: class:TaskInput, class:RunTaskRequest, func:_service_label(service: dict[str, Any] | None) → str, call:str, call:service.get, func:_resolve_service_for_task(store: SettingsStore, task: dict[str, Any], service_id: str | None) → dict[str, Any] | None, call:store.get_service, call:str(task.get("default_service_id") or "").strip, call:task.get, call:store.list_services, call:svc.get, func:_service_row_to_record(service_row: dict[str, Any]) → ServiceRecord, call:build_service_record, call:get_settings_store, func:list_tasks(store) → list[dict[str, Any]], call:store.list_tasks, func:create_task(task: TaskInput, store) → dict[str, Any], call:store.upsert_task, call:task.model_dump, func:update_task(task_id: str, task: TaskInput, store) → dict[str, Any], call:store.get_task, call:store.upsert_task, call:task.model_dump, raise:HTTPException, func:delete_task(task_id: str, store) → dict[str, str], call:store.get_task, call:store.delete_task, raise:HTTPException, func:list_task_runs(task_id: str, limit, store) → dict[str, Any], call:store.get_task, call:store.list_service_task_runs, call:len, raise:HTTPException, func:run_task(request: RunTaskRequest, service_id, store) → dict[str, Any], call:store.get_task, call:task.get, call:_resolve_service_for_task, call:service_row.get, call:_service_row_to_record, call:run_saved_task, call:_service_label, raise:HTTPException | dep: logging, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets.sources - widgets.py | Provides a FastAPI REST API for CRUD operations on dashboard widget instances and widget references (live-links), including data fetching through registered adapters. | exp: class:WidgetReferenceCreate, func:_validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) → None, call:store.get_service, call:get_service_definition, call:definition.widget_kind, call:validate_config, call:is_builtin_kind, call:validate_builtin_config, raise:HTTPException, func:list_builtin_kinds() → list[BuiltinWidgetKindInfo], call:BuiltinWidgetKindInfo, call:BUILTIN_WIDGET_KINDS.values, func:list_instances(service_id, scope, store) → list[dict[str, Any]], call:WidgetInstance(**widget).model_dump, call:store.list_widgets, func:create_instance(body: WidgetInstanceInput, store) → dict[str, Any], call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, func:update_instance(widget_id: str, body: WidgetInstanceInput, store) → dict[str, Any], call:store.get_widget, call:_validate_widget_input, call:store.upsert_widget, call:body.model_dump, call:WidgetInstance(**widget).model_dump, raise:HTTPException, func:delete_instance(widget_id: str, store) → dict[str, str], call:store.get_widget, call:store.delete_widget, raise:HTTPException, func:fetch_data(widget_id: str, store) → dict[str, Any], call:store.get_widget, call:widget.get, call:store.get_service, call:WidgetDataResponse( widget_id=widget_id, error=f"Service {service_id} not found", fetched_at=int(time.time()), ).model_dump, call:int, call:time.time, call:service_row.get, call:WidgetDataResponse( widget_id=widget_id, error="Service is disabled", fetched_at=int(time.time()), ).model_dump, call:get_stats_adapter, call:get_service_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"No adapter for service type {service_row['service_type']}", fetched_at=int(time.time()), ).model_dump, call:build_service_record, call:get_builtin_adapter, call:WidgetDataResponse( widget_id=widget_id, error=f"Unknown built-in widget kind: {widget_kind}", fetched_at=int(time.time()), ).model_dump, call:adapter.fetch, call:logger.exception, call:WidgetDataResponse( widget_id=widget_id, data=data if "error" not in data else None, error=data.get("error"), fetched_at=int(time.time()), ).model_dump, call:data.get, raise:HTTPException, func:list_references(dashboard_scope: str, store) → list[dict[str, Any]], call:store.list_widget_references, func:create_reference(body: WidgetReferenceCreate, store) → dict[str, Any], call:store.create_widget_reference, raise:HTTPException, func:delete_reference(reference_id: str, store) → dict[str, str], call:store.delete_widget_reference, func:update_reference(reference_id: str, sort_order: int, store) → dict[str, Any], call:store.update_widget_reference, raise:HTTPException, func:detach_reference(reference_id: str, store) → dict[str, Any], call:store.detach_widget_reference, call:WidgetInstance(**cloned).model_dump, raise:HTTPException | dep: logging, time, typing, fastapi, pydantic, media_library_viewer_api.dependencies, media_library_viewer_api.integrations.base, media_library_viewer_api.integrations.registry, media_library_viewer_api.models.widgets, media_library_viewer_api.services.settings_store, media_library_viewer_api.widgets.builtin, media_library_viewer_api.widgets.sources ## arch -Modular router-per-domain pattern where each file exposes a FastAPI APIRouter for a specific feature area, delegating business logic to service clients and providers. +Modular router-per-domain pattern where each file exposes a FastAPI APIRouter for a specific feature area; routers delegate business logic to service clients and adapters, using dependency injection for SSH/database access and standard Pydantic models for request/response validation. ## tags -call:, service, raise:httpexception, media_library_viewer_api, backup, get, call:store.get, ssh +call:, service, raise:httpexception, media_library_viewer_api, get, backup, call:store.get, ssh ## symbols - MessageRequest - RunJobRequest diff --git a/backend/src/media_library_viewer_api/routers/jellyseerr.py b/backend/src/media_library_viewer_api/routers/jellyseerr.py index 093f08b..48b7a89 100644 --- a/backend/src/media_library_viewer_api/routers/jellyseerr.py +++ b/backend/src/media_library_viewer_api/routers/jellyseerr.py @@ -17,6 +17,7 @@ from media_library_viewer_api.dependencies import get_settings_store from media_library_viewer_api.services.service_resolution import resolve_service_record from media_library_viewer_api.services.settings_store import SettingsStore from media_library_viewer_api.widgets import jellyseerr_stats # noqa: F401 — ensure provider registration +from media_library_viewer_api.widgets.jellyseerr_stats import fetch_jellyseer_requests from media_library_viewer_api.widgets.stats_provider import get_stats_provider logger = logging.getLogger(__name__) @@ -50,3 +51,20 @@ def get_jellyseerr_stats( logger.exception("Jellyseerr stats endpoint failed") raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc return _serialize(result) + + +@router.get("/requests") +def get_jellyseerr_requests( + jellyfin_service_id: str | None = None, + store: SettingsStore = Depends(get_settings_store), +) -> dict: + """Return Jellyseerr requests for the Requests tab table (filter/sort client-side).""" + service = resolve_service_record(store, "jellyfin", jellyfin_service_id) + if service is None: + raise HTTPException(status_code=503, detail="No Jellyfin service is configured.") + try: + requests = fetch_jellyseer_requests(service) + except Exception as exc: # pragma: no cover - client guards internally + logger.exception("Jellyseerr requests endpoint failed") + raise HTTPException(status_code=502, detail=f"Jellyseerr fetch failed: {exc}") from exc + return {"requests": requests} diff --git a/backend/src/media_library_viewer_api/widgets/.pi-map.index.md b/backend/src/media_library_viewer_api/widgets/.pi-map.index.md index 8c1872e..0ddba8e 100644 --- a/backend/src/media_library_viewer_api/widgets/.pi-map.index.md +++ b/backend/src/media_library_viewer_api/widgets/.pi-map.index.md @@ -2,7 +2,7 @@ dir: backend/src/media_library_viewer_api/widgets ## role -Provides data fetching, normalization, and configuration logic for dashboard widgets across various integrated services and data sources. +Provides widget data adapters, built-in widget definitions, and stats-provider abstractions for fetching and normalizing dashboard data from various external services. ## parent index: backend/src/media_library_viewer_api/.pi-map.index.md map: backend/src/media_library_viewer_api/.pi-map.md diff --git a/backend/src/media_library_viewer_api/widgets/.pi-map.md b/backend/src/media_library_viewer_api/widgets/.pi-map.md index 7a6a667..28df931 100644 --- a/backend/src/media_library_viewer_api/widgets/.pi-map.md +++ b/backend/src/media_library_viewer_api/widgets/.pi-map.md @@ -4,18 +4,18 @@ dir: backend/src/media_library_viewer_api/widgets index: backend/src/media_library_viewer_api/widgets/.pi-map.index.md ## role -Provides data fetching, normalization, and configuration logic for dashboard widgets across various integrated services and data sources. +Provides widget data adapters, built-in widget definitions, and stats-provider abstractions for fetching and normalizing dashboard data from various external services. ## files - __init__.py | Marks the directory as a Python package for the widget subsystem. - builtin.py | Defines built-in widget kinds that don't require external services, providing their configurations, schemas, and validation. | exp: class:StaticConfig, func:get_builtin_widget_kind(kind: str) → WidgetKind | None, call:BUILTIN_WIDGET_KINDS.get, func:is_builtin_kind(kind: str) → bool, func:builtin_widget_kind_models() → dict[str, type], call:Field, func:validate_builtin_config(kind: str, config: dict[str, Any]) → dict[str, Any], call:builtin_widget_kind_models, call:models.get, call:dict, call:model_cls.model_validate(config or {}).model_dump | dep: typing, media_library_viewer_api.integrations.base, pydantic -- jellyseerr_stats.py | Fetches and caches request count statistics and recent requests from Jellyseerr for Jellyfin services. | exp: class:JellyseerrStatsProvider, method:__init__(self, ttl) → None, call:threading.Lock, method:fetch_stats(self, service: Any) → StatsResult, call:str, call:service.config.get, call:(service.secrets or {}).get, call:StatsResult, call:time.time, call:self._cache.get, call:_jellyseer_client, call:client.request_count, call:client.recent_requests, call:logger.warning, call:StatValue, call:int, call:counts.get, func:_jellyseer_client(cache_key: tuple[str, str, str]) → JellyseerrClient, call:JellyseerrClient | dep: logging, threading, time, functools, typing, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.widgets.stats_provider +- jellyseerr_stats.py | Provides Jellyseerr request statistics and recent request lists for Jellyfin services via a cached, thread-safe stats provider. | exp: class:JellyseerrStatsProvider, method:__init__(self, ttl) → None, call:threading.Lock, method:fetch_stats(self, service: Any) → StatsResult, call:str, call:service.config.get, call:(service.secrets or {}).get, call:StatsResult, call:time.time, call:self._cache.get, call:_jellyseer_client, call:client.request_count, call:client.recent_requests, call:logger.warning, call:StatValue, call:int, call:counts.get, func:_jellyseer_client(cache_key: tuple[str, str, str]) → JellyseerrClient, call:JellyseerrClient, func:fetch_jellyseer_requests(service: Any) → list[dict[str, Any]], call:str, call:service.config.get, call:(service.secrets or {}).get, call:_jellyseer_client, call:client.requests | dep: logging, threading, time, functools, typing, media_library_viewer_api.clients.jellyseerr, media_library_viewer_api.widgets.stats_provider, functools.lru_cache, media_library_viewer_api.clients.jellyseerr.JellyseerrClient - prometheus_range.py | Provides shared helper functions for Prometheus range queries, including step-size derivation, window presets, and normalization of both Prometheus and Grafana API responses into a frontend-consumable series format. | exp: func:step_for_window(window_seconds: int, target_points) → int, call:max, call:round, func:_dedup_label(label: str, seen: dict[str, int]) → str, func:normalize_prometheus_matrix(result: list[dict[str, Any]]) → list[dict[str, Any]], call:entry.get, call:sorted, call:metric.items, call:str(k).startswith, call:_dedup_label, call:" ".join, call:_safe_int, call:points.append, call:_safe_float, call:series.append, func:normalize_grafana_frames(raw: dict[str, Any]) → list[dict[str, Any]], call:raw.get, call:results.items, call:ref_data.get, call:frame.get("data", {}).get, call:len, call:frame.get("schema", {}).get, call:value_field.get("config", {}).get, call:sorted, call:frame_labels.items, call:str(k).startswith, call:" ".join, call:_dedup_label, call:zip, call:_safe_int, call:points.append, call:_safe_float, call:series.append, func:_safe_float(raw: Any) → float | None, call:float, func:_safe_int(ts: Any) → int | None, call:int, call:float | dep: typing -- sources.py | Adapts various service instances into dashboard widget data by fetching and normalizing metrics, alerts, media sessions, and task results. | exp: class:ServiceRecord, class:WidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], class:BackupsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:build_backup_dashboard_summary, call:summary.model_dump, call:logger.exception, class:StaticWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:config.get, class:MetricSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:self._fetch_chart, call:self._fetch_gauge, call:self._fetch_mean, call:self._fetch_metric, call:logger.exception, method:_gateway_query(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, promql: str, window_seconds, max_data_points) → dict[str, Any], call:step_for_window, call:requests.post, call:resp.raise_for_status, call:resp.json, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, method:_fetch_chart(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, method:_fetch_gauge(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, method:_fetch_mean(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, call:sum, method:_fetch_metric(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, class:AlertmanagerWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:service.secrets.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:isinstance, call:summarize_alerts, call:logger.exception, class:JellyfinWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:asyncio.wait_for, call:asyncio.to_thread, call:s.get("PlayState", {}).get, call:_map_sessions_to_activity_rows, call:logger.exception, class:SshTaskWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:config.get, call:store.get_task, call:task.get, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:_record_timeout, call:logger.exception, class:QbittorrentWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:_qbittorrent_client, call:asyncio.wait_for, call:asyncio.to_thread, call:data.get, call:torrents.values, call:t.get, call:by_state.get, call:len, call:server_state.get, call:time.time, call:QbittorrentSampleStore, call:store.append, call:store.window, call:logger.exception, class:StatsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_stats_provider, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, call:str, call:config.get, call:next, func:build_service_record(store: SettingsStore, service_row: dict[str, Any]) → ServiceRecord, call:ServiceRecord, call:service_row.get, call:decrypt_secrets, call:bool, func:_record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) → None, call:get_settings_store, call:store.record_service_task_run, call:str, call:config.get, call:logger.exception, func:_qbittorrent_client(cache_key: tuple[str, str, str, str, int]) → QbittorrentClient, call:QbittorrentClient, func:get_service_adapter(service_type: str) → WidgetSource | None, call:SERVICE_ADAPTERS.get, func:get_builtin_adapter(kind: str) → WidgetSource | None, call:BUILTIN_ADAPTERS.get, func:get_stats_adapter() → WidgetSource | None | dep: asyncio, logging, time, dataclasses, functools, typing, requests, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.domain.dashboard, media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.services.qbittorrent_store, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets, media_library_viewer_api.widgets.prometheus_range, media_library_viewer_api.widgets.stats_provider, media_library_viewer_api.services.secrets, JellyfinClient, QbittorrentClient, summarize_alerts, SettingsStore, run_saved_task, normalize_grafana_frames -- stats_provider.py | Defines an abstract stats-provider interface and registry for services that expose named numeric metrics to be rendered by widgets. | exp: class:StatValue, class:StatsResult, class:StatsProvider, method:fetch_stats(self, service: Any) → StatsResult, func:register_stats_provider(service_type: str, provider: StatsProvider) → None, func:get_stats_provider(service_type: str) → StatsProvider | None, call:STATS_PROVIDERS.get | dep: dataclasses, typing +- sources.py | Provides widget source adapters that fetch and normalize data from various services (Grafana/Prometheus, Alertmanager, Jellyfin, SSH tasks, backups) for dashboard rendering. | exp: class:ServiceRecord, class:WidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], class:BackupsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:build_backup_dashboard_summary, call:summary.model_dump, call:logger.exception, class:StaticWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:config.get, class:MetricSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("grafana_url") or "").rstrip, call:service.config.get, call:service.secrets.get, call:int, call:self._fetch_chart, call:self._fetch_gauge, call:self._fetch_mean, call:self._fetch_metric, call:logger.exception, method:_gateway_query(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, promql: str, window_seconds, max_data_points) → dict[str, Any], call:step_for_window, call:requests.post, call:resp.raise_for_status, call:resp.json, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, method:_fetch_chart(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, method:_fetch_gauge(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, method:_fetch_mean(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:WINDOW_PRESETS.get, call:self._gateway_query, call:normalize_grafana_frames, call:len, call:sum, method:_fetch_metric(self, grafana_url: str, api_key: str, datasource_uid: str, timeout: int, config: dict[str, Any]) → dict[str, Any], call:config.get, call:self._gateway_query, call:normalize_grafana_frames, class:AlertmanagerWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str(service.config.get("base_url") or "").rstrip, call:service.config.get, call:int, call:config.get, call:service.secrets.get, call:asyncio.wait_for, call:asyncio.to_thread, call:response.raise_for_status, call:response.json, call:payload.get, call:isinstance, call:summarize_alerts, call:logger.exception, class:JellyfinWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:asyncio.wait_for, call:asyncio.to_thread, call:s.get("PlayState", {}).get, call:_map_sessions_to_activity_rows, call:logger.exception, class:SshTaskWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_settings_store, call:config.get, call:store.get_task, call:task.get, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:_record_timeout, call:logger.exception, class:QbittorrentWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:str, call:service.config.get, call:service.secrets.get, call:int, call:_qbittorrent_client, call:asyncio.wait_for, call:asyncio.to_thread, call:data.get, call:torrents.values, call:t.get, call:by_state.get, call:len, call:server_state.get, call:time.time, call:QbittorrentSampleStore, call:store.append, call:store.window, call:logger.exception, class:StatsWidgetSource, method:fetch(self, service: ServiceRecord | None, widget_kind: str, config: dict[str, Any]) → dict[str, Any], call:get_stats_provider, call:int, call:service.config.get, call:asyncio.wait_for, call:asyncio.to_thread, call:logger.exception, call:str, call:config.get, call:next, func:build_service_record(store: SettingsStore, service_row: dict[str, Any]) → ServiceRecord, call:ServiceRecord, call:service_row.get, call:decrypt_secrets, call:bool, func:_record_timeout(service: ServiceRecord | None, config: dict[str, Any], timeout: int) → None, call:get_settings_store, call:store.record_service_task_run, call:str, call:config.get, call:logger.exception, func:_qbittorrent_client(cache_key: tuple[str, str, str, str, int]) → QbittorrentClient, call:QbittorrentClient, func:get_service_adapter(service_type: str) → WidgetSource | None, call:SERVICE_ADAPTERS.get, func:get_builtin_adapter(kind: str) → WidgetSource | None, call:BUILTIN_ADAPTERS.get, func:get_stats_adapter() → WidgetSource | None | dep: asyncio, logging, time, dataclasses, functools, typing, requests, media_library_viewer_api.clients.jellyfin, media_library_viewer_api.clients.qbittorrent, media_library_viewer_api.domain.dashboard, media_library_viewer_api.integrations.alertmanager, media_library_viewer_api.services.qbittorrent_store, media_library_viewer_api.services.settings_store, media_library_viewer_api.services.task_runner, media_library_viewer_api.widgets, media_library_viewer_api.widgets.prometheus_range, media_library_viewer_api.widgets.stats_provider, media_library_viewer_api.services.secrets, JellyfinClient, QbittorrentClient, SettingsStore, summarize_alerts, run_saved_task, normalize_grafana_frames, get_stats_provider +- stats_provider.py | Defines a generic stats-provider abstraction with a protocol interface and registry for fetching and exposing named numeric metrics from various services. | exp: class:StatValue, class:StatsResult, class:StatsProvider, method:fetch_stats(self, service: Any) → StatsResult, func:register_stats_provider(service_type: str, provider: StatsProvider) → None, func:get_stats_provider(service_type: str) → StatsProvider | None, call:STATS_PROVIDERS.get | dep: dataclasses, typing ## arch -Provider/adapter pattern with a stats-provider registry, built-in widget definitions with schema validation, and helper modules for external API integration (Jellyseerr, Prometheus/Grafana). +Modular adapter pattern with protocol-based provider interfaces, a stats-provider registry, and per-service normalization layers that transform heterogeneous API responses into a unified frontend-consumable format. ## tags -fetch, widget, stats, call:, call:str, call:self., source, call:logger.exception +fetch, widget, stats, call:, call:str, source, call:self., call:logger.exception ## symbols - StaticConfig - JellyseerrStatsProvider diff --git a/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py b/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py index aa186c3..8d25635 100644 --- a/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py +++ b/backend/src/media_library_viewer_api/widgets/jellyseerr_stats.py @@ -62,9 +62,7 @@ class JellyseerrStatsProvider: base_url = str(service.config.get("jellyseerr_url") or "") # jellyseerr_api_key is migrating config -> secret; accept either during the transition. api_key = str( - (service.secrets or {}).get("jellyseerr_api_key") - or service.config.get("jellyseerr_api_key") - or "" + (service.secrets or {}).get("jellyseerr_api_key") or service.config.get("jellyseerr_api_key") or "" ) if not base_url or not api_key: return StatsResult( @@ -91,10 +89,7 @@ class JellyseerrStatsProvider: return StatsResult(stats=[], detail=f"Jellyseerr fetch failed: {exc}") result = StatsResult( - stats=[ - StatValue(key=key, label=label, value=int(counts.get(key, 0))) - for key, label in _JELLYSEERR_STATS - ], + stats=[StatValue(key=key, label=label, value=int(counts.get(key, 0))) for key, label in _JELLYSEERR_STATS], recent=recent, ) with self._lock: @@ -103,3 +98,19 @@ class JellyseerrStatsProvider: register_stats_provider("jellyfin", JellyseerrStatsProvider()) + + +def fetch_jellyseer_requests(service: Any) -> list[dict[str, Any]]: + """Return Jellyseerr requests for the Requests tab table. + + Reuses the per-service cached client (shared with the stats widgets) so the + tab and widgets don't each open a new session. + """ + base_url = str(service.config.get("jellyseerr_url") or "") + api_key = str( + (service.secrets or {}).get("jellyseerr_api_key") or service.config.get("jellyseerr_api_key") or "" + ) + if not base_url or not api_key: + return [] + client = _jellyseer_client((service.id, base_url, api_key)) + return client.requests(500) diff --git a/backend/tests/test_jellyseerr_stats.py b/backend/tests/test_jellyseerr_stats.py index d810380..2e3d449 100644 --- a/backend/tests/test_jellyseerr_stats.py +++ b/backend/tests/test_jellyseerr_stats.py @@ -115,3 +115,49 @@ def test_stat_widget_unknown_stat_returns_error(): with patch("media_library_viewer_api.widgets.sources.get_stats_provider", return_value=provider): data = asyncio.run(src.fetch(_service(), "stat", {"stat": "nope"})) assert "error" in data + + +def test_jellyseer_client_requests_maps_fields(): + """requests() paginates /request and maps type/status/media_status enums.""" + from media_library_viewer_api.clients.jellyseerr import JellyseerrClient + + c = JellyseerrClient("https://js.example.com", "key") + c.session = MagicMock() + resp = MagicMock() + resp.raise_for_status.return_value = None + resp.status_code = 200 + resp.text = "" + resp.json.return_value = { + "results": [ + { + "id": 7, + "type": 1, + "title": "Inception", + "status": 1, + "media": {"status": 5}, + "createdAt": 1_700_000_000, + } + ] + } + c.session.get.return_value = resp + + out = c.requests(500) + + assert len(out) == 1 + r = out[0] + assert r["id"] == 7 + assert r["type"] == "movie" # 1 -> movie + assert r["name"] == "Inception" + assert r["status"] == "pending" # 1 -> pending + assert r["media_status"] == "available" # 5 -> available + assert r["created_at"] == 1_700_000_000 + # Single short page -> no second fetch. + assert c.session.get.call_count == 1 + + +def test_fetch_jellyseer_requests_not_configured_returns_empty(): + """No jellyseerr config -> empty list (tab shows 'No requests').""" + from media_library_viewer_api.widgets.jellyseerr_stats import fetch_jellyseer_requests + + service = ServiceRecord(id="s", service_type="jellyfin", name="JF", config={}, secrets={}) + assert fetch_jellyseer_requests(service) == [] diff --git a/frontend/src/api/.pi-map.index.md b/frontend/src/api/.pi-map.index.md index b463f4e..52ca7b7 100644 --- a/frontend/src/api/.pi-map.index.md +++ b/frontend/src/api/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/api ## role -Typed API client layer that centralizes all backend communication for the frontend application across multiple service domains. +Frontend API client layer that centralizes typed HTTP requests to backend services and external integrations. ## parent index: frontend/src/.pi-map.index.md map: frontend/src/.pi-map.md diff --git a/frontend/src/api/.pi-map.md b/frontend/src/api/.pi-map.md index 9347cca..63ef5fd 100644 --- a/frontend/src/api/.pi-map.md +++ b/frontend/src/api/.pi-map.md @@ -4,18 +4,18 @@ dir: frontend/src/api index: frontend/src/api/.pi-map.index.md ## role -Typed API client layer that centralizes all backend communication for the frontend application across multiple service domains. +Frontend API client layer that centralizes typed HTTP requests to backend services and external integrations. ## files - authentik.ts | API client providing functions to fetch users, send messages, and check message status from the Authentik service. | exp: AuthentikUser, AuthentikUsersResponse, AuthentikMessageInput, AuthentikMessageResponse, func:fetchAuthentikUsers(serviceId: string, params: { search?: string; page?: number; page_size?: number }) → Promise, call:get, call:String, func:sendAuthentikMessage(serviceId: string, input: AuthentikMessageInput) → Promise, call:post, func:fetchAuthentikMessageStatus(serviceId: string) → Promise>, call:get | dep: ./shared - backups.ts | API client functions for fetching and managing backup jobs, runs, alerts, and dashboard summaries. | exp: func:fetchBackupJobs(serviceId: string) → Promise, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string, serviceId: string) → Promise, call:get, func:fetchBackupRun(runId: string) → Promise, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: string, serviceId: string) → Promise, call:get, call:String, func:acknowledgeBackupAlert(alertId: string) → Promise, call:post, func:fetchBackupDashboard() → Promise, call:get | dep: ./shared, ../types/backups - client.ts | Typed API client providing functions for interacting with a FastAPI backend across dashboard, monitoring, media, files, jobs, and observability endpoints. | exp: fetchCounts, fetchLibraries, fetchActivity, fetchUsers, fetchNowPlaying, fetchMonitoringMachines, fetchAppVersion, fetchDashboardShortcuts, saveDashboardShortcut, deleteDashboardShortcut, fetchMonitoringSettings, fetchSSHKeys, generateSSHKey, saveSSHKey, deleteSSHKey, fetchSavedTasks, fetchSavedTaskRuns, saveTask, deleteTask, runTask, saveMonitoringMachine, testMonitoringMachineSSH, deleteMonitoringMachine, resetLocalDatabase, fetchMediaStatus, buildMediaIndex, stopMediaIndexBuild, forceStopMediaIndexBuild, queryMedia, fetchDirectoryListing, fetchFfprobe, fetchStat, resolvePath, fetchJobTemplates, runJob, fetchUserMessageQueueStatus, sendUserMessage, fetchAlertmanagerAlerts, fetchAlertmanagerStatus, fetchPrometheusStatus, fetchPrometheusTargets | dep: ../types, ./shared - dashboards.ts | API client providing CRUD operations for named dashboards via REST endpoints. | exp: NamedDashboard, NamedDashboardInput, func:fetchDashboards() → Promise, call:get, func:fetchDashboardBySlug(slug: string) → Promise, call:get, call:encodeURIComponent, func:createDashboard(input: NamedDashboardInput) → Promise, call:post, func:updateDashboard(input: NamedDashboardInput) → Promise, call:put, func:deleteDashboard(id: string) → Promise<{ status: string }>, call:del | dep: ./shared -- jellyseerr.ts | Fetches Jellyseerr request statistics and recent requests for a Jellyfin service instance via an API endpoint. | exp: JellyseerStat, JellyseerRecentRequest, JellyseerStatsResponse, func:fetchJellyseerrStats(jellyfinServiceId: string) → Promise, call:get | dep: ./shared +- jellyseerr.ts | This file provides API client functions to fetch Jellyseerr request statistics and request lists for a Jellyfin service instance. | exp: JellyseerStat, JellyseerRecentRequest, JellyseerStatsResponse, JellyseerRequest, func:fetchJellyseerrStats(jellyfinServiceId: string) → Promise, call:get, func:fetchJellyseerrRequests(jellyfinServiceId: string) → Promise | dep: ./shared - services.ts | API client functions for CRUD operations and testing of service instances. | exp: func:fetchServiceTypes() → Promise, call:get, func:fetchServiceInstances(serviceType: string) → Promise, call:get, func:createServiceInstance(input: ServiceInstanceInput) → Promise, call:post, func:updateServiceInstance(input: ServiceInstanceInput) → Promise, call:put, raise:Error, func:deleteServiceInstance(serviceId: string) → Promise<{ status: string }>, call:del, func:testServiceInstance(input: ServiceInstanceInput) → Promise, call:post | dep: ./shared, ../types - shared.ts | Provides shared API helper functions (GET, POST, PUT, DELETE, etc.) that automatically attach OIDC auth tokens and handle URL building and error parsing for backend requests. | exp: API_BASE, func:buildUrl(path: string, params: Record) → string, call:isAbsoluteUrl, call:Object.entries, call:url.searchParams.set, call:url.toString, func:readErrorDetail(response: Response) → Promise, call:response.text, call:JSON.parse, call:detail.trim, func:buildHeaders(isJsonBody: boolean) → Headers, call:getAccessToken, call:headers.set, func:get(path: string, params: Record) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:post(path: string, body: unknown) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:postForm(path: string, body: FormData) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:put(path: string, body: unknown) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:del(path: string) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error | dep: ../auth, getAccessToken (from ../auth), fetch API, Headers API, URL API, import.meta.env - widgets.ts | API client module providing CRUD operations for widget instances, widget references, builtin widget kinds, and widget data retrieval. | exp: WidgetReference, WidgetReferenceInput, func:fetchBuiltinWidgetKinds() → Promise< BuiltinWidgetKindInfo[] >, call:get, func:fetchWidgetInstances(serviceId: string, scope: "dashboard" | "service") → Promise, call:get, func:createWidgetInstance(input: WidgetInstanceInput) → Promise, call:post, func:updateWidgetInstance(input: WidgetInstanceInput) → Promise, call:put, raise:Error, func:deleteWidgetInstance(widgetId: string) → Promise<{ status: string }>, call:del, func:fetchWidgetData(widgetId: string) → Promise, call:get, func:fetchWidgetReferences(dashboardScope: string) → Promise, call:get, func:createWidgetReference(input: WidgetReferenceInput) → Promise, call:post, func:deleteWidgetReference(referenceId: string) → Promise<{ status: string }>, call:del, func:detachWidgetReference(referenceId: string) → Promise, call:post, func:updateWidgetReference(referenceId: string, sortOrder: number) → Promise, call:put | dep: ./shared, ../types ## arch -Modular domain-based API clients built on a shared HTTP helper that handles OIDC authentication, URL building, and error parsing, with each module exposing typed functions for specific service endpoints. +Modular API client pattern with a shared helper module for authentication, URL building, and error handling, alongside domain-specific client files organized by service area. ## tags fetch, call:get, widget, dashboard, call:build, authentik, delete, call:post ## symbols diff --git a/frontend/src/api/jellyseerr.ts b/frontend/src/api/jellyseerr.ts index 38ac799..933c824 100644 --- a/frontend/src/api/jellyseerr.ts +++ b/frontend/src/api/jellyseerr.ts @@ -30,3 +30,23 @@ export async function fetchJellyseerrStats( jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined, ); } + +export interface JellyseerRequest { + id?: number | string; + type?: string; + name?: string; + status?: string; + media_status?: string; + created_at?: number | string; +} + +/** Fetch Jellyseerr requests (all, mapped) for the Requests tab table. */ +export async function fetchJellyseerrRequests( + jellyfinServiceId?: string, +): Promise { + const res = await get<{ requests: JellyseerRequest[]}>( + "/api/jellyseerr/requests", + jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined, + ); + return res.requests ?? []; +} diff --git a/frontend/src/components/.pi-map.index.md b/frontend/src/components/.pi-map.index.md index bb29d2c..a0d283c 100644 --- a/frontend/src/components/.pi-map.index.md +++ b/frontend/src/components/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/components ## role -UI component library providing reusable React components for dashboard widgets, backup management tables, charts, dialogs, and media session displays. +Shared UI component library providing reusable React components for tables, cards, charts, dialogs, and dashboard widgets across the frontend application. ## parent index: frontend/src/.pi-map.index.md map: frontend/src/.pi-map.md @@ -21,6 +21,7 @@ map: frontend/src/.pi-map.md - ConfirmDialog.tsx - DialogFooter.tsx - HoverEditButton.tsx +- JellyseerRequestsTable.tsx - LibraryOverview.tsx - LineSeriesChart.tsx - MetricCard.tsx diff --git a/frontend/src/components/.pi-map.md b/frontend/src/components/.pi-map.md index 604af69..59f04dc 100644 --- a/frontend/src/components/.pi-map.md +++ b/frontend/src/components/.pi-map.md @@ -4,7 +4,7 @@ dir: frontend/src/components index: frontend/src/components/.pi-map.index.md ## role -UI component library providing reusable React components for dashboard widgets, backup management tables, charts, dialogs, and media session displays. +Shared UI component library providing reusable React components for tables, cards, charts, dialogs, and dashboard widgets across the frontend application. ## files - BackupAlertsTable.tsx | Renders a responsive table of backup alerts with severity badges and acknowledge actions, switching between desktop table and mobile card layouts. | exp: func:BackupAlertsTable({ alerts, onAcknowledge }: Props), call:useIsMobile, call:onAcknowledge, call:alerts.map, call:severityVariant, call:formatTimestamp | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, @/components/ui/mobile-card, ../hooks/useIsMobile, ../types/backups, useIsMobile hook, BackupAlert type - BackupDashboardWidget.tsx | Displays a dashboard widget summarizing backup job statistics including total jobs, 24-hour success rate, active alerts, and last failure timestamp. | exp: func:BackupDashboardWidget(), call:useBackupDashboard, call:new Date(data.last_failed_at * 1000).toLocaleString | dep: @/components/ui/badge, @/components/ui/card, ../hooks/useBackups @@ -13,8 +13,9 @@ UI component library providing reusable React components for dashboard widgets, - ConfirmDialog.tsx | Reusable confirmation dialog component that wraps shadcn/ui Dialog primitives with standardized cancel/confirm footer behavior. | exp: func:ConfirmDialog({ open, title, message, confirmLabel = "Delete", onCancel, onConfirm, busy, }: { open: boolean; title: string; message: string; confirmLabel?: string; onCancel: () => void; onConfirm: () => void; busy?: boolean; }), call:onCancel | dep: @/components/ui/dialog, ./DialogFooter - DialogFooter.tsx | Renders a dialog footer component with cancel, optional secondary action, and confirm buttons, mapping legacy MUI color/variant props to shadcn Button variants. | exp: func:DialogFooter({ onCancel, cancelLabel = "Cancel", onConfirm, confirmLabel, confirmBusyLabel, confirmDisabled, confirmColor = "primary", confirmVariant = "contained", confirmStartIcon, secondaryAction, }: DialogFooterProps), call:resolveConfirmVariant | dep: react, @/components/ui/button - HoverEditButton.tsx | Renders a hover-reveal edit button for desktop and always-visible edit button for mobile, preserving legacy CSS class hooks. | exp: func:HoverEditButton({ onClick, label = "Edit", mobile = "always", }: HoverEditButtonProps), call:e.stopPropagation, call:onClick | dep: lucide-react, @/components/ui/button +- JellyseerRequestsTable.tsx | Renders a sortable, filterable, and paginated table of Jellyseerr media requests using TanStack Table with client-side search and status filtering. | exp: func:JellyseerRequestsTable({ serviceId }: { serviceId: string }), call:useJellyseerRequests, call:useState, call:useMemo, call:search.trim().toLowerCase, call:requests.filter, call:OPEN_STATUSES.has, call:String(r.name ?? "").toLowerCase().includes, call:useReactTable, call:getCoreRowModel, call:getSortedRowModel, call:getPaginationRowModel, call:setSearch, call:setStatusFilter, call:table.getHeaderGroups().map, call:hg.headers.map, call:header.column.getToggleSortingHandler, call:flexRender, call:header.getContext, call:header.column.getIsSorted, call:table.getRowModel().rows.map, call:row.getVisibleCells().map, call:cell.getContext, call:table.getState, call:table.getPageCount | dep: react, @tanstack/react-table, lucide-react, @/components/ui/alert, @/components/ui/badge, @/components/ui/input, @/components/ui/select, @/components/ui/skeleton, @/components/ui/table, @/components/ui/table-pagination, ../hooks/useJellyseer, ../api/jellyseerr - LibraryOverview.tsx | Renders a two-column responsive grid displaying movie and TV library counts using shadcn/ui Card components | exp: func:LibraryOverview({ libraries }: Props), call:libraries.filter, call:movieLibs.map, call:lib.total.toLocaleString, call:lib.movies.toLocaleString, call:tvLibs.map, call:lib.series.toLocaleString | dep: @/components/ui/card, ../types -- LineSeriesChart.tsx | Renders a responsive multi-series line chart using recharts with automatic metric scaling and time-based X-axis formatting. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, unit = "none", scale = "auto", }: LineSeriesChartProps), call:series.reduce, call:Math.abs, call:metricScaleInfo, call:formatScaled, call:mergeSeries, call:formatTime, call:Number, call:fmt, call:series.map | dep: recharts, ../lib/metricFormat, metricFormat +- LineSeriesChart.tsx | Renders multiple time-series as a responsive line chart with automatic metric scaling and formatting. | exp: SeriesPoint, ChartSeries, func:LineSeriesChart({ series, height = 300, unit = "none", scale = "auto", }: LineSeriesChartProps), call:series.reduce, call:Math.abs, call:metricScaleInfo, call:formatScaled, call:mergeSeries, call:formatTime, call:Number, call:fmt, call:series.map | dep: recharts, ../lib/metricFormat, metricFormat - MetricCard.tsx | Renders a compact metric display card with label, value, and optional subtext using Tailwind CSS styling. | exp: func:MetricCard({ label, value, subtext }: Props) | dep: @/components/ui/card - NowPlaying.tsx | Renders a now-playing panel by wrapping SessionActivityPanel with a specific empty message for user activity sessions. | exp: func:NowPlaying({ sessions, onSelectSession }: Props) | dep: ../types, ./SessionActivityPanel - PinnedServiceLink.tsx | Renders a navigable card-shaped button for pinned service shortcuts on dashboards and provides a helper to construct service target paths. | exp: PinnedServiceLinkProps, func:PinnedServiceLink({ label, target, icon: Icon = Boxes, className, }: PinnedServiceLinkProps), call:useNavigate, call:navigate, call:cn, func:serviceLinkTarget(serviceType: string, serviceId: string, tab: string) → string | dep: react-router-dom, lucide-react, @/lib/utils @@ -23,12 +24,12 @@ UI component library providing reusable React components for dashboard widgets, - ServiceTestPanel.tsx | Presentational component rendering a "Test credentials" panel with test button, result display, and a "Save anyway" checkbox. | exp: func:ServiceTestPanel({ result, isPending, saveAnyway, onTest, onSaveAnywayChange, disabled, }: Props), call:onSaveAnywayChange | dep: @/components/ui/alert, @/components/ui/button, ../types - SessionActivityPanel.tsx | Renders a scrollable table displaying live media session activity details with status badges and optional session selection callbacks. | exp: func:SessionActivityPanel({ sessions, emptyMessage = "No live sessions matched to this user.", selectedUserLabel, onSelectSession, }: Props), call:buildStatusSummary, call:sessions.map, call:formatStateLabel, call:onSelectSession, call:sessionStateVariant, call:event.stopPropagation | dep: @/components/ui/badge, @/components/ui/button, @/components/ui/table, ../types - TabbedCard.tsx | Renders a card with a line-style tab bar header and content area, acting as a controlled wrapper around shadcn/ui Tabs for backward-compatible API migration from MUI. | exp: func:TabbedCard({ value, onChange, tabs, children, }: TabbedCardProps), call:onChange, call:String | dep: react, @/components/ui/card, @/components/ui/tabs -- WidgetConfigDialog.tsx | This file provides a React dialog component for creating, editing, deleting, and managing dashboard widgets and their specific configurations. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useMemo, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui/* (dialog, button, input, textarea, label, switch, select, badge, alert, sheet-form) +- WidgetConfigDialog.tsx | This file provides a React dialog component for creating, editing, reordering, and deleting dashboard widgets, including managing references to existing widgets. | exp: func:WidgetConfigDialog({ open, onClose, serviceId, dashboardScope, editWidgetId, }: Props), call:useWidgetInstances, call:useMemo, call:useServiceInstances, call:useTasks, call:useSaveWidgetInstance, call:useDeleteWidgetInstance, call:useWidgetReferences, call:useCreateWidgetReference, call:useDeleteWidgetReference, call:useDetachWidgetReference, call:useUpdateWidgetReference, call:useState, call:Boolean, call:useEffect, call:instances.find, call:references.find, call:startEdit, call:setDraft, call:SERVICE_REGISTRY[ services.find((s) => s.id === serviceId)?.service_type ?? "" ]?.widgets.find, call:services.find, call:setDraftBaseline, call:onClose, call:saveWidget.mutateAsync, call:reset, call:updateRef.mutateAsync, call:deleteWidget.mutateAsync, call:[...instances].sort, call:references.map, call:[...owned, ...refs].sort, call:instances.map, call:existingSearch.toLowerCase().trim, call:allWidgets .filter((w) => !onDashboard.has(w.id)) .filter, call:onDashboard.has, call:w.title.toLowerCase().includes, call:w.widget_kind.toLowerCase().includes, call:createRef.mutateAsync, call:deleteRef.mutateAsync, call:detachRef.mutateAsync, call:SERVICE_REGISTRY[ services.find((s) => s.id === draft.serviceId)?.service_type ?? "" ]?.widgets.find, call:useIsMobile, call:String, call:Number, call:combinedWidgets.map, call:bindingLabel, call:moveInstance, call:toggleEnabled, call:handleDetach, call:handleRemoveReference, call:removeInstance, call:setShowExisting, call:setExistingSearch, call:availableWidgets.map, call:handleAddReference, call:Object.values(BUILTIN_WIDGETS).map, call:startAddBuiltIn, call:services .filter((s) => s.enabled) // When scoped to a service Overview, only show widgets for THAT // service instance's type (not all services' widgets). .filter((s) => !serviceId || s.id === serviceId) .flatMap, call:(SERVICE_REGISTRY[s.service_type]?.widgets ?? []).map, call:startAddService, call:handleClose, call:JSON.stringify | dep: react, @/components/ui/dialog, @/components/ui/button, @/components/ui/input, @/components/ui/textarea, @/components/ui/label, @/components/ui/switch, @/components/ui/select, @/components/ui/badge, @/components/ui/alert, lucide-react, ../hooks/useWidgets, ../hooks/useServices, ../hooks/useSettings, ../hooks/useIsMobile, @/components/ui/sheet-form, ../types, ../integrations/registry, @/components/ui/* - WidgetInstance.tsx | Renders a widget instance card that dynamically resolves and displays a widget component, with optional edit and copy actions. | exp: func:WidgetInstanceCard({ widget, onEdit, onCopy }: Props), call:useServiceInstances, call:resolveWidget, call:onCopy, call:onEdit | dep: @/components/ui/alert, @/components/ui/button, lucide-react, ../hooks/useServices, ../integrations/registry, ../types, ./SectionCard ## arch -Presentational React components built on shadcn/ui primitives with Tailwind CSS, using responsive design patterns (desktop table/mobile card) and wrapping legacy APIs for MUI-to-shadcn migration compatibility. +Presentational React components built on shadcn/ui primitives with responsive design patterns, TanStack Table for data tables, and legacy API compatibility layers for MUI migration. ## tags -call:use, components, ui, card, widget, table, backup, call:on +call:use, components, ui, card, table, widget, backup, call:on ## symbols - BackupAlertsTable - BackupDashboardWidget @@ -37,7 +38,7 @@ call:use, components, ui, card, widget, table, backup, call:on - ConfirmDialog - DialogFooter - HoverEditButton -- LibraryOverview +- JellyseerRequestsTable ## workflows - change components behavior read: BackupAlertsTable.tsx, BackupDashboardWidget.tsx, BackupJobsTable.tsx diff --git a/frontend/src/components/JellyseerRequestsTable.tsx b/frontend/src/components/JellyseerRequestsTable.tsx new file mode 100644 index 0000000..a696467 --- /dev/null +++ b/frontend/src/components/JellyseerRequestsTable.tsx @@ -0,0 +1,229 @@ +/** + * JellyseerRequestsTable — sortable/filterable table of Jellyseerr requests. + * + * Uses TanStack Table directly (the shared DataTable is deliberately + * visibility-only). Defaults: status filter = "open" (pending/approved/ + * processing), sorted by date added (newest first). Client-side sort + filter + * + pagination over the backend's fetched set. + */ +import { useMemo, useState } from "react"; +import { + type ColumnDef, + type SortingState, + flexRender, + getCoreRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { ArrowDown, ArrowUp, ChevronsUpDown, Search } from "lucide-react"; +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { TablePagination } from "@/components/ui/table-pagination"; +import { useJellyseerRequests } from "../hooks/useJellyseer"; +import type { JellyseerRequest } from "../api/jellyseerr"; + +const OPEN_STATUSES = new Set(["pending", "approved", "processing"]); +type StatusFilter = "open" | "all" | "pending" | "approved" | "declined"; + +function formatDate(v?: number | string): string { + if (!v) return "—"; + const n = Number(v); + const ms = n > 1e12 ? n : n * 1000; // seconds -> ms + const d = new Date(ms); + return Number.isNaN(d.getTime()) ? String(v) : d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); +} + +const columns: ColumnDef[] = [ + { + accessorKey: "name", + header: "Name", + cell: ({ row }) => ( + {row.original.name ?? "—"} + ), + }, + { + accessorKey: "type", + header: "Type", + cell: ({ row }) => ( + {row.original.type ?? "—"} + ), + }, + { + accessorKey: "status", + header: "Status", + cell: ({ row }) => ( + {row.original.status ?? "—"} + ), + }, + { + accessorKey: "media_status", + header: "Media", + cell: ({ row }) => + row.original.media_status ? ( + {row.original.media_status} + ) : ( + "—" + ), + }, + { + accessorKey: "created_at", + header: "Requested", + cell: ({ row }) => formatDate(row.original.created_at), + sortDescFirst: true, + }, +]; + +export function JellyseerRequestsTable({ serviceId }: { serviceId: string }) { + const { data: requests = [], isLoading, error } = useJellyseerRequests(serviceId); + const [sorting, setSorting] = useState([ + { id: "created_at", desc: true }, + ]); + const [search, setSearch] = useState(""); + const [statusFilter, setStatusFilter] = useState("open"); + + const filtered = useMemo(() => { + const q = search.trim().toLowerCase(); + return requests.filter((r) => { + const status = String(r.status ?? ""); + if (statusFilter === "open") { + if (!OPEN_STATUSES.has(status)) return false; + } else if (statusFilter !== "all" && status !== statusFilter) { + return false; + } + if (q && !String(r.name ?? "").toLowerCase().includes(q)) return false; + return true; + }); + }, [requests, statusFilter, search]); + + /* eslint-disable react-hooks/incompatible-library -- TanStack's useReactTable + intentionally returns non-memoizable updater fns (controlled state). */ + const table = useReactTable({ + data: filtered, + columns, + state: { sorting }, + onSortingChange: setSorting, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getPaginationRowModel: getPaginationRowModel(), + initialState: { pagination: { pageSize: 10 } }, + }); + + if (isLoading) { + return ; + } + if (error) { + return ( + + {error.message} + + ); + } + + return ( +
+
+
+ + setSearch(e.target.value)} + className="pl-8" + /> +
+ +
+ +
+ + + {table.getHeaderGroups().map((hg) => ( + + {hg.headers.map((header) => ( + + {header.isPlaceholder ? null : ( + + )} + + ))} + + ))} + + + {table.getRowModel().rows.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + )) + ) : ( + + + No requests. + + + )} + +
+
+ + +
+ ); +} diff --git a/frontend/src/hooks/useJellyseer.ts b/frontend/src/hooks/useJellyseer.ts index 6cbb900..877639e 100644 --- a/frontend/src/hooks/useJellyseer.ts +++ b/frontend/src/hooks/useJellyseer.ts @@ -1,6 +1,8 @@ import { useQuery } from "@tanstack/react-query"; import { + fetchJellyseerrRequests, fetchJellyseerrStats, + type JellyseerRequest, type JellyseerStatsResponse, } from "../api/jellyseerr"; @@ -15,3 +17,13 @@ export function useJellyseerrStats(jellyfinServiceId?: string) { retry: false, }); } + +/** Poll Jellyseerr requests for the Requests tab table. */ +export function useJellyseerRequests(jellyfinServiceId?: string) { + return useQuery({ + queryKey: ["jellyseerr", "requests", jellyfinServiceId ?? "default"], + queryFn: () => fetchJellyseerrRequests(jellyfinServiceId), + refetchInterval: 60_000, + retry: false, + }); +} diff --git a/frontend/src/pages/service-tabs/.pi-map.index.md b/frontend/src/pages/service-tabs/.pi-map.index.md index 6d24abf..2f049f7 100644 --- a/frontend/src/pages/service-tabs/.pi-map.index.md +++ b/frontend/src/pages/service-tabs/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/pages/service-tabs ## role -Provides service-specific tabbed UI content components rendered within service detail pages. +Provides service-specific tabbed UI components for managing and monitoring individual service instances across various integrations (SSH, Alertmanager, Jellyfin, Authentik, Prometheus, etc.). ## parent index: frontend/src/pages/.pi-map.index.md map: frontend/src/pages/.pi-map.md diff --git a/frontend/src/pages/service-tabs/.pi-map.md b/frontend/src/pages/service-tabs/.pi-map.md index c7092a0..19a9413 100644 --- a/frontend/src/pages/service-tabs/.pi-map.md +++ b/frontend/src/pages/service-tabs/.pi-map.md @@ -4,7 +4,7 @@ dir: frontend/src/pages/service-tabs index: frontend/src/pages/service-tabs/.pi-map.index.md ## role -Provides service-specific tabbed UI content components rendered within service detail pages. +Provides service-specific tabbed UI components for managing and monitoring individual service instances across various integrations (SSH, Alertmanager, Jellyfin, Authentik, Prometheus, etc.). ## files - ActionsTab.tsx | Provides a UI tab for managing, editing, and running saved SSH tasks (shell or Python) within a service page. | exp: func:ActionsTab({ instance }: { instance: ServiceInstance }), call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:saveTask.mutateAsync, call:setTab, call:String, call:openEdit, call:tasks.map, call:initialFromTask, call:runTask.mutateAsync, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../../types, ../../hooks/useSettings, ../../components/DialogFooter, ../../components/HoverEditButton, ../../components/SectionCard, ../../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea - AlertsTab.tsx | Renders an Alertmanager alerts tab showing alert summaries and an expandable list of active alerts scoped by instance ID. | exp: func:AlertsTab({ instance }: { instance: ServiceInstance }), call:useAlertmanagerAlerts, call:useAlertmanagerStatus, call:alertsSummary.alerts.map | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, @/components/ui/collapsible, ../../types, useObservability hooks, ui/card, ui/badge, ui/alert, ui/skeleton, ui/collapsible, types @@ -14,11 +14,11 @@ Provides service-specific tabbed UI content components rendered within service d - MessagingTab.tsx | Provides a UI for composing and sending HTML email messages to Authentik users via a mail queue system. | exp: func:MessagingTab({ instance }: { instance: ServiceInstance }), call:useState, call:useAuthentikUsers, call:useSendAuthentikMessage, call:(data?.items ?? []).filter, call:setSelectedEmails, call:next.has, call:next.delete, call:next.add, call:subject.trim, call:sendMessage.mutate, call:Array.from, call:sendMessage.data.request_id?.slice, call:setSearch, call:users.slice(0, 20).map, call:selectedEmails.has, call:toggleEmail, call:setSubject, call:setHtmlBody | dep: react, @/components/ui/alert, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/textarea, ../../hooks/useAuthentik, ../../types - MetricsTab.tsx | Renders a Prometheus metrics monitoring tab showing service health status and Node Exporter scrape targets for a given service instance. | exp: func:MetricsTab({ instance }: { instance: ServiceInstance }), call:usePrometheusStatus, call:usePrometheusTargets | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, ../../types, useObservability hooks, ui/card, ui/badge, ui/alert, ui/skeleton, types - OverviewTab.tsx | Renders a configurable per-service overview tab that displays and manages service-specific widgets in a responsive grid. | exp: func:OverviewTab({ instance }: { instance: ServiceInstance }), call:useWidgetInstances, call:useState, call:useMemo, call:widgets .filter((w) => w.enabled) .sort, call:setConfigOpen, call:visibleWidgets.map, call:setEditWidgetId | dep: react, @/components/ui/alert, @/components/ui/button, lucide-react, ../../hooks/useWidgets, ../../components/WidgetInstance, ../../components/WidgetConfigDialog, ../../types, ui/alert, ui/button, useWidgets hook, WidgetInstance component, WidgetConfigDialog component, types -- RequestsTab.tsx | Displays Jellyseerr request statistics and recent requests for a Jellyfin service instance. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record).jellyseerr_url ?? "", ).trim, call:Boolean, call:useJellyseerrStats, call:(data?.stats ?? []).map, call:data.recent.slice(0, 12).map | dep: ../../types, ../../hooks/useJellyseer, @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../../components/MetricCard, lucide-react, ServiceInstance, useJellyseerrStats, Alert, Badge, Skeleton, MetricCard +- RequestsTab.tsx | Displays Jellyseerr request statistics and a requests table for a Jellyfin service instance, with configuration validation and loading/error states. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record).jellyseerr_url ?? "", ).trim, call:Boolean, call:useJellyseerrStats, call:(data?.stats ?? []).map | dep: ../../types, ../../hooks/useJellyseer, @/components/ui/alert, @/components/ui/skeleton, ../../components/MetricCard, ../../components/JellyseerRequestsTable, lucide-react, ServiceInstance type, useJellyseerrStats hook, Alert, Skeleton, MetricCard, JellyseerRequestsTable - UsersTab.tsx | Displays a searchable, paginated table of Authentik users for a given service instance. | exp: func:UsersTab({ instance }: { instance: ServiceInstance }), call:useState, call:useAuthentikUsers, call:Math.max, call:Math.ceil, call:setPage, call:setCommittedSearch, call:setSearch, call:handleSearch, call:users.map, call:Math.min | dep: react, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/table, ../../types, ../../hooks/useAuthentik - index.ts | Maps service types to their corresponding content tab components for rendering a service page. | exp: ServiceTabComponent, ContentTab, OVERVIEW_TAB, func:serviceContentTabs(serviceType: string) → ContentTab[] | dep: react, ../../types, ./OverviewTab, ./AlertsTab, ./MetricsTab, ./MediaTab, ./RequestsTab, ./FilesTab, ./ActionsTab, ./JobsTab, ./UsersTab, ./MessagingTab, OverviewTab, AlertsTab, MetricsTab, MediaTab, RequestsTab, FilesTab, ActionsTab, JobsTab, UsersTab, MessagingTab ## arch -Component-per-tab pattern with a central registry (index.ts) mapping service types to their respective tab components. +Tab-based component architecture with a central type-to-component mapping registry, each tab being a self-contained React component scoped by service instance ID with shared patterns for pagination, loading/error states, and data tables. ## tags call:use, components, ui, tab, state, call:set, locale, string ## symbols diff --git a/frontend/src/pages/service-tabs/RequestsTab.tsx b/frontend/src/pages/service-tabs/RequestsTab.tsx index 8aee838..0d896d5 100644 --- a/frontend/src/pages/service-tabs/RequestsTab.tsx +++ b/frontend/src/pages/service-tabs/RequestsTab.tsx @@ -2,16 +2,16 @@ * RequestsTab — Jellyseerr request stats surface on the Jellyfin page. * * Reads the Jellyfin service's jellyseerr_url (config) + jellyseerr_api_key - * (secret). When configured, polls /api/jellyseerr/stats and renders the - * request-count grid + a recent-requests list. Individual stats can be pinned - * to dashboards via the "Request stat" widget. + * (secret). When configured, polls /api/jellyseerr/stats for the count grid and + * /api/jellyseerr/requests for a sortable/filterable requests table. Individual + * stats can be pinned to dashboards via the "Request stat" widget. */ import type { ServiceInstance } from "../../types"; import { useJellyseerrStats } from "../../hooks/useJellyseer"; import { Alert, AlertDescription } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { MetricCard } from "../../components/MetricCard"; +import { JellyseerRequestsTable } from "../../components/JellyseerRequestsTable"; import { ExternalLink } from "lucide-react"; export function RequestsTab({ instance }: { instance: ServiceInstance }) { @@ -75,36 +75,16 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) { ))} - {data?.recent && data.recent.length > 0 ? ( -
- - Recent requests - - {data.recent.slice(0, 12).map((r, i) => ( -
-
- {r.name ?? "—"} - - {r.type ? String(r.type) : ""} - -
-
- {r.media_status ? ( - {r.media_status} - ) : null} - {r.status ? {r.status} : null} -
-
- ))} -
- ) : null} +
+ + Requests + + +

- Pin individual stats to a dashboard with the “Request stat” - widget. + Pin individual stats to a dashboard with the “Request + stat” widget.

)} diff --git a/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx index 350a64e..453bb01 100644 --- a/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx +++ b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx @@ -1,17 +1,22 @@ import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { RequestsTab } from "../RequestsTab"; -import { useJellyseerrStats } from "../../../hooks/useJellyseer"; +import { + useJellyseerrStats, + useJellyseerRequests, +} from "../../../hooks/useJellyseer"; import type { JellyseerStatsResponse } from "../../../api/jellyseerr"; import type { ServiceInstance } from "../../../types"; -// Mock the stats hook so the tab renders without a QueryClientProvider and we -// can drive the rendered state directly. +// Mock both hooks so the tab + its table render without a QueryClientProvider. vi.mock("../../../hooks/useJellyseer", () => ({ useJellyseerrStats: vi.fn(), + useJellyseerRequests: vi.fn(), })); const mockUseJellyseerrStats = vi.mocked(useJellyseerrStats); +const mockUseJellyseerRequests = vi.mocked(useJellyseerRequests); type StatsResult = ReturnType; +type RequestsResult = ReturnType; function mockStats(result: { data: JellyseerStatsResponse | undefined; @@ -20,6 +25,11 @@ function mockStats(result: { }) { // UseQueryResult has many fields; cast the partial we care about. mockUseJellyseerrStats.mockReturnValue(result as unknown as StatsResult); + mockUseJellyseerRequests.mockReturnValue({ + data: [], + isLoading: false, + error: null, + } as unknown as RequestsResult); } function makeInstance( @@ -57,7 +67,9 @@ describe("RequestsTab", () => { mockStats({ data: undefined, isLoading: false, error: null }); render( , ); expect(screen.getByText(/not configured/i)).toBeInTheDocument(); @@ -91,11 +103,12 @@ describe("RequestsTab", () => { )} />, ); - expect(screen.getByText("https://requests.example.com")).toBeInTheDocument(); + expect( + screen.getByText("https://requests.example.com"), + ).toBeInTheDocument(); expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument(); expect(screen.getByText("Pending")).toBeInTheDocument(); expect(screen.getByText("3")).toBeInTheDocument(); - expect(screen.getByText("Inception")).toBeInTheDocument(); }); it("surfaces a fetch error", () => {