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.
This commit is contained in:
Developer
2026-07-12 17:18:21 +00:00
parent 54851779fb
commit 7665ef4d10
21 changed files with 447 additions and 77 deletions
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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}
@@ -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
File diff suppressed because one or more lines are too long
@@ -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)
+46
View File
@@ -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) == []
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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<AuthentikUsersResponse>, call:get, call:String, func:sendAuthentikMessage(serviceId: string, input: AuthentikMessageInput) → Promise<AuthentikMessageResponse>, call:post, func:fetchAuthentikMessageStatus(serviceId: string) → Promise<Record<string, unknown>>, 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<BackupJob[]>, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string, serviceId: string) → Promise<BackupRun[]>, call:get, func:fetchBackupRun(runId: string) → Promise<BackupRun>, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: string, serviceId: string) → Promise<BackupAlert[]>, call:get, call:String, func:acknowledgeBackupAlert(alertId: string) → Promise<BackupAlert>, call:post, func:fetchBackupDashboard() → Promise<BackupDashboardSummary>, 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<NamedDashboard[]>, call:get, func:fetchDashboardBySlug(slug: string) → Promise<NamedDashboard>, call:get, call:encodeURIComponent, func:createDashboard(input: NamedDashboardInput) → Promise<NamedDashboard>, call:post, func:updateDashboard(input: NamedDashboardInput) → Promise<NamedDashboard>, 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<JellyseerStatsResponse>, 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<JellyseerStatsResponse>, call:get, func:fetchJellyseerrRequests(jellyfinServiceId: string) → Promise<JellyseerRequest[]> | dep: ./shared
- services.ts | API client functions for CRUD operations and testing of service instances. | exp: func:fetchServiceTypes() → Promise<ServiceTypeInfo[]>, call:get, func:fetchServiceInstances(serviceType: string) → Promise<ServiceInstance[]>, call:get, func:createServiceInstance(input: ServiceInstanceInput) → Promise<ServiceInstance>, call:post, func:updateServiceInstance(input: ServiceInstanceInput) → Promise<ServiceInstance>, call:put, raise:Error, func:deleteServiceInstance(serviceId: string) → Promise<{ status: string }>, call:del, func:testServiceInstance(input: ServiceInstanceInput) → Promise<ServiceTestResult>, 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, string>) → string, call:isAbsoluteUrl, call:Object.entries, call:url.searchParams.set, call:url.toString, func:readErrorDetail(response: Response) → Promise<string>, 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<string, string>) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:post(path: string, body: unknown) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:postForm(path: string, body: FormData) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:put(path: string, body: unknown) → Promise<T>, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:del(path: string) → Promise<T>, 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<WidgetInstance[]>, call:get, func:createWidgetInstance(input: WidgetInstanceInput) → Promise<WidgetInstance>, call:post, func:updateWidgetInstance(input: WidgetInstanceInput) → Promise<WidgetInstance>, call:put, raise:Error, func:deleteWidgetInstance(widgetId: string) → Promise<{ status: string }>, call:del, func:fetchWidgetData(widgetId: string) → Promise<WidgetDataResponse>, call:get, func:fetchWidgetReferences(dashboardScope: string) → Promise<WidgetReference[]>, call:get, func:createWidgetReference(input: WidgetReferenceInput) → Promise<WidgetReference>, call:post, func:deleteWidgetReference(referenceId: string) → Promise<{ status: string }>, call:del, func:detachWidgetReference(referenceId: string) → Promise<WidgetInstance>, call:post, func:updateWidgetReference(referenceId: string, sortOrder: number) → Promise<WidgetReference>, 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
+20
View File
@@ -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<JellyseerRequest[]> {
const res = await get<{ requests: JellyseerRequest[]}>(
"/api/jellyseerr/requests",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
return res.requests ?? [];
}
+2 -1
View File
@@ -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
+7 -6
View File
@@ -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
@@ -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<JellyseerRequest>[] = [
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<span className="truncate font-medium">{row.original.name ?? "—"}</span>
),
},
{
accessorKey: "type",
header: "Type",
cell: ({ row }) => (
<span className="capitalize">{row.original.type ?? "—"}</span>
),
},
{
accessorKey: "status",
header: "Status",
cell: ({ row }) => (
<Badge variant="secondary">{row.original.status ?? "—"}</Badge>
),
},
{
accessorKey: "media_status",
header: "Media",
cell: ({ row }) =>
row.original.media_status ? (
<Badge variant="outline">{row.original.media_status}</Badge>
) : (
"—"
),
},
{
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<SortingState>([
{ id: "created_at", desc: true },
]);
const [search, setSearch] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("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 <Skeleton className="h-48 w-full" />;
}
if (error) {
return (
<Alert variant="destructive">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<div className="relative min-w-[180px] flex-1">
<Search className="absolute left-2 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Search requests…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-8"
/>
</div>
<Select
value={statusFilter}
onValueChange={(v) => setStatusFilter(v as StatusFilter)}
>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="all">All</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="declined">Declined</SelectItem>
</SelectContent>
</Select>
</div>
<div className="overflow-hidden rounded-lg border">
<Table>
<TableHeader>
{table.getHeaderGroups().map((hg) => (
<TableRow key={hg.id} className="hover:bg-transparent">
{hg.headers.map((header) => (
<TableHead key={header.id}>
{header.isPlaceholder ? null : (
<button
type="button"
className="inline-flex items-center gap-1"
onClick={header.column.getToggleSortingHandler()}
>
{flexRender(
header.column.columnDef.header,
header.getContext(),
)}
{header.column.getIsSorted() === "asc" ? (
<ArrowUp className="size-3" />
) : header.column.getIsSorted() === "desc" ? (
<ArrowDown className="size-3" />
) : (
<ChevronsUpDown className="size-3 opacity-40" />
)}
</button>
)}
</TableHead>
))}
</TableRow>
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows.length ? (
table.getRowModel().rows.map((row) => (
<TableRow key={String(row.original.id ?? row.index)}>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
))
) : (
<TableRow className="hover:bg-transparent">
<TableCell
colSpan={columns.length}
className="h-16 text-center text-muted-foreground"
>
No requests.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<TablePagination
pageIndex={table.getState().pagination.pageIndex}
pageSize={table.getState().pagination.pageSize}
pageSizeOptions={[10, 20, 50]}
totalRows={table.getRowModel().rows.length}
pageCount={table.getPageCount()}
onPaginationChange={table.setPagination}
/>
</div>
);
}
+12
View File
@@ -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<JellyseerRequest[], Error, JellyseerRequest[]>({
queryKey: ["jellyseerr", "requests", jellyfinServiceId ?? "default"],
queryFn: () => fetchJellyseerrRequests(jellyfinServiceId),
refetchInterval: 60_000,
retry: false,
});
}
@@ -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
+3 -3
View File
@@ -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<string, unknown>).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<string, unknown>).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
+12 -32
View File
@@ -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 }) {
))}
</div>
{data?.recent && data.recent.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent requests
</span>
{data.recent.slice(0, 12).map((r, i) => (
<div
key={String(r.id ?? i)}
className="flex items-center justify-between gap-2 rounded border p-2 text-sm"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{r.name ?? "—"}</span>
<span className="text-xs text-muted-foreground">
{r.type ? String(r.type) : ""}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{r.media_status ? (
<Badge variant="outline">{r.media_status}</Badge>
) : null}
{r.status ? <Badge variant="secondary">{r.status}</Badge> : null}
</div>
</div>
))}
</div>
) : null}
<div className="flex flex-col gap-2">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Requests
</span>
<JellyseerRequestsTable serviceId={instance.id} />
</div>
<p className="text-xs text-muted-foreground">
Pin individual stats to a dashboard with the &ldquo;Request stat&rdquo;
widget.
Pin individual stats to a dashboard with the &ldquo;Request
stat&rdquo; widget.
</p>
</>
)}
@@ -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<typeof useJellyseerrStats>;
type RequestsResult = ReturnType<typeof useJellyseerRequests>;
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(
<RequestsTab
instance={makeInstance({ jellyseerr_url: "https://requests.example.com" })}
instance={makeInstance({
jellyseerr_url: "https://requests.example.com",
})}
/>,
);
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", () => {