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 dir: frontend/src/api
## role ## 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 ## parent
index: frontend/src/.pi-map.index.md index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.md map: frontend/src/.pi-map.md
@@ -13,6 +13,7 @@ map: frontend/src/.pi-map.md
- backups.ts - backups.ts
- client.ts - client.ts
- dashboards.ts - dashboards.ts
- jellyseerr.ts
- services.ts - services.ts
- shared.ts - shared.ts
- widgets.ts - widgets.ts
+7 -6
View File
@@ -4,19 +4,20 @@ dir: frontend/src/api
index: frontend/src/api/.pi-map.index.md index: frontend/src/api/.pi-map.index.md
## role ## 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 ## 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 - 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 - 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 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 - 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 - 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 - 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 - 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 ## 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 ## tags
fetch, call:get, widget, dashboard, call:build, authentik, delete, backup fetch, call:get, widget, dashboard, call:build, authentik, delete, call:post
## symbols ## symbols
- fetchAuthentikUsers - fetchAuthentikUsers
- sendAuthentikMessage - 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 dir: frontend/src/integrations
## role ## 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 ## parent
index: frontend/src/.pi-map.index.md index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.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 index: frontend/src/integrations/.pi-map.index.md
## role ## 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 ## 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 - 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.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 ## 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 ## tags
service, widgets, widget, binding, nav, entries, types, registry service, widgets, binding, widget, nav, types, entries, registry
## symbols ## symbols
- configuredNavEntries - configuredNavEntries
- getServiceBinding - getServiceBinding
+37
View File
@@ -10,6 +10,8 @@ import { PrometheusMetricWidget } from "../widgets/PrometheusMetricWidget";
import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget"; import { QbittorrentActiveTorrentsWidget } from "../widgets/QbittorrentActiveTorrentsWidget";
import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget"; import { QbittorrentSpeedWidget } from "../widgets/QbittorrentSpeedWidget";
import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget"; import { QbittorrentTotalsWidget } from "../widgets/QbittorrentTotalsWidget";
import { RequestStatWidget } from "../widgets/RequestStatWidget";
import { RequestsOverviewWidget } from "../widgets/RequestsOverviewWidget";
import { SshTaskWidget } from "../widgets/SshTaskWidget"; import { SshTaskWidget } from "../widgets/SshTaskWidget";
import { StaticWidget } from "../widgets/StaticWidget"; import { StaticWidget } from "../widgets/StaticWidget";
import type { import type {
@@ -252,6 +254,41 @@ export const SERVICE_REGISTRY: Record<string, ServiceBinding> = {
configSchema: { type: "object", properties: {}, required: [] }, configSchema: { type: "object", properties: {}, required: [] },
component: JellyfinNowPlayingWidget, 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: { nextcloud: {
@@ -2,7 +2,7 @@
dir: frontend/src/pages/service-tabs dir: frontend/src/pages/service-tabs
## role ## 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 ## parent
index: frontend/src/pages/.pi-map.index.md index: frontend/src/pages/.pi-map.index.md
map: frontend/src/pages/.pi-map.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 index: frontend/src/pages/service-tabs/.pi-map.index.md
## role ## 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 ## 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 - 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/* - 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 - 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 - 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 - 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 - 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 - 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 ## 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 ## tags
call:use, components, ui, tab, state, call:set, locale, string call:use, components, ui, tab, state, call:set, locale, string
## symbols ## 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 + * Reads the Jellyfin service's jellyseerr_url (config) + jellyseerr_api_key
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When * (secret). When configured, polls /api/jellyseerr/stats and renders the
* configured, it shows the URL and a placeholder (no requests backend endpoint * request-count grid + a recent-requests list. Individual stats can be pinned
* exists yet — building one is out of scope for this slice). When not * to dashboards via the "Request stat" widget.
* configured, it shows an empty-state CTA directing the user to add the fields
* to the Jellyfin config.
*/ */
import type { ServiceInstance } from "../../types"; import type { ServiceInstance } from "../../types";
import { useJellyseerrStats } from "../../hooks/useJellyseer";
import { Alert, AlertDescription } from "@/components/ui/alert"; 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"; import { ExternalLink } from "lucide-react";
export function RequestsTab({ instance }: { instance: ServiceInstance }) { export function RequestsTab({ instance }: { instance: ServiceInstance }) {
const jellyseerrUrl = String( const jellyseerrUrl = String(
(instance.config as Record<string, unknown>).jellyseerr_url ?? "", (instance.config as Record<string, unknown>).jellyseerr_url ?? "",
).trim(); ).trim();
const jellyseerrApiKey = String( const jellyseerrApiKeySet = Boolean(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "", (instance.secrets_set as Record<string, boolean> | undefined)
).trim(); ?.jellyseerr_api_key,
);
const { data, isLoading, error } = useJellyseerrStats(instance.id);
if (!jellyseerrUrl || !jellyseerrApiKey) { if (!jellyseerrUrl || !jellyseerrApiKeySet) {
return ( return (
<Alert> <Alert>
<AlertDescription> <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"> <code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url jellyseerr_url
</code> </code>
and config field and a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs"> <code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key jellyseerr_api_key
</code> </code>
to the Jellyfin config (Config tab) to enable request management. secret to the Jellyfin service to enable request management.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
); );
@@ -52,13 +56,58 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) {
<ExternalLink className="size-3.5" /> <ExternalLink className="size-3.5" />
</a> </a>
</div> </div>
<Alert>
<AlertDescription> {error ? (
Jellyseerr is configured. The requests view will show pending and <Alert variant="destructive">
recently fulfilled media requests. (This surface is under <AlertDescription>{error.message}</AlertDescription>
development.) </Alert>
</AlertDescription> ) : isLoading ? (
</Alert> <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> </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 { render, screen } from "@testing-library/react";
import { RequestsTab } from "../RequestsTab"; import { RequestsTab } from "../RequestsTab";
import { useJellyseerrStats } from "../../../hooks/useJellyseer";
import type { ServiceInstance } from "../../../types"; 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 { return {
id: "jellyfin-1", id: "jellyfin-1",
service_type: "jellyfin", service_type: "jellyfin",
name: "Main Jellyfin", name: "Main Jellyfin",
config, config,
secrets_set: {}, secrets_set,
enabled: true, enabled: true,
created_at: 1_700_000_000, created_at: 1_700_000_000,
updated_at: 1_700_000_000, updated_at: 1_700_000_000,
@@ -18,6 +30,7 @@ function makeInstance(config: Record<string, unknown>): ServiceInstance {
describe("RequestsTab", () => { describe("RequestsTab", () => {
it("shows empty-state CTA when Jellyseerr is not configured", () => { it("shows empty-state CTA when Jellyseerr is not configured", () => {
mockUseJellyseerrStats.mockReturnValue({ data: undefined, isLoading: false, error: null });
render( render(
<RequestsTab <RequestsTab
instance={makeInstance({ instance={makeInstance({
@@ -30,30 +43,59 @@ describe("RequestsTab", () => {
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument(); 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( render(
<RequestsTab <RequestsTab
instance={makeInstance({ instance={makeInstance({ jellyseerr_url: "https://requests.example.com" })}
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",
})}
/>, />,
); );
expect(screen.getByText(/not configured/i)).toBeInTheDocument(); 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 dir: frontend/src/widgets
## role ## 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 ## parent
index: frontend/src/.pi-map.index.md index: frontend/src/.pi-map.index.md
map: frontend/src/.pi-map.md map: frontend/src/.pi-map.md
@@ -22,6 +22,8 @@ map: frontend/src/.pi-map.md
- QbittorrentActiveTorrentsWidget.tsx - QbittorrentActiveTorrentsWidget.tsx
- QbittorrentSpeedWidget.tsx - QbittorrentSpeedWidget.tsx
- QbittorrentTotalsWidget.tsx - QbittorrentTotalsWidget.tsx
- RequestStatWidget.tsx
- RequestsOverviewWidget.tsx
- SshTaskWidget.tsx - SshTaskWidget.tsx
- StaticWidget.tsx - StaticWidget.tsx
- index.ts - index.ts
+8 -6
View File
@@ -4,26 +4,28 @@ dir: frontend/src/widgets
index: frontend/src/widgets/.pi-map.index.md index: frontend/src/widgets/.pi-map.index.md
## role ## 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 ## 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 - 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 ## 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 ## tags
widget, components, data, ui, call:use, metric, sectioncard, types widget, components, data, ui, call:use, sectioncard, metric, types
## symbols ## symbols
- AlertmanagerAlertsWidget - AlertmanagerAlertsWidget
- BackupsWidget - 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 { QbittorrentActiveTorrentsWidget } from "./QbittorrentActiveTorrentsWidget";
export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget"; export { QbittorrentSpeedWidget } from "./QbittorrentSpeedWidget";
export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget"; export { QbittorrentTotalsWidget } from "./QbittorrentTotalsWidget";
export { RequestStatWidget } from "./RequestStatWidget";
export { RequestsOverviewWidget } from "./RequestsOverviewWidget";
export { SshTaskWidget } from "./SshTaskWidget"; export { SshTaskWidget } from "./SshTaskWidget";
export { StaticWidget } from "./StaticWidget"; export { StaticWidget } from "./StaticWidget";