Add SWR for system health and command palette search fetching

Replace manual fetch/useState/useEffect patterns with SWR hooks in
system health section and command palette search, gaining automatic
caching, request deduplication, and stale-while-revalidate.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-05 12:24:23 -05:00
co-authored by Claude Opus 4.6
parent fb6c88e8f5
commit 59e9dd6775
7 changed files with 87 additions and 66 deletions
@@ -9,7 +9,7 @@ import {
IconPlayerPlay, IconPlayerPlay,
IconRefresh, IconRefresh,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { StatusDot } from "@/components/status-dot"; import { StatusDot } from "@/components/status-dot";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -34,6 +34,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { useSystemHealth } from "@/hooks/use-system-health";
import { useTimeAgo } from "@/hooks/use-time-ago"; import { useTimeAgo } from "@/hooks/use-time-ago";
import type { SystemHealthData } from "@/lib/services/system-health"; import type { SystemHealthData } from "@/lib/services/system-health";
@@ -129,30 +130,15 @@ function LiveTimeAgo({
/** Renders 3 separate cards: System status, Background jobs, Storage */ /** Renders 3 separate cards: System status, Background jobs, Storage */
export function SystemHealthCards() { export function SystemHealthCards() {
const [data, setData] = useState<SystemHealthData | null>(null); const { data, error, isLoading, isValidating, refresh } = useSystemHealth();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const fetchHealth = useCallback(async (isRefresh = false) => {
if (isRefresh) setRefreshing(true);
try {
const res = await fetch("/api/admin/system-health");
if (!res.ok) throw new Error("Failed to fetch");
const health: SystemHealthData = await res.json();
setData(health);
} catch {
if (isRefresh) toast.error("Failed to refresh system health");
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => { useEffect(() => {
fetchHealth(); if (error && !isLoading) {
}, [fetchHealth]); toast.error("Failed to refresh system health");
}
}, [error, isLoading]);
if (loading) return <SkeletonCards />; if (isLoading) return <SkeletonCards />;
if (!data) return null; if (!data) return null;
return ( return (
@@ -178,12 +164,12 @@ export function SystemHealthCards() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => fetchHealth(true)} onClick={() => refresh()}
disabled={refreshing} disabled={isValidating}
/> />
} }
> >
{refreshing ? <Spinner /> : <IconRefresh />} {isValidating ? <Spinner /> : <IconRefresh />}
</TooltipTrigger> </TooltipTrigger>
<TooltipContent>Refresh</TooltipContent> <TooltipContent>Refresh</TooltipContent>
</Tooltip> </Tooltip>
@@ -277,10 +263,7 @@ export function SystemHealthCards() {
</Card> </Card>
{/* ── Card 2: Background Jobs ── */} {/* ── Card 2: Background Jobs ── */}
<BackgroundJobsCard <BackgroundJobsCard jobs={data.jobs} onRefresh={refresh} />
jobs={data.jobs}
onRefresh={() => fetchHealth(true)}
/>
{/* ── Card 3: Storage ── */} {/* ── Card 3: Storage ── */}
<Card className="border-l-2 border-l-primary/30"> <Card className="border-l-2 border-l-primary/30">
+5
View File
@@ -29,6 +29,7 @@
"recharts": "3.7.0", "recharts": "3.7.0",
"shadcn": "3.8.5", "shadcn": "3.8.5",
"sonner": "2.0.7", "sonner": "2.0.7",
"swr": "2.4.1",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"vaul": "1.1.2", "vaul": "1.1.2",
@@ -759,6 +760,8 @@
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="],
"destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
@@ -1381,6 +1384,8 @@
"styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="],
"swr": ["swr@2.4.1", "", { "dependencies": { "dequal": "^2.0.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA=="],
"tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="], "tabbable": ["tabbable@6.4.0", "", {}, "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="], "tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+7 -32
View File
@@ -33,6 +33,7 @@ import {
import { Kbd } from "@/components/ui/kbd"; import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { useDebounce } from "@/hooks/use-debounce"; import { useDebounce } from "@/hooks/use-debounce";
import { useSearch } from "@/hooks/use-search";
import { import {
commandPaletteOpenAtom, commandPaletteOpenAtom,
helpOpenAtom, helpOpenAtom,
@@ -41,14 +42,7 @@ import {
} from "@/lib/atoms/command-palette"; } from "@/lib/atoms/command-palette";
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts"; import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
interface SearchResult { type SearchResult = ReturnType<typeof useSearch>["results"][number];
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}
export function CommandPalette() { export function CommandPalette() {
const router = useRouter(); const router = useRouter();
@@ -58,9 +52,8 @@ export function CommandPalette() {
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom); const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom); const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300); const debouncedQuery = useDebounce(query, 300);
const { results, isLoading: loading } = useSearch(debouncedQuery);
const enabled = !commandPaletteOpen; const enabled = !commandPaletteOpen;
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev)); useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
@@ -75,41 +68,23 @@ export function CommandPalette() {
timeout: 500, timeout: 500,
}); });
// Reset ephemeral state when palette opens // Reset query when palette opens
useEffect(() => { useEffect(() => {
if (commandPaletteOpen) { if (commandPaletteOpen) {
setQuery(""); setQuery("");
setResults([]);
} }
}, [commandPaletteOpen]); }, [commandPaletteOpen]);
// Search TMDB // Save to recent searches when results arrive
useEffect(() => { useEffect(() => {
if (!debouncedQuery.trim()) {
setResults([]);
return;
}
let cancelled = false;
setLoading(true);
fetch(`/api/search?query=${encodeURIComponent(debouncedQuery)}`)
.then((r) => r.json())
.then((data) => {
if (!cancelled) {
setResults((data.results ?? []).slice(0, 8));
const trimmed = debouncedQuery.trim(); const trimmed = debouncedQuery.trim();
if (trimmed && results.length > 0) {
setRecentSearches((prev) => { setRecentSearches((prev) => {
const filtered = prev.filter((q) => q !== trimmed); const filtered = prev.filter((q) => q !== trimmed);
return [trimmed, ...filtered].slice(0, MAX_RECENT); return [trimmed, ...filtered].slice(0, MAX_RECENT);
}); });
} }
}) }, [debouncedQuery, results, setRecentSearches]);
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, setRecentSearches]);
const handleSelect = useCallback( const handleSelect = useCallback(
(result: SearchResult) => { (result: SearchResult) => {
+30
View File
@@ -0,0 +1,30 @@
import useSWR from "swr";
import { fetcher } from "@/lib/swr/fetcher";
interface SearchResponse {
results: {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}[];
}
export function useSearch(debouncedQuery: string) {
const trimmed = debouncedQuery.trim();
const { data, isLoading } = useSWR<SearchResponse>(
trimmed ? `/api/search?query=${encodeURIComponent(trimmed)}` : null,
fetcher,
{
revalidateOnFocus: false,
dedupingInterval: 2_000,
},
);
return {
results: data?.results?.slice(0, 8) ?? [],
isLoading,
};
}
+19
View File
@@ -0,0 +1,19 @@
import useSWR from "swr";
import type { SystemHealthData } from "@/lib/services/system-health";
import { fetcher } from "@/lib/swr/fetcher";
export function useSystemHealth() {
const { data, error, isLoading, isValidating, mutate } =
useSWR<SystemHealthData>("/api/admin/system-health", fetcher, {
revalidateOnFocus: true,
dedupingInterval: 10_000,
});
return {
data: data ?? null,
error,
isLoading,
isValidating,
refresh: () => mutate(),
};
}
+8
View File
@@ -0,0 +1,8 @@
export async function fetcher<T>(url: string): Promise<T> {
const res = await fetch(url);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.error || `Request failed (${res.status})`);
}
return res.json();
}
+1
View File
@@ -39,6 +39,7 @@
"recharts": "3.7.0", "recharts": "3.7.0",
"shadcn": "3.8.5", "shadcn": "3.8.5",
"sonner": "2.0.7", "sonner": "2.0.7",
"swr": "2.4.1",
"tailwind-merge": "3.5.0", "tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0", "tw-animate-css": "1.4.0",
"vaul": "1.1.2", "vaul": "1.1.2",