Build OverSnitch dashboard
Full implementation on top of the Next.js scaffold: - Leaderboard with per-user request count, storage, avg GB/req, and optional Tautulli watch stats (plays, watch hours), each with dense per-metric rank (#N/total) - SWR cache on /api/stats (5-min stale, force-refresh via button); client-side localStorage seed so the UI is instant on return visits - Alerting system: content-centric alerts (unfulfilled downloads, partial TV downloads, stale pending requests) and user-behavior alerts (ghost requester, low watch rate, declined streak) - Partial TV detection: flags ended series with <90% of episodes on disk - Alert persistence in data/alerts.json with open/closed state, auto-resolve when condition clears, manual close with per-category cooldown, and per-alert notes - Alert detail page rendered as a server component for instant load - Dark UI with Tailwind v4, severity-colored left borders, summary cards with icons, sortable leaderboard table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
189
src/components/LeaderboardTable.tsx
Normal file
189
src/components/LeaderboardTable.tsx
Normal file
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { UserStat } from "@/lib/types";
|
||||
|
||||
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
||||
|
||||
interface LeaderboardTableProps {
|
||||
users: UserStat[];
|
||||
hasTautulli: boolean;
|
||||
}
|
||||
|
||||
function formatGB(gb: number): string {
|
||||
return gb >= 1000 ? `${(gb / 1000).toFixed(2)} TB` : `${gb.toFixed(1)} GB`;
|
||||
}
|
||||
|
||||
function formatHours(h: number): string {
|
||||
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
|
||||
return `${h.toFixed(0)}h`;
|
||||
}
|
||||
|
||||
function Rank({ rank, total }: { rank: number | null; total: number }) {
|
||||
if (rank === null) return <span className="text-slate-700">—</span>;
|
||||
return (
|
||||
<span className="text-xs font-mono text-slate-500">
|
||||
#{rank}<span className="text-slate-700">/{total}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function SortChevrons({ active, asc }: { active: boolean; asc: boolean }) {
|
||||
return (
|
||||
<span className="ml-1 inline-flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{active ? (
|
||||
<svg
|
||||
className="h-3 w-3 text-yellow-400"
|
||||
viewBox="0 0 12 12"
|
||||
fill="currentColor"
|
||||
>
|
||||
{asc
|
||||
? <path d="M6 2 L10 8 L2 8 Z" />
|
||||
: <path d="M6 10 L10 4 L2 4 Z" />
|
||||
}
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3 w-3 text-slate-600" viewBox="0 0 12 12" fill="currentColor">
|
||||
<path d="M6 1 L9 5 L3 5 Z M6 11 L9 7 L3 7 Z" />
|
||||
</svg>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function LeaderboardTable({
|
||||
users,
|
||||
hasTautulli,
|
||||
}: LeaderboardTableProps) {
|
||||
const [sortKey, setSortKey] = useState<SortKey>("totalBytes");
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
const sorted = [...users].sort((a, b) => {
|
||||
const av = (a[sortKey] ?? -1) as number;
|
||||
const bv = (b[sortKey] ?? -1) as number;
|
||||
return sortAsc ? av - bv : bv - av;
|
||||
});
|
||||
|
||||
function handleSort(key: SortKey) {
|
||||
if (key === sortKey) setSortAsc((p) => !p);
|
||||
else { setSortKey(key); setSortAsc(false); }
|
||||
}
|
||||
|
||||
function Th({
|
||||
label,
|
||||
col,
|
||||
right,
|
||||
}: {
|
||||
label: string;
|
||||
col?: SortKey;
|
||||
right?: boolean;
|
||||
}) {
|
||||
const active = col === sortKey;
|
||||
return (
|
||||
<th
|
||||
onClick={col ? () => handleSort(col) : undefined}
|
||||
className={[
|
||||
"group py-3 px-4 text-xs font-semibold uppercase tracking-wider whitespace-nowrap select-none",
|
||||
right ? "text-right" : "text-left",
|
||||
col ? "cursor-pointer" : "",
|
||||
active ? "text-yellow-400" : "text-slate-500 hover:text-slate-300",
|
||||
].join(" ")}
|
||||
>
|
||||
{label}
|
||||
{col && <SortChevrons active={active} asc={sortAsc} />}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
const total = users.length;
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-800/80 border-b border-slate-700/60">
|
||||
<tr>
|
||||
<Th label="#" />
|
||||
<Th label="User" />
|
||||
<Th label="Requests" col="requestCount" right />
|
||||
<Th label="Storage" col="totalBytes" right />
|
||||
<Th label="Avg / Req" col="avgGB" right />
|
||||
{hasTautulli && <Th label="Plays" col="plays" right />}
|
||||
{hasTautulli && <Th label="Watch Time" col="watchHours" right />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-700/30">
|
||||
{sorted.map((user, idx) => (
|
||||
<tr
|
||||
key={user.userId}
|
||||
className="bg-slate-900 hover:bg-slate-800/50 transition-colors"
|
||||
>
|
||||
{/* Row index */}
|
||||
<td className="py-3 px-4 text-slate-700 font-mono text-xs w-10 tabular-nums">
|
||||
{idx + 1}
|
||||
</td>
|
||||
|
||||
{/* User */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="font-medium text-white leading-snug">{user.displayName}</div>
|
||||
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
|
||||
</td>
|
||||
|
||||
{/* Requests */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="text-slate-300 font-mono tabular-nums">
|
||||
{user.requestCount.toLocaleString()}
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-end">
|
||||
<Rank rank={user.requestRank} total={total} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Storage */}
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="text-white font-semibold font-mono tabular-nums">
|
||||
{formatGB(user.totalGB)}
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-end">
|
||||
<Rank rank={user.storageRank} total={total} />
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Avg / Request */}
|
||||
<td className="py-3 px-4 text-right text-slate-500 font-mono text-xs tabular-nums">
|
||||
{user.requestCount > 0 ? formatGB(user.avgGB) : "—"}
|
||||
</td>
|
||||
|
||||
{/* Plays */}
|
||||
{hasTautulli && (
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="text-slate-300 font-mono tabular-nums">
|
||||
{user.plays !== null ? user.plays.toLocaleString() : "—"}
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-end">
|
||||
<Rank rank={user.playsRank} total={total} />
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{/* Watch Time */}
|
||||
{hasTautulli && (
|
||||
<td className="py-3 px-4 text-right">
|
||||
<div className="text-slate-500 font-mono text-xs tabular-nums">
|
||||
{user.watchHours !== null && user.watchHours > 0
|
||||
? formatHours(user.watchHours)
|
||||
: user.watchHours === 0
|
||||
? "0h"
|
||||
: "—"}
|
||||
</div>
|
||||
<div className="mt-0.5 flex justify-end">
|
||||
<Rank rank={user.watchRank} total={total} />
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user