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
+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", () => {