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,
IconRefresh,
} from "@tabler/icons-react";
import { useCallback, useEffect, useState } from "react";
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { StatusDot } from "@/components/status-dot";
import { Button } from "@/components/ui/button";
@@ -34,6 +34,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useSystemHealth } from "@/hooks/use-system-health";
import { useTimeAgo } from "@/hooks/use-time-ago";
import type { SystemHealthData } from "@/lib/services/system-health";
@@ -129,30 +130,15 @@ function LiveTimeAgo({
/** Renders 3 separate cards: System status, Background jobs, Storage */
export function SystemHealthCards() {
const [data, setData] = useState<SystemHealthData | null>(null);
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);
}
}, []);
const { data, error, isLoading, isValidating, refresh } = useSystemHealth();
useEffect(() => {
fetchHealth();
}, [fetchHealth]);
if (error && !isLoading) {
toast.error("Failed to refresh system health");
}
}, [error, isLoading]);
if (loading) return <SkeletonCards />;
if (isLoading) return <SkeletonCards />;
if (!data) return null;
return (
@@ -178,12 +164,12 @@ export function SystemHealthCards() {
<Button
variant="ghost"
size="icon"
onClick={() => fetchHealth(true)}
disabled={refreshing}
onClick={() => refresh()}
disabled={isValidating}
/>
}
>
{refreshing ? <Spinner /> : <IconRefresh />}
{isValidating ? <Spinner /> : <IconRefresh />}
</TooltipTrigger>
<TooltipContent>Refresh</TooltipContent>
</Tooltip>
@@ -277,10 +263,7 @@ export function SystemHealthCards() {
</Card>
{/* ── Card 2: Background Jobs ── */}
<BackgroundJobsCard
jobs={data.jobs}
onRefresh={() => fetchHealth(true)}
/>
<BackgroundJobsCard jobs={data.jobs} onRefresh={refresh} />
{/* ── Card 3: Storage ── */}
<Card className="border-l-2 border-l-primary/30">
+5
View File
@@ -29,6 +29,7 @@
"recharts": "3.7.0",
"shadcn": "3.8.5",
"sonner": "2.0.7",
"swr": "2.4.1",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",
@@ -759,6 +760,8 @@
"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=="],
"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=="],
"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=="],
"tagged-tag": ["tagged-tag@1.0.0", "", {}, "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng=="],
+12 -37
View File
@@ -33,6 +33,7 @@ import {
import { Kbd } from "@/components/ui/kbd";
import { Skeleton } from "@/components/ui/skeleton";
import { useDebounce } from "@/hooks/use-debounce";
import { useSearch } from "@/hooks/use-search";
import {
commandPaletteOpenAtom,
helpOpenAtom,
@@ -41,14 +42,7 @@ import {
} from "@/lib/atoms/command-palette";
import { SHORTCUT_DESCRIPTIONS } from "@/lib/constants/shortcuts";
interface SearchResult {
tmdbId: number;
type: "movie" | "tv";
title: string;
posterPath: string | null;
releaseDate: string | null;
voteAverage: number;
}
type SearchResult = ReturnType<typeof useSearch>["results"][number];
export function CommandPalette() {
const router = useRouter();
@@ -58,9 +52,8 @@ export function CommandPalette() {
const [helpOpen, setHelpOpen] = useAtom(helpOpenAtom);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [query, setQuery] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const debouncedQuery = useDebounce(query, 300);
const { results, isLoading: loading } = useSearch(debouncedQuery);
const enabled = !commandPaletteOpen;
useHotkey("Mod+K", () => setCommandPaletteOpen((prev) => !prev));
@@ -75,41 +68,23 @@ export function CommandPalette() {
timeout: 500,
});
// Reset ephemeral state when palette opens
// Reset query when palette opens
useEffect(() => {
if (commandPaletteOpen) {
setQuery("");
setResults([]);
}
}, [commandPaletteOpen]);
// Search TMDB
// Save to recent searches when results arrive
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();
setRecentSearches((prev) => {
const filtered = prev.filter((q) => q !== trimmed);
return [trimmed, ...filtered].slice(0, MAX_RECENT);
});
}
})
.finally(() => {
if (!cancelled) setLoading(false);
const trimmed = debouncedQuery.trim();
if (trimmed && results.length > 0) {
setRecentSearches((prev) => {
const filtered = prev.filter((q) => q !== trimmed);
return [trimmed, ...filtered].slice(0, MAX_RECENT);
});
return () => {
cancelled = true;
};
}, [debouncedQuery, setRecentSearches]);
}
}, [debouncedQuery, results, setRecentSearches]);
const handleSelect = useCallback(
(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",
"shadcn": "3.8.5",
"sonner": "2.0.7",
"swr": "2.4.1",
"tailwind-merge": "3.5.0",
"tw-animate-css": "1.4.0",
"vaul": "1.1.2",