feat(jellyseer): stat widgets + rich Requests tab (slice 3/3)

Frontend for Jellyseerr request stats, reusing the generic stat abstraction.

- api/jellyseerr.ts + hooks/useJellyseer.ts: fetchJellyseerrStats +
  useJellyseerrStats (polls /api/jellyseerr/stats, no-retry; shares the backend
  cache with the widgets).
- Two reusable widgets backed by the stat/stats_overview kinds:
  - RequestStatWidget: a single selected stat (big value + label).
  - RequestsOverviewWidget: a MetricCard grid of all stats + a recent-requests
    list with status/media-status badges.
- registry.ts: Jellyfin gains `stat` (a dropdown over
  total/pending/approved/declined/processing/available — the "extract one stat
  into a widget" affordance, rendered as a Select via the existing enum UI) and
  `stats_overview` widget kinds, wired to the new components.
- RequestsTab rewritten: live stats grid (6 counts) + recent-requests list +
  a hint to pin individual stats via the Request stat widget. Reads
  jellyseerr_url from config and the now-secret jellyseerr_api_key from
  secrets_set.

Tests: RequestStatWidget + RequestsOverviewWidget rendering/error; RequestsTab
not-configured CTA, configured stats grid, and error states. 184/184 frontend
tests pass; tsc + ESLint clean.
This commit is contained in:
Developer
2026-07-12 13:59:08 +00:00
parent e25240c2f3
commit 0b039529f6
17 changed files with 444 additions and 70 deletions
+2 -1
View File
@@ -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
+7 -6
View File
@@ -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<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() → Promise<BackupJob[]>, call:get, func:fetchBackupJob(jobId: string) → Promise<{ job: BackupJob; runs: BackupRun[] }>, call:get, func:fetchBackupRuns(jobId: string, status: string) → Promise<BackupRun[]>, call:get, func:fetchBackupRun(runId: string) → Promise<BackupRun>, call:get, func:fetchBackupAlerts(jobId: string, acknowledged: boolean, severity: 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 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<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
- services.ts | Provides API service functions for CRUD operations on service instances and fetching service types. | 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 | 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<JellyseerrStatsResponse>, call:get | dep: ./shared, 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 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
+32
View File
@@ -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<JellyseerrStatsResponse> {
return get<JellyseerrStatsResponse>(
"/api/jellyseerr/stats",
jellyfinServiceId ? { jellyfin_service_id: jellyfinServiceId } : undefined,
);
}
+17
View File
@@ -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<JellyseerStatsResponse, Error, JellyseerStatsResponse>({
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,
});
}
+1 -1
View File
@@ -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
+4 -4
View File
@@ -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<string>) → 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
+37
View File
@@ -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<string, ServiceBinding> = {
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: {
@@ -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
+6 -6
View File
@@ -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<string, unknown>).jellyseerr_url ?? "", ).trim, call:String( (instance.config as Record<string, unknown>).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<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
- 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
+70 -21
View File
@@ -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<string, unknown>).jellyseerr_url ?? "",
).trim();
const jellyseerrApiKey = String(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
).trim();
const jellyseerrApiKeySet = Boolean(
(instance.secrets_set as Record<string, boolean> | undefined)
?.jellyseerr_api_key,
);
const { data, isLoading, error } = useJellyseerrStats(instance.id);
if (!jellyseerrUrl || !jellyseerrApiKey) {
if (!jellyseerrUrl || !jellyseerrApiKeySet) {
return (
<Alert>
<AlertDescription>
Jellyseerr is not configured for this Jellyfin instance. Add
Jellyseerr is not configured for this Jellyfin instance. Add a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url
</code>
and
config field and a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key
</code>
to the Jellyfin config (Config tab) to enable request management.
secret to the Jellyfin service to enable request management.
</AlertDescription>
</Alert>
);
@@ -52,13 +56,58 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) {
<ExternalLink className="size-3.5" />
</a>
</div>
<Alert>
<AlertDescription>
Jellyseerr is configured. The requests view will show pending and
recently fulfilled media requests. (This surface is under
development.)
</AlertDescription>
</Alert>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
) : isLoading ? (
<Skeleton className="h-32 w-full" />
) : data?.detail && !(data.stats && data.stats.length > 0) ? (
<Alert>
<AlertDescription>{data.detail}</AlertDescription>
</Alert>
) : (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{(data?.stats ?? []).map((s) => (
<MetricCard key={s.key} label={s.label} value={String(s.value)} />
))}
</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}
<p className="text-xs text-muted-foreground">
Pin individual stats to a dashboard with the &ldquo;Request stat&rdquo;
widget.
</p>
</>
)}
</div>
);
}
@@ -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<string, unknown>): 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<string, unknown>,
secrets_set: Record<string, boolean> = {},
): 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<string, unknown>): ServiceInstance {
describe("RequestsTab", () => {
it("shows empty-state CTA when Jellyseerr is not configured", () => {
mockUseJellyseerrStats.mockReturnValue({ data: undefined, isLoading: false, error: null });
render(
<RequestsTab
instance={makeInstance({
@@ -30,30 +43,59 @@ describe("RequestsTab", () => {
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(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
jellyseerr_url: "https://requests.example.com",
jellyseerr_api_key: "secret-key",
})}
/>,
);
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(
<RequestsTab
instance={makeInstance({
jellyseerr_url: "https://requests.example.com",
})}
instance={makeInstance({ jellyseerr_url: "https://requests.example.com" })}
/>,
);
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(
<RequestsTab
instance={makeInstance(
{ jellyseerr_url: "https://requests.example.com" },
{ jellyseerr_api_key: true },
)}
/>,
);
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(
<RequestsTab
instance={makeInstance(
{ jellyseerr_url: "https://requests.example.com" },
{ jellyseerr_api_key: true },
)}
/>,
);
expect(screen.getByText(/boom/i)).toBeInTheDocument();
});
});
+3 -1
View File
@@ -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
+8 -6
View File
@@ -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
@@ -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 (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-16 w-24" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : stat ? (
<div className="flex flex-col items-center justify-center py-4">
<span className="text-3xl font-bold">{stat.value ?? "—"}</span>
<span className="text-xs uppercase tracking-wide text-muted-foreground">
{stat.label ?? "Requests"}
</span>
</div>
) : (
<Alert>
<AlertDescription>No request data.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -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 (
<SectionCard title={widget.title} description={description}>
{isLoading && !data ? (
<Skeleton className="h-32 w-full" />
) : data?.error ? (
<Alert variant="destructive">
<AlertDescription>{data.error}</AlertDescription>
</Alert>
) : overview?.stats && overview.stats.length > 0 ? (
<div className="flex flex-col gap-3">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3">
{overview.stats.map((s) => (
<MetricCard key={s.key} label={s.label} value={String(s.value)} />
))}
</div>
{overview.recent && overview.recent.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent
</span>
{overview.recent.slice(0, 8).map((r, i) => (
<div
key={String(r.id ?? i)}
className="flex items-center justify-between gap-2 text-sm"
>
<span className="truncate">{r.name ?? "—"}</span>
<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>
) : (
<Alert>
<AlertDescription>No request data.</AlertDescription>
</Alert>
)}
</SectionCard>
);
}
@@ -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<typeof useWidgets.useWidgetData>);
}
describe("RequestStatWidget", () => {
it("renders the selected stat value + label", () => {
mockData({ key: "pending", label: "Pending", value: 3 });
render(<RequestStatWidget widget={statWidget} refreshIntervalMs={30_000} />);
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("Pending")).toBeInTheDocument();
});
it("surfaces an error from the source", () => {
mockData(null, "Jellyseerr is not configured");
render(<RequestStatWidget widget={statWidget} refreshIntervalMs={30_000} />);
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(<RequestsOverviewWidget widget={overviewWidget} refreshIntervalMs={30_000} />);
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByText("Total")).toBeInTheDocument();
expect(screen.getByText("Inception")).toBeInTheDocument();
});
});
+2
View File
@@ -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";