diff --git a/frontend/src/api/.pi-map.index.md b/frontend/src/api/.pi-map.index.md index 5d2ac12..1e82288 100644 --- a/frontend/src/api/.pi-map.index.md +++ b/frontend/src/api/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/api ## role -Frontend API client layer that centralizes all HTTP communication with backend services using typed, token-authenticated request functions. +Centralized API client layer providing typed functions for frontend communication with various backend services and external integrations. ## parent index: frontend/src/.pi-map.index.md map: frontend/src/.pi-map.md @@ -13,6 +13,7 @@ map: frontend/src/.pi-map.md - backups.ts - client.ts - dashboards.ts +- jellyseerr.ts - services.ts - shared.ts - widgets.ts diff --git a/frontend/src/api/.pi-map.md b/frontend/src/api/.pi-map.md index e3a8579..25d5950 100644 --- a/frontend/src/api/.pi-map.md +++ b/frontend/src/api/.pi-map.md @@ -4,19 +4,20 @@ dir: frontend/src/api index: frontend/src/api/.pi-map.index.md ## role -Frontend API client layer that centralizes all HTTP communication with backend services using typed, token-authenticated request functions. +Centralized API client layer providing typed functions for frontend communication with various backend services and external integrations. ## files - authentik.ts | API client providing functions to fetch users, send messages, and check message status from the Authentik service. | exp: AuthentikUser, AuthentikUsersResponse, AuthentikMessageInput, AuthentikMessageResponse, func:fetchAuthentikUsers(serviceId: string, params: { search?: string; page?: number; page_size?: number }) → Promise, call:get, call:String, func:sendAuthentikMessage(serviceId: string, input: AuthentikMessageInput) → Promise, call:post, func:fetchAuthentikMessageStatus(serviceId: string) → Promise>, call:get | dep: ./shared -- backups.ts | API client functions for fetching and managing backup jobs, runs, alerts, and dashboard summaries. | exp: func:fetchBackupJobs() → Promise, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string) → Promise, call:get, func:fetchBackupRun(runId: string) → Promise, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: string) → Promise, call:get, call:String, func:acknowledgeBackupAlert(alertId: string) → Promise, call:post, func:fetchBackupDashboard() → Promise, call:get | dep: ./shared, ../types/backups -- client.ts | Typed API client module that provides frontend 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, fetch API +- backups.ts | API client functions for fetching and managing backup jobs, runs, alerts, and dashboard summaries. | exp: func:fetchBackupJobs(serviceId: string) → Promise, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string, serviceId: string) → Promise, call:get, func:fetchBackupRun(runId: string) → Promise, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: string, serviceId: string) → Promise, call:get, call:String, func:acknowledgeBackupAlert(alertId: string) → Promise, call:post, func:fetchBackupDashboard() → Promise, call:get | dep: ./shared, ../types/backups +- client.ts | Typed API client providing functions for interacting with a FastAPI backend across dashboard, monitoring, media, files, jobs, and observability endpoints. | exp: fetchCounts, fetchLibraries, fetchActivity, fetchUsers, fetchNowPlaying, fetchMonitoringMachines, fetchAppVersion, fetchDashboardShortcuts, saveDashboardShortcut, deleteDashboardShortcut, fetchMonitoringSettings, fetchSSHKeys, generateSSHKey, saveSSHKey, deleteSSHKey, fetchSavedTasks, fetchSavedTaskRuns, saveTask, deleteTask, runTask, saveMonitoringMachine, testMonitoringMachineSSH, deleteMonitoringMachine, resetLocalDatabase, fetchMediaStatus, buildMediaIndex, stopMediaIndexBuild, forceStopMediaIndexBuild, queryMedia, fetchDirectoryListing, fetchFfprobe, fetchStat, resolvePath, fetchJobTemplates, runJob, fetchUserMessageQueueStatus, sendUserMessage, fetchAlertmanagerAlerts, fetchAlertmanagerStatus, fetchPrometheusStatus, fetchPrometheusTargets | dep: ../types, ./shared - dashboards.ts | API client providing CRUD operations for named dashboards via REST endpoints. | exp: NamedDashboard, NamedDashboardInput, func:fetchDashboards() → Promise, call:get, func:fetchDashboardBySlug(slug: string) → Promise, call:get, call:encodeURIComponent, func:createDashboard(input: NamedDashboardInput) → Promise, call:post, func:updateDashboard(input: NamedDashboardInput) → Promise, call:put, func:deleteDashboard(id: string) → Promise<{ status: string }>, call:del | dep: ./shared -- services.ts | Provides API service functions for CRUD operations on service instances and fetching service types. | exp: func:fetchServiceTypes() → Promise, call:get, func:fetchServiceInstances(serviceType: string) → Promise, call:get, func:createServiceInstance(input: ServiceInstanceInput) → Promise, call:post, func:updateServiceInstance(input: ServiceInstanceInput) → Promise, call:put, raise:Error, func:deleteServiceInstance(serviceId: string) → Promise<{ status: string }>, call:del | dep: ./shared, ../types +- jellyseerr.ts | Fetches Jellyseerr request statistics and recent requests for a Jellyfin service instance via an API endpoint. | exp: JellyseerStat, JellyseerRecentRequest, JellyseerrStatsResponse, func:fetchJellyseerrStats(jellyfinServiceId: string) → Promise, call:get | dep: ./shared, shared +- services.ts | API client functions for CRUD operations and testing of service instances. | exp: func:fetchServiceTypes() → Promise, call:get, func:fetchServiceInstances(serviceType: string) → Promise, call:get, func:createServiceInstance(input: ServiceInstanceInput) → Promise, call:post, func:updateServiceInstance(input: ServiceInstanceInput) → Promise, call:put, raise:Error, func:deleteServiceInstance(serviceId: string) → Promise<{ status: string }>, call:del, func:testServiceInstance(input: ServiceInstanceInput) → Promise, call:post | dep: ./shared, ../types - shared.ts | Provides shared API helper functions (GET, POST, PUT, DELETE, etc.) that automatically attach OIDC auth tokens and handle URL building and error parsing for backend requests. | exp: API_BASE, func:buildUrl(path: string, params: Record) → string, call:isAbsoluteUrl, call:Object.entries, call:url.searchParams.set, call:url.toString, func:readErrorDetail(response: Response) → Promise, call:response.text, call:JSON.parse, call:detail.trim, func:buildHeaders(isJsonBody: boolean) → Headers, call:getAccessToken, call:headers.set, func:get(path: string, params: Record) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:post(path: string, body: unknown) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:postForm(path: string, body: FormData) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error, func:put(path: string, body: unknown) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:JSON.stringify, call:response.json, raise:Error, func:del(path: string) → Promise, call:fetch, call:buildUrl, call:buildHeaders, call:response.json, raise:Error | dep: ../auth, getAccessToken (from ../auth), fetch API, Headers API, URL API, import.meta.env - widgets.ts | API client module providing CRUD operations for widget instances, widget references, builtin widget kinds, and widget data retrieval. | exp: WidgetReference, WidgetReferenceInput, func:fetchBuiltinWidgetKinds() → Promise< BuiltinWidgetKindInfo[] >, call:get, func:fetchWidgetInstances(serviceId: string, scope: "dashboard" | "service") → Promise, call:get, func:createWidgetInstance(input: WidgetInstanceInput) → Promise, call:post, func:updateWidgetInstance(input: WidgetInstanceInput) → Promise, call:put, raise:Error, func:deleteWidgetInstance(widgetId: string) → Promise<{ status: string }>, call:del, func:fetchWidgetData(widgetId: string) → Promise, call:get, func:fetchWidgetReferences(dashboardScope: string) → Promise, call:get, func:createWidgetReference(input: WidgetReferenceInput) → Promise, call:post, func:deleteWidgetReference(referenceId: string) → Promise<{ status: string }>, call:del, func:detachWidgetReference(referenceId: string) → Promise, call:post, func:updateWidgetReference(referenceId: string, sortOrder: number) → Promise, call:put | dep: ./shared, ../types ## arch -Modular API client pattern with a shared base client (`shared.ts`) handling authentication and error parsing, while domain-specific modules (authentik, backups, dashboards, services, widgets, client) expose typed CRUD and fetch operations per service area. +Modular per-domain API modules (authentik, backups, dashboards, jellyseerr, services, widgets) built on a shared HTTP client that handles OIDC token injection, URL construction, and error parsing. ## tags -fetch, call:get, widget, dashboard, call:build, authentik, delete, backup +fetch, call:get, widget, dashboard, call:build, authentik, delete, call:post ## symbols - fetchAuthentikUsers - sendAuthentikMessage diff --git a/frontend/src/api/jellyseerr.ts b/frontend/src/api/jellyseerr.ts new file mode 100644 index 0000000..4abc8ee --- /dev/null +++ b/frontend/src/api/jellyseerr.ts @@ -0,0 +1,32 @@ +import { get } from "./shared"; + +export interface JellyseerStat { + key: string; + label: string; + value: number; +} + +export interface JellyseerRecentRequest { + id?: number | string; + type?: number | string; + name?: string; + status?: string; + media_status?: string; + created_at?: number | string; +} + +export interface JellyseerrStatsResponse { + stats: JellyseerStat[]; + recent: JellyseerRecentRequest[]; + detail?: string | null; +} + +/** Fetch Jellyseerr request stats for a Jellyfin service instance. */ +export async function fetchJellyseerrStats( + jellyfinServiceId?: string, +): Promise { + return get( + "/api/jellyseerr/stats", + jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined, + ); +} diff --git a/frontend/src/hooks/useJellyseer.ts b/frontend/src/hooks/useJellyseer.ts new file mode 100644 index 0000000..6cbb900 --- /dev/null +++ b/frontend/src/hooks/useJellyseer.ts @@ -0,0 +1,17 @@ +import { useQuery } from "@tanstack/react-query"; +import { + fetchJellyseerrStats, + type JellyseerStatsResponse, +} from "../api/jellyseerr"; + +/** Poll Jellyseerr request stats for a Jellyfin service (shares the backend cache). */ +export function useJellyseerrStats(jellyfinServiceId?: string) { + return useQuery({ + queryKey: ["jellyseerr", "stats", jellyfinServiceId ?? "default"], + queryFn: () => fetchJellyseerrStats(jellyfinServiceId), + refetchInterval: 60_000, + // The backend surfaces "not configured"/unreachable as normal responses or + // 503s; don't hammer it with retries. + retry: false, + }); +} diff --git a/frontend/src/integrations/.pi-map.index.md b/frontend/src/integrations/.pi-map.index.md index 82192c1..f8c2858 100644 --- a/frontend/src/integrations/.pi-map.index.md +++ b/frontend/src/integrations/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/integrations ## role -Frontend integration layer that maps service types and widgets to their React components, configuration schemas, and navigation entries. +Frontend integration layer that maps service types to their navigation entries, React widget components, configuration schemas, and metadata for dashboard rendering. ## parent index: frontend/src/.pi-map.index.md map: frontend/src/.pi-map.md diff --git a/frontend/src/integrations/.pi-map.md b/frontend/src/integrations/.pi-map.md index 8a52ae1..ec447bf 100644 --- a/frontend/src/integrations/.pi-map.md +++ b/frontend/src/integrations/.pi-map.md @@ -4,15 +4,15 @@ dir: frontend/src/integrations index: frontend/src/integrations/.pi-map.index.md ## role -Frontend integration layer that maps service types and widgets to their React components, configuration schemas, and navigation entries. +Frontend integration layer that maps service types to their navigation entries, React widget components, configuration schemas, and metadata for dashboard rendering. ## files - navEntries.ts | Defines a static mapping of service types to navigation entries and provides a filter function to return only entries for currently configured services. | exp: NavEntry, SERVICE_TYPE_NAV_ENTRIES, func:configuredNavEntries(configuredTypes: Set) → NavEntry[], call:SERVICE_TYPE_NAV_ENTRIES.filter, call:configuredTypes.has | dep: lucide-react - registry.test.ts | Tests the service and widget registry, validating correct registration of service types, widget bindings, configuration schemas, and widget resolution behavior. | dep: vitest, ./registry, ../types -- registry.ts | Provides a frontend registry that maps service types and built-in widgets to their React components, config schemas, and metadata, with a resolver function for widget instances. | exp: WidgetComponentProps, ServiceWidgetBinding, ServiceBinding, SERVICE_REGISTRY, BUILTIN_WIDGETS, ResolvedWidget, func:getServiceBinding(serviceType: string) → ServiceBinding | undefined, func:getBuiltinBinding(kind: string) → ServiceWidgetBinding | undefined, func:resolveWidget(widget: WidgetInstance, services: ServiceInstance[]) → ResolvedWidget | undefined, call:services.find, call:getServiceBinding, call:binding?.widgets.find, call:getBuiltinBinding, func:enrichServiceTypes(types: ServiceTypeInfo[]) → ServiceTypeInfo[] | dep: react, ../widgets/AlertmanagerAlertsWidget, ../widgets/BackupsWidget, ../widgets/MetricChartWidget, ../widgets/MetricGaugeWidget, ../widgets/MetricMeanWidget, ../widgets/JellyfinWidget, ../widgets/JellyfinNowPlayingWidget, ../widgets/PrometheusMetricWidget, ../widgets/QbittorrentActiveTorrentsWidget, ../widgets/QbittorrentSpeedWidget, ../widgets/QbittorrentTotalsWidget, ../widgets/SshTaskWidget, ../widgets/StaticWidget, ../types, AlertmanagerAlertsWidget, BackupsWidget, MetricChartWidget, MetricGaugeWidget, MetricMeanWidget, JellyfinWidget, JellyfinNowPlayingWidget, PrometheusMetricWidget, QbittorrentActiveTorrentsWidget, QbittorrentSpeedWidget, QbittorrentTotalsWidget, SshTaskWidget, StaticWidget +- registry.ts | Provides a frontend registry mapping service types and built-in widgets to their React components, default configs, and metadata for dashboard rendering. | exp: WidgetComponentProps, ServiceWidgetBinding, ServiceBinding, SERVICE_REGISTRY, BUILTIN_WIDGETS, ResolvedWidget, func:getServiceBinding(serviceType: string) → ServiceBinding | undefined, func:getBuiltinBinding(kind: string) → ServiceWidgetBinding | undefined, func:resolveWidget(widget: WidgetInstance, services: ServiceInstance[]) → ResolvedWidget | undefined, call:services.find, call:getServiceBinding, call:binding?.widgets.find, call:getBuiltinBinding, func:enrichServiceTypes(types: ServiceTypeInfo[]) → ServiceTypeInfo[] | dep: react, ../widgets/AlertmanagerAlertsWidget, ../widgets/BackupsWidget, ../widgets/MetricChartWidget, ../widgets/MetricGaugeWidget, ../widgets/MetricMeanWidget, ../widgets/JellyfinWidget, ../widgets/JellyfinNowPlayingWidget, ../widgets/PrometheusMetricWidget, ../widgets/QbittorrentActiveTorrentsWidget, ../widgets/QbittorrentSpeedWidget, ../widgets/QbittorrentTotalsWidget, ../widgets/RequestStatWidget, ../widgets/RequestsOverviewWidget, ../widgets/SshTaskWidget, ../widgets/StaticWidget, ../types, various widget components, types ## arch -Registry pattern with static mappings and a resolver function, centralizing widget/component resolution and service configuration metadata for the UI. +Registry pattern using static maps and lookup functions to decouple service type definitions from their concrete UI component implementations, with filtering logic to surface only configured services. ## tags -service, widgets, widget, binding, nav, entries, types, registry +service, widgets, binding, widget, nav, types, entries, registry ## symbols - configuredNavEntries - getServiceBinding diff --git a/frontend/src/integrations/registry.ts b/frontend/src/integrations/registry.ts index 32ee3c2..50999c9 100644 --- a/frontend/src/integrations/registry.ts +++ b/frontend/src/integrations/registry.ts @@ -10,6 +10,8 @@ import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget"; import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget"; import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget"; import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget"; +import { RequestStatWidget } from "../widgets/RequestStatWidget"; +import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget"; import { SshTaskWidget } from "../widgets/SshTaskWidget"; import { StaticWidget } from "../widgets/StaticWidget"; import type { @@ -252,6 +254,41 @@ export const SERVICE_REGISTRY: Record = { configSchema: { type: "object", properties: {}, required: [] }, component: JellyfinNowPlayingWidget, }, + { + kind: "stat", + name: "Request stat", + description: "A single Jellyseerr request statistic (e.g. pending requests).", + refreshIntervalMs: 60_000, + defaultConfig: { stat: "pending" }, + configSchema: { + type: "object", + properties: { + stat: { + type: "string", + enum: [ + "total", + "pending", + "approved", + "declined", + "processing", + "available", + ], + description: "Which request count to display", + }, + }, + required: [], + }, + component: RequestStatWidget, + }, + { + kind: "stats_overview", + name: "Requests overview", + description: "All Jellyseerr request stats plus a recent-requests list.", + refreshIntervalMs: 60_000, + defaultConfig: {}, + configSchema: { type: "object", properties: {}, required: [] }, + component: RequestsOverviewWidget, + }, ], }, nextcloud: { diff --git a/frontend/src/pages/service-tabs/.pi-map.index.md b/frontend/src/pages/service-tabs/.pi-map.index.md index 7bf9e08..6d24abf 100644 --- a/frontend/src/pages/service-tabs/.pi-map.index.md +++ b/frontend/src/pages/service-tabs/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/pages/service-tabs ## role -Provides service-specific tabbed UI components rendered within service detail pages based on service type. +Provides service-specific tabbed UI content components rendered within service detail pages. ## parent index: frontend/src/pages/.pi-map.index.md map: frontend/src/pages/.pi-map.md diff --git a/frontend/src/pages/service-tabs/.pi-map.md b/frontend/src/pages/service-tabs/.pi-map.md index 538b529..c7092a0 100644 --- a/frontend/src/pages/service-tabs/.pi-map.md +++ b/frontend/src/pages/service-tabs/.pi-map.md @@ -4,21 +4,21 @@ dir: frontend/src/pages/service-tabs index: frontend/src/pages/service-tabs/.pi-map.index.md ## role -Provides service-specific tabbed UI components rendered within service detail pages based on service type. +Provides service-specific tabbed UI content components rendered within service detail pages. ## 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 summary and expandable alert list for a specific service instance. | 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 +- 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 - FilesTab.tsx | Displays a file browser interface for an SSH task instance, featuring directory navigation, media file previews via ffprobe, and job execution. | exp: func:FilesTab({ instance }: { instance: ServiceInstance }), call:useIsMobile, call:useSearchParams, call:searchParams.get, call:useState, call:usePersistentState, call:isVideoFile, call:path.includes, call:path.replace, call:selectedPath.replace, call:defaultFileBrowserState, call:setBrowserState, call:useDirectoryListing, call:useFfprobe, call:useJobTemplates, call:useRunJob, call:updateBrowserState, call:navigate, call:currentDir.replace, call:rows.push, call:entry.name.split(".").pop, call:formatSize, call:formatTime, call:updater, call:Object.keys(next).filter, call:rows.find, call:templates?.find, call:refetch, call:String, call:templates.map, call:runJob.mutate | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/mobile-card, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/select, ../../hooks/useFiles, ../../hooks/usePersistentState, ../../components/SectionCard, ../../types, ../../hooks/useIsMobile, @/components/ui/* -- JobsTab.tsx | Displays a tabbed UI for backup operations showing Jobs, Runs, and Alerts tables with global (non-instance-scoped) backup data. | exp: func:JobsTab({ instance }: { instance: ServiceInstance }), call:useState, call:useBackupJobs, call:useBackupRuns, call:useBackupAlerts, call:useAcknowledgeAlert, call:latestRuns.get, call:latestRuns.set, call:acknowledgeMutation.mutate | dep: react, @/components/ui/tabs, ../../hooks/useBackups, ../../components/BackupAlertsTable, ../../components/BackupJobsTable, ../../components/BackupRunsTable, ../../types, react/useState +- JobsTab.tsx | Renders a tabbed interface displaying instance-scoped backup jobs, runs, and alerts with acknowledgement capability. | exp: func:JobsTab({ instance }: { instance: ServiceInstance }), call:useState, call:useBackupJobs, call:useBackupRuns, call:useBackupAlerts, call:useAcknowledgeAlert, call:latestRuns.get, call:latestRuns.set, call:acknowledgeMutation.mutate | dep: react, @/components/ui/tabs, ../../hooks/useBackups, ../../components/BackupAlertsTable, ../../components/BackupJobsTable, ../../components/BackupRunsTable, ../../types, useBackups hooks, BackupAlertsTable, BackupJobsTable, BackupRunsTable, types - MediaTab.tsx | Provides the media management interface for a Jellyfin service instance, including index building controls, filtering, and a paginated data table of media items. | exp: func:MediaTab({ instance }: { instance: ServiceInstance }), call:useNavigate, call:useServiceInstances, call:usePrefersSmallScreen, call:useIsMobile, call:useCounts, call:useLibraries, call:useMediaStatus, call:useBuildIndex, call:useStopBuildIndex, call:useForceStopBuildIndex, call:usePersistentState, call:defaultMediaTabState, call:setMediaState, call:useState, call:useMediaDataQuery, call:Math.floor, call:updater, call:useMemo, call:sshServices.find, call:navigate, call:encodeURIComponent, call:Math.max, call:Math.ceil, call:formatDuration, call:status.item_count.toLocaleString, call:counts.movies.toLocaleString, call:counts.series.toLocaleString, call:counts.episodes.toLocaleString, call:(libraries?.length ?? 0).toLocaleString, call:buildIndex.mutate, call:stopBuildIndex.mutate, call:forceStopBuildIndex.mutate, call:Math.round, call:status?.build_items_processed?.toLocaleString, call:status?.build_items_total?.toLocaleString, call:status?.build_library_items_processed?.toLocaleString, call:status?.build_library_items_total?.toLocaleString, call:updateMediaState, call:total.toLocaleString | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/mobile-card, @/components/ui/table-pagination, @/components/ui/alert, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/progress, @/components/ui/select, ../../hooks/useMedia, ../../hooks/usePersistentState, ../../hooks/useIsMobile, ../../types, ../../hooks/useDashboard, ../../hooks/useServices, @/components/ui/* (data-table, mobile-card, table-pagination, alert, button, card, input, label, progress, select), useMedia, usePersistentState, useIsMobile, useDashboard, useServices - 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 | Displays Prometheus status and Node Exporter scrape targets in an instance-scoped metrics tab. | 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 components (Card, Badge, Alert, Skeleton), 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 a Jellyseerr request-management tab within a Jellyfin page, showing either a configuration CTA or a placeholder view depending on whether Jellyseerr config fields are present. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record).jellyseerr_url ?? "", ).trim, call:String( (instance.config as Record).jellyseerr_api_key ?? "", ).trim | dep: ../../types, @/components/ui/alert, lucide-react, ServiceInstance type, Alert/AlertDescription UI components, lucide-react ExternalLink icon +- RequestsTab.tsx | Displays Jellyseerr request statistics and recent requests for a Jellyfin service instance. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record).jellyseerr_url ?? "", ).trim, call:Boolean, call:useJellyseerrStats, call:(data?.stats ?? []).map, call:data.recent.slice(0, 12).map | dep: ../../types, ../../hooks/useJellyseer, @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../../components/MetricCard, lucide-react, ServiceInstance, useJellyseerrStats, Alert, Badge, Skeleton, MetricCard - 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 -Registry pattern via index.ts mapping service types to React tab components; each tab is a self-contained functional component scoped to a specific service instance. +Component-per-tab pattern with a central registry (index.ts) mapping service types to their respective tab components. ## tags call:use, components, ui, tab, state, call:set, locale, string ## symbols diff --git a/frontend/src/pages/service-tabs/RequestsTab.tsx b/frontend/src/pages/service-tabs/RequestsTab.tsx index 1228650..8aee838 100644 --- a/frontend/src/pages/service-tabs/RequestsTab.tsx +++ b/frontend/src/pages/service-tabs/RequestsTab.tsx @@ -1,38 +1,42 @@ /** - * RequestsTab — Jellyseerr request-management surface on the Jellyfin page. + * RequestsTab — Jellyseerr request stats surface on the Jellyfin page. * - * Jellyseerr was absorbed into Jellyfin config (jellyseerr_url + - * jellyseerr_api_key) in Slice 1. This tab reads those config fields. When - * configured, it shows the URL and a placeholder (no requests backend endpoint - * exists yet — building one is out of scope for this slice). When not - * configured, it shows an empty-state CTA directing the user to add the fields - * to the Jellyfin config. + * 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. */ 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 { ExternalLink } from "lucide-react"; export function RequestsTab({ instance }: { instance: ServiceInstance }) { const jellyseerrUrl = String( (instance.config as Record).jellyseerr_url ?? "", ).trim(); - const jellyseerrApiKey = String( - (instance.config as Record).jellyseerr_api_key ?? "", - ).trim(); + const jellyseerrApiKeySet = Boolean( + (instance.secrets_set as Record | undefined) + ?.jellyseerr_api_key, + ); + const { data, isLoading, error } = useJellyseerrStats(instance.id); - if (!jellyseerrUrl || !jellyseerrApiKey) { + if (!jellyseerrUrl || !jellyseerrApiKeySet) { return ( - Jellyseerr is not configured for this Jellyfin instance. Add + Jellyseerr is not configured for this Jellyfin instance. Add a jellyseerr_url - and + config field and a jellyseerr_api_key - to the Jellyfin config (Config tab) to enable request management. + secret to the Jellyfin service to enable request management. ); @@ -52,13 +56,58 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) { - - - Jellyseerr is configured. The requests view will show pending and - recently fulfilled media requests. (This surface is under - development.) - - + + {error ? ( + + {error.message} + + ) : isLoading ? ( + + ) : data?.detail && !(data.stats && data.stats.length > 0) ? ( + + {data.detail} + + ) : ( + <> +
+ {(data?.stats ?? []).map((s) => ( + + ))} +
+ + {data?.recent && data.recent.length > 0 ? ( +
+ + Recent requests + + {data.recent.slice(0, 12).map((r, i) => ( +
+
+ {r.name ?? "—"} + + {r.type ? String(r.type) : ""} + +
+
+ {r.media_status ? ( + {r.media_status} + ) : null} + {r.status ? {r.status} : null} +
+
+ ))} +
+ ) : null} + +

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

