Files
OverSnitch/src/app/page.tsx
Josh Wright 641a7fd096 Add settings UI, Discord notifications, and alert detail improvements
- Settings modal (gear icon) lets you configure all service URLs and API
  keys from the dashboard; values persist to data/settings.json with
  process.env as fallback so existing .env.local setups keep working
- Per-service Test button hits each service's status endpoint and reports
  the version on success
- Discord webhook support: structured embeds per alert category (requesters,
  approval age, episode progress, watch-rate stats) sent on new/reopened
  alerts only — already-open alerts don't re-notify
- Alert detail page restructured: prose descriptions replaced with labelled
  fields, episode progress bar for partial TV, watch-rate stat block,
  View in Radarr/Sonarr/Seerr action buttons, requester names link to
  Overseerr profiles, timestamps moved inline with status
- Tab state is pure client state (no ?tab= in URL); router.back() used
  on alert detail for clean browser history

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-12 14:57:07 -04:00

193 lines
7.8 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { DashboardStats } from "@/lib/types";
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";
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return new Date(iso).toLocaleDateString();
}
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]);
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 ${
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>
);
}