0b039529f6
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.
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
/**
|
|
* RequestsTab — Jellyseerr request stats surface on the Jellyfin page.
|
|
*
|
|
* 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 jellyseerrApiKeySet = Boolean(
|
|
(instance.secrets_set as Record<string, boolean> | undefined)
|
|
?.jellyseerr_api_key,
|
|
);
|
|
const { data, isLoading, error } = useJellyseerrStats(instance.id);
|
|
|
|
if (!jellyseerrUrl || !jellyseerrApiKeySet) {
|
|
return (
|
|
<Alert>
|
|
<AlertDescription>
|
|
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>
|
|
config field and a
|
|
<code className="mx-1 rounded bg-muted px-1 py-0.5 text-xs">
|
|
jellyseerr_api_key
|
|
</code>
|
|
secret to the Jellyfin service to enable request management.
|
|
</AlertDescription>
|
|
</Alert>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="text-sm font-semibold">Jellyseerr</h3>
|
|
<a
|
|
href={jellyseerrUrl}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
|
|
>
|
|
{jellyseerrUrl}
|
|
<ExternalLink className="size-3.5" />
|
|
</a>
|
|
</div>
|
|
|
|
{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>
|
|
);
|
|
}
|