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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 “Request stat”
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user