mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-07-14 18:15:56 -04:00
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:
@@ -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">
|
||||
|
||||
@@ -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=="],
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user