+ + )} ); } diff --git a/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx index 6e253be..30b0ff6 100644 --- a/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx +++ b/frontend/src/pages/service-tabs/__tests__/RequestsTab.test.tsx @@ -1,15 +1,27 @@ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; import { render, screen } from "@testing-library/react"; import { RequestsTab } from "../RequestsTab"; +import { useJellyseerrStats } from "../../../hooks/useJellyseer"; import type { ServiceInstance } from "../../../types"; -function makeInstance(config: Record): ServiceInstance { +// Mock the stats hook so the tab renders without a QueryClientProvider and we +// can drive the rendered state directly. +vi.mock("../../../hooks/useJellyseer", () => ({ + useJellyseerrStats: vi.fn(), +})); +const mockUseJellyseerrStats = vi.mocked(useJellyseerrStats); + + +function makeInstance( + config: Record, + secrets_set: Record = {}, +): ServiceInstance { return { id: "jellyfin-1", service_type: "jellyfin", name: "Main Jellyfin", config, - secrets_set: {}, + secrets_set, enabled: true, created_at: 1_700_000_000, updated_at: 1_700_000_000, @@ -18,6 +30,7 @@ function makeInstance(config: Record): ServiceInstance { describe("RequestsTab", () => { it("shows empty-state CTA when Jellyseerr is not configured", () => { + mockUseJellyseerrStats.mockReturnValue({ data: undefined, isLoading: false, error: null }); render( { expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument(); }); - it("shows the configured Jellyseerr URL when both fields are set", () => { + it("shows empty-state when only URL is set (api key secret missing)", () => { + mockUseJellyseerrStats.mockReturnValue({ data: undefined, isLoading: false, error: null }); render( , - ); - expect( - screen.getByText("https://requests.example.com"), - ).toBeInTheDocument(); - expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument(); - }); - - it("shows empty-state when only URL is set (missing api_key)", () => { - render( - , ); expect(screen.getByText(/not configured/i)).toBeInTheDocument(); }); + + it("shows the configured Jellyseerr URL and the stats grid", () => { + mockUseJellyseerrStats.mockReturnValue({ + data: { + stats: [ + { key: "pending", label: "Pending", value: 3 }, + { key: "total", label: "Total", value: 42 }, + ], + recent: [ + { id: 1, name: "Inception", type: "movie", status: "pending", media_status: "available" }, + ], + }, + isLoading: false, + error: null, + }); + render( + , + ); + 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", () => { + mockUseJellyseerrStats.mockReturnValue({ + data: undefined, + isLoading: false, + error: new Error("boom"), + }); + render( + , + ); + expect(screen.getByText(/boom/i)).toBeInTheDocument(); + }); }); diff --git a/frontend/src/widgets/.pi-map.index.md b/frontend/src/widgets/.pi-map.index.md index 407ace8..e2f3041 100644 --- a/frontend/src/widgets/.pi-map.index.md +++ b/frontend/src/widgets/.pi-map.index.md @@ -2,7 +2,7 @@ dir: frontend/src/widgets ## role -Collection of self-contained dashboard widget components that fetch and render monitoring, media, backup, and torrent data in various visual formats. +Provides self-contained, reusable dashboard widget components for monitoring and visualizing data from various services (Alertmanager, Jellyfin, qBittorrent, Prometheus, Jellyseerr, backups, SSH tasks, etc.). ## parent index: frontend/src/.pi-map.index.md map: frontend/src/.pi-map.md @@ -22,6 +22,8 @@ map: frontend/src/.pi-map.md - QbittorrentActiveTorrentsWidget.tsx - QbittorrentSpeedWidget.tsx - QbittorrentTotalsWidget.tsx +- RequestStatWidget.tsx +- RequestsOverviewWidget.tsx - SshTaskWidget.tsx - StaticWidget.tsx - index.ts diff --git a/frontend/src/widgets/.pi-map.md b/frontend/src/widgets/.pi-map.md index d94c7f9..902f720 100644 --- a/frontend/src/widgets/.pi-map.md +++ b/frontend/src/widgets/.pi-map.md @@ -4,26 +4,28 @@ dir: frontend/src/widgets index: frontend/src/widgets/.pi-map.index.md ## role -Collection of self-contained dashboard widget components that fetch and render monitoring, media, backup, and torrent data in various visual formats. +Provides self-contained, reusable dashboard widget components for monitoring and visualizing data from various services (Alertmanager, Jellyfin, qBittorrent, Prometheus, Jellyseerr, backups, SSH tasks, etc.). ## files - AlertmanagerAlertsWidget.tsx | Renders an Alertmanager alerts dashboard widget displaying alert summaries, severity badges, and individual alert details. | exp: func:AlertmanagerAlertsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Object.entries(summary.by_severity).map, call:severityVariant, call:alerts.slice(0, 5).map | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types - BackupsWidget.tsx | Displays a dashboard widget showing backup job metrics including total jobs, 24-hour success rate, active alerts, and last failure timestamp. | exp: func:BackupsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:new Date(summary.last_failed_at * 1000).toLocaleString | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types/backups, ../types - JellyfinNowPlayingWidget.tsx | Displays a Jellyfin now-playing widget that fetches and renders active media sessions using a custom data hook and session activity panel. | exp: func:JellyfinNowPlayingWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Array.isArray | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SessionActivityPanel, ../components/SectionCard, ../hooks/useWidgets, ../types, SessionActivityPanel, SectionCard, useWidgetData, types - JellyfinWidget.tsx | Displays Jellyfin media server activity sessions in a widget with loading, error, and empty states. | exp: func:JellyfinWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Array.isArray | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SessionActivityPanel, ../components/SectionCard, ../hooks/useWidgets, ../types -- MetricChartWidget.tsx | Renders a metric chart widget that displays time-series data in a line chart with loading, error, and empty states. | exp: func:MetricChartWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../lib/metricFormat, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgetData, metricFormat +- MetricChartWidget.tsx | Renders a metric chart widget that fetches time-series data via polling and displays it as a line chart with loading, error, and empty states. | exp: func:MetricChartWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../lib/metricFormat, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgetData hook, metricFormat types - MetricGaugeWidget.tsx | Renders a metric gauge widget using a radial bar chart with threshold-based color bands for visualizing a single numeric value. | exp: func:MetricGaugeWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Math.max, call:Math.round, call:Math.min, call:toPercent, call:formatValue | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, recharts, SectionCard, useWidgetData, WidgetInstance types - MetricMeanWidget.tsx | Displays a metric mean value from Prometheus/PromQL query results with conditional loading, error, and empty states. | exp: func:MetricMeanWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:formatMean | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance - PrometheusMetricWidget.tsx | Displays a Prometheus metric widget that fetches and formats time-series data with loading and error states. | exp: func:PrometheusMetricWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:formatMetricResult | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance - QbittorrentActiveTorrentsWidget.tsx | Displays a list of active torrents from a qBittorrent instance with download/upload speeds and status badges in a dashboard widget. | exp: func:QbittorrentActiveTorrentsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:torrents.map, call:formatSpeed | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData hook, WidgetInstance type -- QbittorrentSpeedWidget.tsx | Displays qBittorrent download/upload speed data as a line series chart with loading, error, and empty states. | exp: func:QbittorrentSpeedWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../lib/metricFormat, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgets, metricFormat types, WidgetInstance types +- QbittorrentSpeedWidget.tsx | Displays qBittorrent download/upload speed data as a line series chart widget with loading, error, and empty states. | exp: func:QbittorrentSpeedWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/LineSeriesChart, ../lib/metricFormat, ../components/SectionCard, ../hooks/useWidgets, ../types, LineSeriesChart, SectionCard, useWidgetData hook, metricFormat types - QbittorrentTotalsWidget.tsx | Displays qBittorrent torrent totals and per-state breakdown using a widget with loading, error, and data states. | exp: func:QbittorrentTotalsWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:Object.keys, call:Object.entries(payload.by_state).map | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types +- RequestStatWidget.tsx | Displays a single Jellyseerr request statistic (e.g., pending or total requests) within a card with loading, error, and empty states. | exp: func:RequestStatWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance +- RequestsOverviewWidget.tsx | Displays Jellyseerr request statistics in a metric grid along with a list of recent requests. | exp: func:RequestsOverviewWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData, call:overview.stats.map, call:String, call:overview.recent.slice(0, 8).map | dep: @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../components/MetricCard, ../components/SectionCard, ../hooks/useWidgets, ../types, ../api/jellyseerr, MetricCard, SectionCard, useWidgetData, WidgetInstance, jellyseerr API types - SshTaskWidget.tsx | Displays SSH task execution results with exit status, stdout, and stderr in a polling widget card | exp: func:SshTaskWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: @/components/ui/alert, @/components/ui/skeleton, ../components/SectionCard, ../hooks/useWidgets, ../types - StaticWidget.tsx | Renders a static text widget that displays fetched text content or a fallback message within a section card. | exp: func:StaticWidget({ widget, refreshIntervalMs, description, }: Props), call:useWidgetData | dep: ../components/SectionCard, ../hooks/useWidgets, ../types, SectionCard, useWidgetData, WidgetInstance -- index.ts | Barrel file that re-exports all widget components from a dashboard/widget module. | dep: AlertmanagerAlertsWidget, BackupsWidget, MetricChartWidget, MetricGaugeWidget, MetricMeanWidget, JellyfinWidget, JellyfinNowPlayingWidget, PrometheusMetricWidget, QbittorrentActiveTorrentsWidget, QbittorrentSpeedWidget, QbittorrentTotalsWidget, SshTaskWidget, StaticWidget +- index.ts | Barrel file that re-exports various dashboard widget components. | dep: AlertmanagerAlertsWidget, BackupsWidget, MetricChartWidget, MetricGaugeWidget, MetricMeanWidget, JellyfinWidget, JellyfinNowPlayingWidget, PrometheusMetricWidget, QbittorrentActiveTorrentsWidget, QbittorrentSpeedWidget, QbittorrentTotalsWidget, RequestStatWidget, RequestsOverviewWidget, SshTaskWidget, StaticWidget ## arch -Composition of individually encapsulated widget components using a consistent pattern of data-fetching hooks with standardized loading, error, empty, and data states, exported via a barrel file. +Feature-organized React functional components using custom data-fetching hooks, each implementing independent loading/error/empty states; centralized exports via a barrel file for modular widget composition. ## tags -widget, components, data, ui, call:use, metric, sectioncard, types +widget, components, data, ui, call:use, sectioncard, metric, types ## symbols - AlertmanagerAlertsWidget - BackupsWidget diff --git a/frontend/src/widgets/RequestStatWidget.tsx b/frontend/src/widgets/RequestStatWidget.tsx new file mode 100644 index 0000000..0f4af20 --- /dev/null +++ b/frontend/src/widgets/RequestStatWidget.tsx @@ -0,0 +1,50 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Skeleton } from "@/components/ui/skeleton"; +import { SectionCard } from "../components/SectionCard"; +import { useWidgetData } from "../hooks/useWidgets"; +import type { WidgetInstance } from "../types"; + +interface Props { + widget: WidgetInstance; + refreshIntervalMs: number; + description?: string; +} + +interface StatData { + key?: string; + label?: string; + value?: number; +} + +/** Single Jellyseerr request statistic (e.g. pending / total). */ +export function RequestStatWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { + const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); + const stat = data?.data as StatData | undefined; + + return ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : stat ? ( +
+ {stat.value ?? "—"} + + {stat.label ?? "Requests"} + +
+ ) : ( + + No request data. + + )} +
+ ); +} diff --git a/frontend/src/widgets/RequestsOverviewWidget.tsx b/frontend/src/widgets/RequestsOverviewWidget.tsx new file mode 100644 index 0000000..9362062 --- /dev/null +++ b/frontend/src/widgets/RequestsOverviewWidget.tsx @@ -0,0 +1,74 @@ +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 { SectionCard } from "../components/SectionCard"; +import { useWidgetData } from "../hooks/useWidgets"; +import type { WidgetInstance } from "../types"; +import type { JellyseerRecentRequest, JellyseerStat } from "../api/jellyseerr"; + +interface Props { + widget: WidgetInstance; + refreshIntervalMs: number; + description?: string; +} + +interface OverviewData { + stats?: JellyseerStat[]; + recent?: JellyseerRecentRequest[]; +} + +/** All Jellyseerr request stats as a grid, plus a recent-requests list. */ +export function RequestsOverviewWidget({ + widget, + refreshIntervalMs, + description, +}: Props) { + const { data, isLoading } = useWidgetData(widget.id, refreshIntervalMs); + const overview = data?.data as OverviewData | undefined; + + return ( + + {isLoading && !data ? ( + + ) : data?.error ? ( + + {data.error} + + ) : overview?.stats && overview.stats.length > 0 ? ( +
+
+ {overview.stats.map((s) => ( + + ))} +
+ {overview.recent && overview.recent.length > 0 ? ( +
+ + Recent + + {overview.recent.slice(0, 8).map((r, i) => ( +
+ {r.name ?? "—"} +
+ {r.media_status ? ( + {r.media_status} + ) : null} + {r.status ? {r.status} : null} +
+
+ ))} +
+ ) : null} +
+ ) : ( + + No request data. + + )} +
+ ); +} diff --git a/frontend/src/widgets/__tests__/RequestWidgets.test.tsx b/frontend/src/widgets/__tests__/RequestWidgets.test.tsx new file mode 100644 index 0000000..be44e7d --- /dev/null +++ b/frontend/src/widgets/__tests__/RequestWidgets.test.tsx @@ -0,0 +1,65 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { RequestStatWidget } from "../RequestStatWidget"; +import { RequestsOverviewWidget } from "../RequestsOverviewWidget"; +import type { WidgetInstance } from "../../types"; +import * as useWidgets from "../../hooks/useWidgets"; + +vi.mock("../../hooks/useWidgets", () => ({ useWidgetData: vi.fn() })); + +const statWidget: WidgetInstance = { + id: "w1", + service_id: "s1", + widget_kind: "stat", + title: "My stat widget", + config: { stat: "pending" }, + enabled: true, + sort_order: 0, + created_at: 0, + updated_at: 0, +}; + +function mockData(data: unknown, error?: string) { + vi.mocked(useWidgets.useWidgetData).mockReturnValue({ + data: error + ? { widget_id: "w1", error, fetched_at: 0 } + : { widget_id: "w1", data, fetched_at: 0 }, + isLoading: false, + } as unknown as ReturnType); +} + +describe("RequestStatWidget", () => { + it("renders the selected stat value + label", () => { + mockData({ key: "pending", label: "Pending", value: 3 }); + render(); + expect(screen.getByText("3")).toBeInTheDocument(); + expect(screen.getByText("Pending")).toBeInTheDocument(); + }); + + it("surfaces an error from the source", () => { + mockData(null, "Jellyseerr is not configured"); + render(); + expect(screen.getByText(/not configured/i)).toBeInTheDocument(); + }); +}); + +describe("RequestsOverviewWidget", () => { + it("renders the stats grid + a recent request", () => { + mockData({ + stats: [ + { key: "pending", label: "Pending", value: 2 }, + { key: "total", label: "Total", value: 5 }, + ], + recent: [{ id: 1, name: "Inception", status: "pending" }], + }); + const overviewWidget: WidgetInstance = { + ...statWidget, + widget_kind: "stats_overview", + title: "Overview", + }; + render(); + expect(screen.getByText("Pending")).toBeInTheDocument(); + expect(screen.getByText("Total")).toBeInTheDocument(); + expect(screen.getByText("Inception")).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/widgets/index.ts b/frontend/src/widgets/index.ts index 22d0ab3..557f101 100644 --- a/frontend/src/widgets/index.ts +++ b/frontend/src/widgets/index.ts @@ -9,5 +9,7 @@ export { PrometheusMetricWidget } from "./PrometheusMetricWidget"; export { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget"; export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget"; export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget"; +export { RequestStatWidget } from "./RequestStatWidget"; +export { RequestsOverviewWidget } from "./RequestsOverviewWidget"; export { SshTaskWidget } from "./SshTaskWidget"; export { StaticWidget } from "./StaticWidget";