Files
OverSnitch/src/app/page.tsx
T
josh 74588e50f6 Refactor user detail page: split components, unify formatters
Break the 615-line UserDetail.tsx into focused sub-components
(header, stat cards, activity chart, request history, open alerts)
and extract shared utilities to lib/ (format, userChart,
enrichRequests). Promote storage load (GB/hr) to a stat card and
collapse the chart UX to a single metric picker. Add server-wide
average reference line alongside the user's own on every metric,
and link request titles to their Seerr pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:00:21 -04:00

191 lines
7.8 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { DashboardStats } from "@/lib/types";
import { timeAgo } from "@/lib/format";
import SummaryCards from "@/components/SummaryCards";
import LeaderboardTable from "@/components/LeaderboardTable";
import AlertsPanel from "@/components/AlertsPanel";
import RefreshButton from "@/components/RefreshButton";
import SettingsModal from "@/components/SettingsModal";
type Tab = "leaderboard" | "alerts";
const LS_KEY = "oversnitch_stats";
export default function Page() {
const [tab, setTab] = useState<Tab>("leaderboard");
const [data, setData] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const didInit = useRef(false);
const load = useCallback(async (force = false) => {
setData((prev) => {
if (prev) setRefreshing(true);
else setLoading(true);
return prev;
});
setError(null);
try {
const res = await fetch(force ? "/api/stats?force=1" : "/api/stats");
const json = await res.json();
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
const stats = json as DashboardStats;
setData(stats);
try { localStorage.setItem(LS_KEY, JSON.stringify(stats)); } catch {}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) setData(JSON.parse(raw) as DashboardStats);
} catch {}
load();
}, [load]);
// Poll every 2 minutes to keep the UI fresh against the server cache.
// The server itself refreshes every 5 min via the background poller.
useEffect(() => {
const id = setInterval(() => load(), 2 * 60 * 1000);
return () => clearInterval(id);
}, [load]);
const hasTautulli = data?.summary.totalWatchHours !== null;
const openAlertCount = data?.summary.openAlertCount ?? 0;
const generatedAt = data?.generatedAt ?? null;
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Over<span className="text-yellow-400">Snitch</span>
</h1>
<p className="text-slate-400 text-sm mt-1.5">Request &amp; usage analytics</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsOpen(true)}
className="rounded-lg border border-slate-700/60 bg-slate-800/40 hover:bg-slate-800 p-2 text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Settings"
title="Settings"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
</div>
{generatedAt && (
<span className="text-xs text-slate-600">
{refreshing
? <span className="text-yellow-600/80">Refreshing</span>
: <>Updated {timeAgo(generatedAt)}</>
}
</span>
)}
</div>
</div>
{/* First-ever load spinner */}
{loading && !data && (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<svg
className="animate-spin h-8 w-8 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<div className="text-center space-y-1">
<p className="text-slate-300 text-sm font-medium">Fetching data</p>
<p className="text-slate-600 text-xs">This only takes a moment on first load.</p>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-800 bg-red-950/40 px-5 py-4 text-sm">
<span className="font-semibold text-red-400">Error: </span>
<span className="text-red-300">{error}</span>
</div>
)}
{data && (
<>
<SummaryCards
totalUsers={data.summary.totalUsers}
totalRequests={data.summary.totalRequests}
totalStorageGB={data.summary.totalStorageGB}
totalWatchHours={data.summary.totalWatchHours}
openAlertCount={data.summary.openAlertCount}
onAlertsClick={() => setTab("alerts")}
/>
{/* Tab bar */}
<div className="border-b border-slate-700/60">
<div className="flex gap-1 -mb-px">
{(["leaderboard", "alerts"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
tab === t
? "border-yellow-400 text-white"
: "border-transparent text-slate-500 hover:text-slate-300"
}`}
>
{t === "alerts" ? (
<span className="flex items-center gap-2">
Alerts
{openAlertCount > 0 && (
<span className="rounded-full bg-yellow-500 text-black text-xs font-bold px-1.5 py-0.5 leading-none">
{openAlertCount}
</span>
)}
</span>
) : (
"Leaderboard"
)}
</button>
))}
</div>
</div>
{tab === "leaderboard" && (
<LeaderboardTable users={data.users} hasTautulli={hasTautulli} />
)}
{tab === "alerts" && <AlertsPanel />}
</>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => load(true)}
/>
</main>
);
}