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,7 +2,7 @@
dir: frontend/src/pages/service-tabs
## role
Provides service-specific tabbed UI components rendered within service detail pages based on service type.
Provides service-specific tabbed UI content components rendered within service detail pages.
## parent
index: frontend/src/pages/.pi-map.index.md
map: frontend/src/pages/.pi-map.md
+6 -6
View File
@@ -4,21 +4,21 @@ dir: frontend/src/pages/service-tabs
index: frontend/src/pages/service-tabs/.pi-map.index.md
## role
Provides service-specific tabbed UI components rendered within service detail pages based on service type.
Provides service-specific tabbed UI content components rendered within service detail pages.
## files
- ActionsTab.tsx | Provides a UI tab for managing, editing, and running saved SSH tasks (shell or Python) within a service page. | exp: func:ActionsTab({ instance }: { instance: ServiceInstance }), call:useTasks, call:useSaveTask, call:useDeleteTask, call:useRunTask, call:useState, call:emptyTask, call:useMemo, call:tasks.find, call:useTaskRuns, call:setDraft, call:setDraftBaseline, call:setEditOpen, call:saveTask.mutateAsync, call:setTab, call:String, call:openEdit, call:tasks.map, call:initialFromTask, call:runTask.mutateAsync, call:selectedRuns.data.items.map, call:new Date(run.created_at * 1000).toLocaleString, call:deleteTask.mutate | dep: react, ../../types, ../../hooks/useSettings, ../../components/DialogFooter, ../../components/HoverEditButton, ../../components/SectionCard, ../../components/SelectionRailCard, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/dialog, @/components/ui/input, @/components/ui/label, @/components/ui/select, @/components/ui/separator, @/components/ui/tabs, @/components/ui/textarea
- AlertsTab.tsx | Renders an Alertmanager alerts summary and expandable alert list for a specific service instance. | exp: func:AlertsTab({ instance }: { instance: ServiceInstance }), call:useAlertmanagerAlerts, call:useAlertmanagerStatus, call:alertsSummary.alerts.map | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, @/components/ui/collapsible, ../../types, useObservability hooks
- AlertsTab.tsx | Renders an Alertmanager alerts tab showing alert summaries and an expandable list of active alerts scoped by instance ID. | exp: func:AlertsTab({ instance }: { instance: ServiceInstance }), call:useAlertmanagerAlerts, call:useAlertmanagerStatus, call:alertsSummary.alerts.map | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, @/components/ui/collapsible, ../../types, useObservability hooks, ui/card, ui/badge, ui/alert, ui/skeleton, ui/collapsible, types
- FilesTab.tsx | Displays a file browser interface for an SSH task instance, featuring directory navigation, media file previews via ffprobe, and job execution. | exp: func:FilesTab({ instance }: { instance: ServiceInstance }), call:useIsMobile, call:useSearchParams, call:searchParams.get, call:useState, call:usePersistentState, call:isVideoFile, call:path.includes, call:path.replace, call:selectedPath.replace, call:defaultFileBrowserState, call:setBrowserState, call:useDirectoryListing, call:useFfprobe, call:useJobTemplates, call:useRunJob, call:updateBrowserState, call:navigate, call:currentDir.replace, call:rows.push, call:entry.name.split(".").pop, call:formatSize, call:formatTime, call:updater, call:Object.keys(next).filter, call:rows.find, call:templates?.find, call:refetch, call:String, call:templates.map, call:runJob.mutate | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/mobile-card, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/select, ../../hooks/useFiles, ../../hooks/usePersistentState, ../../components/SectionCard, ../../types, ../../hooks/useIsMobile, @/components/ui/*
- JobsTab.tsx | Displays a tabbed UI for backup operations showing Jobs, Runs, and Alerts tables with global (non-instance-scoped) backup data. | exp: func:JobsTab({ instance }: { instance: ServiceInstance }), call:useState, call:useBackupJobs, call:useBackupRuns, call:useBackupAlerts, call:useAcknowledgeAlert, call:latestRuns.get, call:latestRuns.set, call:acknowledgeMutation.mutate | dep: react, @/components/ui/tabs, ../../hooks/useBackups, ../../components/BackupAlertsTable, ../../components/BackupJobsTable, ../../components/BackupRunsTable, ../../types, react/useState
- JobsTab.tsx | Renders a tabbed interface displaying instance-scoped backup jobs, runs, and alerts with acknowledgement capability. | exp: func:JobsTab({ instance }: { instance: ServiceInstance }), call:useState, call:useBackupJobs, call:useBackupRuns, call:useBackupAlerts, call:useAcknowledgeAlert, call:latestRuns.get, call:latestRuns.set, call:acknowledgeMutation.mutate | dep: react, @/components/ui/tabs, ../../hooks/useBackups, ../../components/BackupAlertsTable, ../../components/BackupJobsTable, ../../components/BackupRunsTable, ../../types, useBackups hooks, BackupAlertsTable, BackupJobsTable, BackupRunsTable, types
- MediaTab.tsx | Provides the media management interface for a Jellyfin service instance, including index building controls, filtering, and a paginated data table of media items. | exp: func:MediaTab({ instance }: { instance: ServiceInstance }), call:useNavigate, call:useServiceInstances, call:usePrefersSmallScreen, call:useIsMobile, call:useCounts, call:useLibraries, call:useMediaStatus, call:useBuildIndex, call:useStopBuildIndex, call:useForceStopBuildIndex, call:usePersistentState, call:defaultMediaTabState, call:setMediaState, call:useState, call:useMediaDataQuery, call:Math.floor, call:updater, call:useMemo, call:sshServices.find, call:navigate, call:encodeURIComponent, call:Math.max, call:Math.ceil, call:formatDuration, call:status.item_count.toLocaleString, call:counts.movies.toLocaleString, call:counts.series.toLocaleString, call:counts.episodes.toLocaleString, call:(libraries?.length ?? 0).toLocaleString, call:buildIndex.mutate, call:stopBuildIndex.mutate, call:forceStopBuildIndex.mutate, call:Math.round, call:status?.build_items_processed?.toLocaleString, call:status?.build_items_total?.toLocaleString, call:status?.build_library_items_processed?.toLocaleString, call:status?.build_library_items_total?.toLocaleString, call:updateMediaState, call:total.toLocaleString | dep: react, react-router-dom, @tanstack/react-table, @/components/ui/data-table, @/components/ui/mobile-card, @/components/ui/table-pagination, @/components/ui/alert, @/components/ui/button, @/components/ui/card, @/components/ui/input, @/components/ui/label, @/components/ui/progress, @/components/ui/select, ../../hooks/useMedia, ../../hooks/usePersistentState, ../../hooks/useIsMobile, ../../types, ../../hooks/useDashboard, ../../hooks/useServices, @/components/ui/* (data-table, mobile-card, table-pagination, alert, button, card, input, label, progress, select), useMedia, usePersistentState, useIsMobile, useDashboard, useServices
- MessagingTab.tsx | Provides a UI for composing and sending HTML email messages to Authentik users via a mail queue system. | exp: func:MessagingTab({ instance }: { instance: ServiceInstance }), call:useState, call:useAuthentikUsers, call:useSendAuthentikMessage, call:(data?.items ?? []).filter, call:setSelectedEmails, call:next.has, call:next.delete, call:next.add, call:subject.trim, call:sendMessage.mutate, call:Array.from, call:sendMessage.data.request_id?.slice, call:setSearch, call:users.slice(0, 20).map, call:selectedEmails.has, call:toggleEmail, call:setSubject, call:setHtmlBody | dep: react, @/components/ui/alert, @/components/ui/button, @/components/ui/input, @/components/ui/label, @/components/ui/textarea, ../../hooks/useAuthentik, ../../types
- MetricsTab.tsx | Displays Prometheus status and Node Exporter scrape targets in an instance-scoped metrics tab. | exp: func:MetricsTab({ instance }: { instance: ServiceInstance }), call:usePrometheusStatus, call:usePrometheusTargets | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, ../../types, useObservability hooks, UI components (Card, Badge, Alert, Skeleton), types
- MetricsTab.tsx | Renders a Prometheus metrics monitoring tab showing service health status and Node Exporter scrape targets for a given service instance. | exp: func:MetricsTab({ instance }: { instance: ServiceInstance }), call:usePrometheusStatus, call:usePrometheusTargets | dep: lucide-react, ../../hooks/useObservability, @/components/ui/card, @/components/ui/badge, @/components/ui/alert, @/components/ui/skeleton, ../../types, useObservability hooks, ui/card, ui/badge, ui/alert, ui/skeleton, types
- OverviewTab.tsx | Renders a configurable per-service overview tab that displays and manages service-specific widgets in a responsive grid. | exp: func:OverviewTab({ instance }: { instance: ServiceInstance }), call:useWidgetInstances, call:useState, call:useMemo, call:widgets .filter((w) => w.enabled) .sort, call:setConfigOpen, call:visibleWidgets.map, call:setEditWidgetId | dep: react, @/components/ui/alert, @/components/ui/button, lucide-react, ../../hooks/useWidgets, ../../components/WidgetInstance, ../../components/WidgetConfigDialog, ../../types, ui/alert, ui/button, useWidgets hook, WidgetInstance component, WidgetConfigDialog component, types
- RequestsTab.tsx | Displays a Jellyseerr request-management tab within a Jellyfin page, showing either a configuration CTA or a placeholder view depending on whether Jellyseerr config fields are present. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record<string, unknown>).jellyseerr_url ?? "", ).trim, call:String( (instance.config as Record<string, unknown>).jellyseerr_api_key ?? "", ).trim | dep: ../../types, @/components/ui/alert, lucide-react, ServiceInstance type, Alert/AlertDescription UI components, lucide-react ExternalLink icon
- RequestsTab.tsx | Displays Jellyseerr request statistics and recent requests for a Jellyfin service instance. | exp: func:RequestsTab({ instance }: { instance: ServiceInstance }), call:String( (instance.config as Record<string, unknown>).jellyseerr_url ?? "", ).trim, call:Boolean, call:useJellyseerrStats, call:(data?.stats ?? []).map, call:data.recent.slice(0, 12).map | dep: ../../types, ../../hooks/useJellyseer, @/components/ui/alert, @/components/ui/badge, @/components/ui/skeleton, ../../components/MetricCard, lucide-react, ServiceInstance, useJellyseerrStats, Alert, Badge, Skeleton, MetricCard
- UsersTab.tsx | Displays a searchable, paginated table of Authentik users for a given service instance. | exp: func:UsersTab({ instance }: { instance: ServiceInstance }), call:useState, call:useAuthentikUsers, call:Math.max, call:Math.ceil, call:setPage, call:setCommittedSearch, call:setSearch, call:handleSearch, call:users.map, call:Math.min | dep: react, @/components/ui/alert, @/components/ui/badge, @/components/ui/button, @/components/ui/input, @/components/ui/table, ../../types, ../../hooks/useAuthentik
- index.ts | Maps service types to their corresponding content tab components for rendering a service page. | exp: ServiceTabComponent, ContentTab, OVERVIEW_TAB, func:serviceContentTabs(serviceType: string) → ContentTab[] | dep: react, ../../types, ./OverviewTab, ./AlertsTab, ./MetricsTab, ./MediaTab, ./RequestsTab, ./FilesTab, ./ActionsTab, ./JobsTab, ./UsersTab, ./MessagingTab, OverviewTab, AlertsTab, MetricsTab, MediaTab, RequestsTab, FilesTab, ActionsTab, JobsTab, UsersTab, MessagingTab
## arch
Registry pattern via index.ts mapping service types to React tab components; each tab is a self-contained functional component scoped to a specific service instance.
Component-per-tab pattern with a central registry (index.ts) mapping service types to their respective tab components.
## tags
call:use, components, ui, tab, state, call:set, locale, string
## symbols
+70 -21
View File
@@ -1,38 +1,42 @@
/**
* RequestsTab — Jellyseerr request-management surface on the Jellyfin page.
* RequestsTab — Jellyseerr request stats surface on the Jellyfin page.
*
* Jellyseerr was absorbed into Jellyfin config (jellyseerr_url +
* jellyseerr_api_key) in Slice 1. This tab reads those config fields. When
* configured, it shows the URL and a placeholder (no requests backend endpoint
* exists yet — building one is out of scope for this slice). When not
* configured, it shows an empty-state CTA directing the user to add the fields
* to the Jellyfin config.
* Reads the Jellyfin service's jellyseerr_url (config) + jellyseerr_api_key
* (secret). When configured, polls /api/jellyseerr/stats and renders the
* request-count grid + a recent-requests list. Individual stats can be pinned
* to dashboards via the "Request stat" widget.
*/
import type { ServiceInstance } from "../../types";
import { useJellyseerrStats } from "../../hooks/useJellyseer";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { MetricCard } from "../../components/MetricCard";
import { ExternalLink } from "lucide-react";
export function RequestsTab({ instance }: { instance: ServiceInstance }) {
const jellyseerrUrl = String(
(instance.config as Record<string, unknown>).jellyseerr_url ?? "",
).trim();
const jellyseerrApiKey = String(
(instance.config as Record<string, unknown>).jellyseerr_api_key ?? "",
).trim();
const jellyseerrApiKeySet = Boolean(
(instance.secrets_set as Record<string, boolean> | undefined)
?.jellyseerr_api_key,
);
const { data, isLoading, error } = useJellyseerrStats(instance.id);
if (!jellyseerrUrl || !jellyseerrApiKey) {
if (!jellyseerrUrl || !jellyseerrApiKeySet) {
return (
<Alert>
<AlertDescription>
Jellyseerr is not configured for this Jellyfin instance. Add
Jellyseerr is not configured for this Jellyfin instance. Add a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_url
</code>
and
config field and a
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
jellyseerr_api_key
</code>
to the Jellyfin config (Config tab) to enable request management.
secret to the Jellyfin service to enable request management.
</AlertDescription>
</Alert>
);
@@ -52,13 +56,58 @@ export function RequestsTab({ instance }: { instance: ServiceInstance }) {
<ExternalLink className="size-3.5" />
</a>
</div>
<Alert>
<AlertDescription>
Jellyseerr is configured. The requests view will show pending and
recently fulfilled media requests. (This surface is under
development.)
</AlertDescription>
</Alert>
{error ? (
<Alert variant="destructive">
<AlertDescription>{error.message}</AlertDescription>
</Alert>
) : isLoading ? (
<Skeleton className="h-32 w-full" />
) : data?.detail && !(data.stats && data.stats.length > 0) ? (
<Alert>
<AlertDescription>{data.detail}</AlertDescription>
</Alert>
) : (
<>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-6">
{(data?.stats ?? []).map((s) => (
<MetricCard key={s.key} label={s.label} value={String(s.value)} />
))}
</div>
{data?.recent && data.recent.length > 0 ? (
<div className="flex flex-col gap-1">
<span className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Recent requests
</span>
{data.recent.slice(0, 12).map((r, i) => (
<div
key={String(r.id ?? i)}
className="flex items-center justify-between gap-2 rounded border p-2 text-sm"
>
<div className="flex min-w-0 flex-col">
<span className="truncate font-medium">{r.name ?? "—"}</span>
<span className="text-xs text-muted-foreground">
{r.type ? String(r.type) : ""}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{r.media_status ? (
<Badge variant="outline">{r.media_status}</Badge>
) : null}
{r.status ? <Badge variant="secondary">{r.status}</Badge> : null}
</div>
</div>
))}
</div>
) : null}
<p className="text-xs text-muted-foreground">
Pin individual stats to a dashboard with the &ldquo;Request stat&rdquo;
widget.
</p>
</>
)}
</div>
);
}
@@ -1,15 +1,27 @@
import { describe, it, expect } from "vitest";
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { RequestsTab } from "../RequestsTab";
import { useJellyseerrStats } from "../../../hooks/useJellyseer";
import type { ServiceInstance } from "../../../types";
function makeInstance(config: Record<string, unknown>): ServiceInstance {
// Mock the stats hook so the tab renders without a QueryClientProvider and we
// can drive the rendered state directly.
vi.mock("../../../hooks/useJellyseer", () => ({
useJellyseerrStats: vi.fn(),
}));
const mockUseJellyseerrStats = vi.mocked(useJellyseerrStats);
function makeInstance(
config: Record<string, unknown>,
secrets_set: Record<string, boolean> = {},
): ServiceInstance {
return {
id: "jellyfin-1",
service_type: "jellyfin",
name: "Main Jellyfin",
config,
secrets_set: {},
secrets_set,
enabled: true,
created_at: 1_700_000_000,
updated_at: 1_700_000_000,
@@ -18,6 +30,7 @@ function makeInstance(config: Record<string, unknown>): ServiceInstance {
describe("RequestsTab", () => {
it("shows empty-state CTA when Jellyseerr is not configured", () => {
mockUseJellyseerrStats.mockReturnValue({ data: undefined, isLoading: false, error: null });
render(
<RequestsTab
instance={makeInstance({
@@ -30,30 +43,59 @@ describe("RequestsTab", () => {
expect(screen.getByText(/jellyseerr_url/i)).toBeInTheDocument();
});
it("shows the configured Jellyseerr URL when both fields are set", () => {
it("shows empty-state when only URL is set (api key secret missing)", () => {
mockUseJellyseerrStats.mockReturnValue({ data: undefined, isLoading: false, error: null });
render(
<RequestsTab
instance={makeInstance({
base_url: "https://jf.example.com",
jellyseerr_url: "https://requests.example.com",
jellyseerr_api_key: "secret-key",
})}
/>,
);
expect(
screen.getByText("https://requests.example.com"),
).toBeInTheDocument();
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
});
it("shows empty-state when only URL is set (missing api_key)", () => {
render(
<RequestsTab
instance={makeInstance({
jellyseerr_url: "https://requests.example.com",
})}
instance={makeInstance({ jellyseerr_url: "https://requests.example.com" })}
/>,
);
expect(screen.getByText(/not configured/i)).toBeInTheDocument();
});
it("shows the configured Jellyseerr URL and the stats grid", () => {
mockUseJellyseerrStats.mockReturnValue({
data: {
stats: [
{ key: "pending", label: "Pending", value: 3 },
{ key: "total", label: "Total", value: 42 },
],
recent: [
{ id: 1, name: "Inception", type: "movie", status: "pending", media_status: "available" },
],
},
isLoading: false,
error: null,
});
render(
<RequestsTab
instance={makeInstance(
{ jellyseerr_url: "https://requests.example.com" },
{ jellyseerr_api_key: true },
)}
/>,
);
expect(screen.getByText("https://requests.example.com")).toBeInTheDocument();
expect(screen.queryByText(/not configured/i)).not.toBeInTheDocument();
expect(screen.getByText("Pending")).toBeInTheDocument();
expect(screen.getByText("3")).toBeInTheDocument();
expect(screen.getByText("Inception")).toBeInTheDocument();
});
it("surfaces a fetch error", () => {
mockUseJellyseerrStats.mockReturnValue({
data: undefined,
isLoading: false,
error: new Error("boom"),
});
render(
<RequestsTab
instance={makeInstance(
{ jellyseerr_url: "https://requests.example.com" },
{ jellyseerr_api_key: true },
)}
/>,
);
expect(screen.getByText(/boom/i)).toBeInTheDocument();
});
});