Add per-user detail pages with activity chart and request history

Each user in the leaderboard links to a profile page showing stat cards,
a line chart (requests / storage / watch hours, 1W–1Y timeframes, raw or
normalized, plus a Storage Load mode), and a full request history sorted
newest-first. Includes Overseerr media status codes (1–5), Tautulli watch
history aggregation, and a server-side raw cache so the user API route can
enrich requests without re-fetching everything.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-12 17:26:47 -04:00
parent 641a7fd096
commit b2c1642065
14 changed files with 1377 additions and 66 deletions
+1 -1
View File
@@ -393,7 +393,7 @@ export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrU
{/* Back */}
<button
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
+2 -56
View File
@@ -1,63 +1,9 @@
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, OverseerrRequest } from "@/lib/types";
const BATCH_SIZE = 5;
// ── Server-side SWR cache ────────────────────────────────────────────────────
// Persists in the Node.js process between requests.
// Background-refreshes after STALE_MS so reads always return instantly.
const STALE_MS = 5 * 60 * 1000; // start background refresh after 5 min
let cache: { stats: DashboardStats; at: number } | null = null;
let refreshing = false;
async function buildStats(): Promise<DashboardStats> {
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
buildRadarrMap(),
buildSonarrMap(),
fetchAllUsers(),
buildTautulliMap(),
]);
const allRequests = new Map<number, OverseerrRequest[]>();
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(
chunk.map((u) => fetchUserRequests(u.id))
);
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
import { getStats } from "@/lib/statsBuilder";
export async function GET(req: Request) {
const force = new URL(req.url).searchParams.has("force");
try {
// Force (Refresh button) or cold start: wait for fresh data
if (force || !cache) {
const stats = await buildStats();
cache = { stats, at: Date.now() };
return Response.json(cache.stats);
}
// Stale: kick off background refresh, return cache immediately
const age = Date.now() - cache.at;
if (age > STALE_MS && !refreshing) {
refreshing = true;
buildStats()
.then((stats) => { cache = { stats, at: Date.now() }; })
.catch(() => {})
.finally(() => { refreshing = false; });
}
return Response.json(cache.stats);
return Response.json(await getStats(force));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
+92
View File
@@ -0,0 +1,92 @@
import { getStats, getRawCache } from "@/lib/statsBuilder";
import { lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
import { getAllAlerts } from "@/lib/db";
import { bytesToGB } from "@/lib/aggregate";
import { EnrichedRequest, UserPageData } from "@/lib/types";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
try {
// Find the user in the cached stats (triggers a build if cache is cold)
const stats = await getStats();
const stat = stats.users.find((u) => u.userId === userId);
if (!stat) {
return Response.json({ error: "User not found" }, { status: 404 });
}
// Enrich requests with resolved title + size from cached media maps
const raw = getRawCache();
const userRequests = raw?.allRequests.get(userId) ?? [];
const enrichedRequests: EnrichedRequest[] = userRequests.map((req) => {
let sizeOnDisk = 0;
let title = req.media.title ?? "";
if (req.type === "movie") {
const entry = raw?.radarrMap.get(req.media.tmdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
} else if (req.type === "tv" && req.media.tvdbId) {
const entry = raw?.sonarrMap.get(req.media.tvdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
}
if (!title) {
title = req.type === "movie"
? `Movie #${req.media.tmdbId}`
: `Show #${req.media.tmdbId}`;
}
return {
id: req.id,
type: req.type,
status: req.status,
createdAt: req.createdAt,
mediaId: req.type === "movie" ? req.media.tmdbId : (req.media.tvdbId ?? 0),
title,
sizeOnDisk,
sizeGB: bytesToGB(sizeOnDisk),
};
});
// Fetch watch history from Tautulli (if available)
let tautulliUserId: number | null = null;
if (raw?.tautulliMap) {
const tu = lookupTautulliUser(raw.tautulliMap, stat.email, stat.displayName);
tautulliUserId = tu?.user_id ?? null;
}
const watchHistory = tautulliUserId
? await fetchUserWatchHistory(tautulliUserId)
: [];
// Open alerts involving this user
const allAlerts = getAllAlerts();
const openAlerts = allAlerts.filter(
(a) =>
a.status === "open" &&
(a.userId === userId || a.requesterIds?.includes(userId))
);
const result: UserPageData = {
stat,
enrichedRequests,
watchHistory,
openAlerts,
};
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
}
}
+8 -1
View File
@@ -65,6 +65,13 @@ export default function Page() {
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;
@@ -152,7 +159,7 @@ export default function Page() {
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
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"
+614
View File
@@ -0,0 +1,614 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import Link from "next/link";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
Legend,
ReferenceLine,
} from "recharts";
import {
EnrichedRequest,
WatchDataPoint,
UserPageData,
AlertSeverity,
} from "@/lib/types";
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatGB(gb: number): string {
if (gb === 0) return "—";
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 timeAgo(iso: string | null | undefined): string {
if (!iso) return "Never";
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`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", year: "numeric" });
}
function unixTimeAgo(ts: number | null): string {
if (ts === null) return "Never";
return timeAgo(new Date(ts * 1000).toISOString());
}
// ── Status badge ──────────────────────────────────────────────────────────────
// Overseerr media status codes (media.status, not request approval status):
// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available
const STATUS_LABEL: Record<number, string> = {
1: "Unknown",
2: "Pending",
3: "Processing",
4: "Partial",
5: "Available",
};
const STATUS_COLOR: Record<number, string> = {
1: "bg-slate-700/30 text-slate-500 border-slate-600/40",
2: "bg-yellow-500/15 text-yellow-400 border-yellow-700/40",
3: "bg-blue-500/15 text-blue-400 border-blue-700/40",
4: "bg-cyan-500/15 text-cyan-400 border-cyan-700/40",
5: "bg-green-500/15 text-green-400 border-green-700/40",
};
function StatusBadge({ status }: { status: number }) {
return (
<span className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${STATUS_COLOR[status] ?? "bg-slate-700 text-slate-400 border-slate-600"}`}>
{STATUS_LABEL[status] ?? `Status ${status}`}
</span>
);
}
// ── Rank chip ─────────────────────────────────────────────────────────────────
function RankChip({ rank, total }: { rank: number | null; total: number }) {
if (rank === null) return null;
return (
<span className="text-xs font-mono text-slate-500">
#{rank}<span className="text-slate-700">/{total}</span>
</span>
);
}
// ── Stat cards ────────────────────────────────────────────────────────────────
function StatCard({ label, value, rank, total, highlight }: {
label: string;
value: string;
rank?: number | null;
total?: number;
highlight?: boolean;
}) {
return (
<div className="flex flex-col gap-1 rounded-xl border border-slate-700/60 bg-slate-800/60 px-4 py-3">
<span className="text-xs font-semibold uppercase tracking-wider text-slate-500">{label}</span>
<span className={`text-2xl font-bold tabular-nums ${highlight ? "text-yellow-300" : "text-white"}`}>
{value}
</span>
{rank !== undefined && rank !== null && total !== undefined && (
<RankChip rank={rank} total={total} />
)}
</div>
);
}
// ── Chart ─────────────────────────────────────────────────────────────────────
type Timeframe = "1W" | "1M" | "3M" | "1Y";
const TF_CONFIG: Record<Timeframe, { rangeDays: number; bucketDays: number }> = {
"1W": { rangeDays: 7, bucketDays: 1 },
"1M": { rangeDays: 30, bucketDays: 1 },
"3M": { rangeDays: 91, bucketDays: 7 },
"1Y": { rangeDays: 365, bucketDays: 30 },
};
interface ChartPoint {
label: string;
requests: number;
gb: number;
plays: number;
watchHours: number;
/** GB requested ÷ watch hours. null when watchHours = 0 (no denominator). */
load: number | null;
}
function buildChartPoints(
enrichedRequests: EnrichedRequest[],
watchHistory: WatchDataPoint[],
tf: Timeframe
): ChartPoint[] {
const now = Date.now();
const MS = 86_400_000;
const { rangeDays, bucketDays } = TF_CONFIG[tf];
const numBuckets = Math.ceil(rangeDays / bucketDays);
const points: ChartPoint[] = Array.from({ length: numBuckets }, (_, i) => {
const midMs = now - (numBuckets - 1 - i + 0.5) * bucketDays * MS;
const d = new Date(midMs);
const label =
bucketDays >= 28
? d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return { label, requests: 0, gb: 0, plays: 0, watchHours: 0, load: null };
});
for (const req of enrichedRequests) {
const ageMs = now - new Date(req.createdAt).getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].requests += 1;
points[idx].gb = Math.round((points[idx].gb + req.sizeGB) * 10) / 10;
}
}
for (const wh of watchHistory) {
const ageMs = now - new Date(wh.date + "T12:00:00").getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].plays += wh.plays;
points[idx].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10;
}
}
for (const p of points) {
p.load = p.watchHours > 0 ? Math.round((p.gb / p.watchHours) * 10) / 10 : null;
}
return points;
}
/**
* Normalize each series to % of its own period average.
* A value equal to the mean shows as 100; double the mean shows as 200.
* Series with a mean of 0 stay at 0. load is a ratio — not normalized.
*/
function normalizeData(points: ChartPoint[]): ChartPoint[] {
if (points.length === 0) return points;
const n = points.length;
const meanReq = points.reduce((s, p) => s + p.requests, 0) / n;
const meanGb = points.reduce((s, p) => s + p.gb, 0) / n;
const meanWh = points.reduce((s, p) => s + p.watchHours, 0) / n;
return points.map((p) => ({
label: p.label,
requests: meanReq > 0 ? Math.round((p.requests / meanReq) * 100) : 0,
gb: meanGb > 0 ? Math.round((p.gb / meanGb) * 100) : 0,
plays: p.plays,
watchHours: meanWh > 0 ? Math.round((p.watchHours / meanWh) * 100) : 0,
load: p.load,
}));
}
// ── Alert severity helpers ────────────────────────────────────────────────────
const SEV_COLOR: Record<AlertSeverity, string> = {
danger: "border-l-red-500 bg-red-950/20",
warning: "border-l-yellow-500 bg-yellow-950/10",
info: "border-l-blue-500 bg-blue-950/20",
};
const SEV_TEXT: Record<AlertSeverity, string> = {
danger: "text-red-400",
warning: "text-yellow-400",
info: "text-blue-400",
};
// ── Main component ─────────────────────────────────────────────────────────────
export default function UserDetail({ userId }: { userId: number }) {
const [data, setData] = useState<UserPageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [tf, setTf] = useState<Timeframe>("1M");
const [viewMode, setViewMode] = useState<"metrics" | "load">("metrics");
const [normalized, setNormalized] = useState(false);
const [showRequests, setShowRequests] = useState(true);
const [showStorage, setShowStorage] = useState(true);
const [showWatchHours, setShowWatchHours] = useState(true);
const [showAll, setShowAll] = useState(false);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((json) => {
if (json.error) throw new Error(json.error);
setData(json as UserPageData);
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, [userId]);
const chartData = useMemo(() => {
if (!data) return [];
return buildChartPoints(data.enrichedRequests, data.watchHistory, tf);
}, [data, tf]);
const displayData = useMemo(
() => (normalized && viewMode === "metrics" ? normalizeData(chartData) : chartData),
[chartData, normalized, viewMode]
);
const hasWatch = (data?.watchHistory.length ?? 0) > 0;
// Request history sorted newest first
const sortedByDate = useMemo(() => {
if (!data) return [];
return [...data.enrichedRequests].sort(
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
);
}, [data]);
const displayedRequests = showAll ? sortedByDate : sortedByDate.slice(0, 20);
if (loading) {
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<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>
<p className="text-slate-400 text-sm">Loading user data</p>
</div>
</main>
);
}
if (error || !data) {
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-4">
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
All Users
</Link>
<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 ?? "User not found"}</span>
</div>
</main>
);
}
const { stat, openAlerts } = data;
const hasTautulli = stat.plays !== null;
// User's overall average load (GB requested per watch hour, all time)
const overallLoad =
hasTautulli && stat.watchHours && stat.watchHours > 0
? Math.round((stat.totalGB / stat.watchHours) * 10) / 10
: null;
const statCols = hasTautulli ? "sm:grid-cols-5" : "sm:grid-cols-3";
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Back */}
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
All Users
</Link>
{/* Header */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-3xl font-bold tracking-tight text-white">{stat.displayName}</h1>
{stat.requestCount === 0 && (
<span className="rounded-full border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
No requests
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">{stat.email}</p>
</div>
<div className="flex flex-col items-end gap-1">
{hasTautulli && (
<span className="text-xs text-slate-600">
Last seen: <span className="text-slate-400">{unixTimeAgo(stat.tautulliLastSeen)}</span>
</span>
)}
<span className="text-xs text-slate-600">
#{stat.storageRank} of {stat.totalUsers} by storage
</span>
</div>
</div>
{/* Stat cards */}
<div className={`grid grid-cols-2 gap-3 ${statCols}`}>
<StatCard label="Requests" value={stat.requestCount.toLocaleString()} rank={stat.requestRank} total={stat.totalUsers} />
<StatCard label="Storage" value={formatGB(stat.totalGB)} rank={stat.storageRank} total={stat.totalUsers} highlight />
<StatCard label="Avg / Req" value={stat.requestCount > 0 ? formatGB(stat.avgGB) : "—"} />
{hasTautulli && (
<StatCard label="Plays" value={(stat.plays ?? 0).toLocaleString()} rank={stat.playsRank} total={stat.totalUsers} />
)}
{hasTautulli && (
<StatCard
label="Watch Time"
value={stat.watchHours !== null && stat.watchHours > 0 ? formatHours(stat.watchHours) : "0h"}
rank={stat.watchRank}
total={stat.totalUsers}
/>
)}
</div>
{/* Activity chart */}
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-5 space-y-4">
{/* Chart header */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-2">
<h2 className="text-base font-semibold text-white">Activity</h2>
{/* Metrics / Storage Load mode */}
<div className="flex rounded-lg border border-slate-700/60 overflow-hidden text-xs font-medium">
<button
onClick={() => setViewMode("metrics")}
className={`cursor-pointer px-2.5 py-1 transition-colors ${viewMode === "metrics" ? "bg-slate-700 text-white" : "text-slate-500 hover:text-slate-300"}`}
>
Metrics
</button>
<button
onClick={() => setViewMode("load")}
title="GB requested ÷ watch hours — how much server storage each hour of viewing costs"
className={`cursor-pointer px-2.5 py-1 transition-colors border-l border-slate-700/60 ${viewMode === "load" ? "bg-orange-500/20 text-orange-300" : "text-slate-500 hover:text-slate-300"}`}
>
Storage Load
</button>
</div>
{/* Raw / Relative — metrics mode only */}
{viewMode === "metrics" && (
<button
onClick={() => setNormalized((v) => !v)}
title={normalized ? "Switch to raw values" : "Normalize to % of period average"}
className={`cursor-pointer rounded-full border px-2.5 py-0.5 text-xs font-medium transition-colors ${normalized ? "border-violet-700/60 bg-violet-500/15 text-violet-400" : "border-slate-700/40 bg-slate-800/40 text-slate-500 hover:text-slate-300"}`}
>
{normalized ? "Relative" : "Raw"}
</button>
)}
</div>
<div className="flex items-center gap-1">
{(["1W", "1M", "3M", "1Y"] as Timeframe[]).map((t) => (
<button
key={t}
onClick={() => setTf(t)}
className={`cursor-pointer rounded px-2.5 py-1 text-xs font-medium transition-colors ${tf === t ? "bg-yellow-400/20 text-yellow-300" : "text-slate-500 hover:text-slate-300"}`}
>
{t}
</button>
))}
</div>
</div>
{/* Series toggles — metrics mode only */}
{viewMode === "metrics" && (
<div className="flex flex-wrap gap-3">
<button
onClick={() => setShowRequests((v) => !v)}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showRequests ? "border-blue-700/60 bg-blue-500/10 text-blue-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
>
<span className="h-2 w-2 rounded-full bg-blue-500 inline-block" />
Requests
</button>
<button
onClick={() => setShowStorage((v) => !v)}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showStorage ? "border-yellow-700/60 bg-yellow-500/10 text-yellow-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
>
<span className="h-2 w-2 rounded-full bg-yellow-400 inline-block" />
Storage (GB)
</button>
{hasWatch && (
<button
onClick={() => setShowWatchHours((v) => !v)}
className={`cursor-pointer inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-opacity ${showWatchHours ? "border-green-700/60 bg-green-500/10 text-green-400" : "border-slate-700/40 bg-slate-800/40 text-slate-600 opacity-50"}`}
>
<span className="h-2 w-2 rounded-full bg-green-400 inline-block" />
Watch Hours
</button>
)}
</div>
)}
{/* Load mode explainer */}
{viewMode === "load" && (
<p className="text-xs text-slate-600">
GB requested ÷ watch hours per period. Lower is healthier a well-watched library stays near your average.
{overallLoad !== null && (
<> Your overall average is <span className="text-slate-400">{overallLoad} GB/hr</span>.</>
)}
</p>
)}
{/* Chart */}
<ResponsiveContainer width="100%" height={260}>
<LineChart
data={displayData}
margin={{ top: 4, right: viewMode === "load" || normalized ? 8 : 16, left: -8, bottom: 4 }}
>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="label" tick={{ fill: "#475569", fontSize: 11 }} axisLine={{ stroke: "#334155" }} tickLine={false} />
{viewMode === "load" && (
<YAxis yAxisId="left" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}G/h`} width={44} />
)}
{viewMode === "metrics" && normalized && (
<YAxis yAxisId="left" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}%`} width={40} />
)}
{viewMode === "metrics" && !normalized && (
<YAxis yAxisId="counts" orientation="left" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} width={32} />
)}
{viewMode === "metrics" && !normalized && (
<YAxis yAxisId="gb" orientation="right" tick={{ fill: "#475569", fontSize: 11 }} axisLine={false} tickLine={false} tickFormatter={(v: number) => `${v}G`} width={40} />
)}
<Tooltip
contentStyle={{ background: "#0f172a", border: "1px solid #334155", borderRadius: "8px", fontSize: "12px" }}
labelStyle={{ color: "#94a3b8", marginBottom: "4px" }}
itemStyle={{ color: "#e2e8f0" }}
formatter={(value, name) => {
const num = typeof value === "number" ? value : Number(value);
const label = String(name ?? "");
if (viewMode === "load") return [`${num} GB/hr`, label];
if (normalized) return [`${num}%`, label];
if (label === "Storage (GB)") return [formatGB(num), label];
if (label === "Watch Hours") return [`${num}h`, label];
return [num, label];
}}
/>
<Legend wrapperStyle={{ display: "none" }} />
{viewMode === "metrics" && normalized && (
<ReferenceLine yAxisId="left" y={100} stroke="#334155" strokeDasharray="4 3" label={{ value: "avg", position: "insideTopRight", fill: "#475569", fontSize: 10 }} />
)}
{viewMode === "load" && overallLoad !== null && (
<ReferenceLine yAxisId="left" y={overallLoad} stroke="#f97316" strokeOpacity={0.4} strokeDasharray="4 3" label={{ value: "avg", position: "insideTopRight", fill: "#f97316", fontSize: 10 }} />
)}
{viewMode === "load" && (
<Line yAxisId="left" type="monotone" dataKey="load" stroke="#f97316" strokeWidth={2} dot={false} activeDot={{ r: 4 }} connectNulls={false} name="GB / Watch Hr" />
)}
{viewMode === "metrics" && showRequests && (
<Line yAxisId={normalized ? "left" : "counts"} type="monotone" dataKey="requests" stroke="#3b82f6" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Requests" />
)}
{viewMode === "metrics" && showStorage && (
<Line yAxisId={normalized ? "left" : "gb"} type="monotone" dataKey="gb" stroke="#facc15" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Storage (GB)" />
)}
{viewMode === "metrics" && hasWatch && showWatchHours && (
<Line yAxisId={normalized ? "left" : "counts"} type="monotone" dataKey="watchHours" stroke="#4ade80" strokeWidth={2} dot={false} activeDot={{ r: 4 }} name="Watch Hours" />
)}
</LineChart>
</ResponsiveContainer>
{chartData.every((p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0) && (
<p className="text-center text-sm text-slate-600 py-2">No activity in this period</p>
)}
</div>
{/* Request history */}
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">
Request History
<span className="ml-2 text-xs font-normal text-slate-600">{sortedByDate.length} total</span>
</h2>
{sortedByDate.length === 0 ? (
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 px-5 py-6 text-center text-sm text-slate-600">
No requests yet
</div>
) : (
<>
<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 className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Title</th>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Type</th>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Status</th>
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Size</th>
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Requested</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{displayedRequests.map((req) => (
<tr key={req.id} className="bg-slate-900 hover:bg-slate-800/50 transition-colors">
<td className="py-2.5 px-4 font-medium text-white max-w-xs truncate">{req.title}</td>
<td className="py-2.5 px-4">
<span className={`rounded border px-1.5 py-0.5 text-xs ${req.type === "movie" ? "border-purple-700/40 bg-purple-500/10 text-purple-400" : "border-cyan-700/40 bg-cyan-500/10 text-cyan-400"}`}>
{req.type === "movie" ? "Movie" : "TV"}
</span>
</td>
<td className="py-2.5 px-4">
<StatusBadge status={req.status} />
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs tabular-nums text-slate-400">
{req.sizeGB > 0 ? formatGB(req.sizeGB) : <span className="text-slate-700"></span>}
</td>
<td className="py-2.5 px-4 text-right text-xs text-slate-600">
{new Date(req.createdAt).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" })}
</td>
</tr>
))}
</tbody>
</table>
</div>
{sortedByDate.length > 20 && (
<button
onClick={() => setShowAll((v) => !v)}
className="cursor-pointer w-full rounded-lg border border-slate-700/40 bg-slate-800/20 py-2 text-sm text-slate-500 hover:text-slate-300 hover:bg-slate-800/40 transition-colors"
>
{showAll ? "Show recent 20" : `Show all ${sortedByDate.length} requests`}
</button>
)}
</>
)}
</div>
{/* Open alerts */}
{openAlerts.length > 0 && (
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">
Open Alerts
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-yellow-500 text-black text-xs font-bold px-1.5 py-0.5 leading-none">
{openAlerts.length}
</span>
</h2>
<div className="space-y-2">
{openAlerts.map((alert) => (
<Link
key={alert.id}
href={`/alerts/${alert.id}`}
className={`block rounded-xl border border-slate-700/60 border-l-4 px-4 py-3.5 transition-colors hover:bg-slate-800/60 cursor-pointer ${SEV_COLOR[alert.severity]}`}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<span className={`text-xs font-semibold uppercase tracking-wider ${SEV_TEXT[alert.severity]}`}>
{alert.category}
</span>
<p className="mt-0.5 text-sm font-medium text-white leading-snug">{alert.title}</p>
<p className="mt-1 text-xs text-slate-500 line-clamp-2">{alert.description}</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4 shrink-0 text-slate-600 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</div>
</Link>
))}
</div>
</div>
)}
</main>
);
}
+10
View File
@@ -0,0 +1,10 @@
import UserDetail from "./UserDetail";
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <UserDetail userId={Number(id)} />;
}
+10 -2
View File
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { UserStat } from "@/lib/types";
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
@@ -124,8 +125,15 @@ export default function LeaderboardTable({
{/* 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>
<Link
href={`/users/${user.userId}`}
className="group/name cursor-pointer"
>
<div className="font-medium text-white leading-snug group-hover/name:text-yellow-300 transition-colors">
{user.displayName}
</div>
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
</Link>
</td>
{/* Requests */}
+14
View File
@@ -0,0 +1,14 @@
/**
* Next.js instrumentation hook — runs once when the server starts.
* Used to kick off the background stats poller so alerts are generated
* and Discord notifications are sent even when no client is connected.
*/
export async function register() {
// Only run in the Node.js runtime (not Edge).
// better-sqlite3 and the fetch clients require Node.js APIs.
if (process.env.NEXT_RUNTIME === "edge") return;
const { startBackgroundPoller } = await import("@/lib/statsBuilder");
startBackgroundPoller();
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Shared stats-build logic and server-side SWR cache.
*
* Imported by both the /api/stats route handler (for on-demand fetches) and
* instrumentation.ts (for the background poller that runs independent of
* client activity).
*/
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, MediaEntry, OverseerrRequest, TautulliUser } from "@/lib/types";
const BATCH_SIZE = 5;
const STALE_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5 * 60 * 1000;
// ── Encapsulated cache ────────────────────────────────────────────────────────
let cache: { stats: DashboardStats; at: number } | null = null;
let refreshing = false;
/** Raw data cached alongside stats for use by the user-page API route. */
export interface RawCache {
radarrMap: Map<number, MediaEntry>;
sonarrMap: Map<number, MediaEntry>;
allRequests: Map<number, OverseerrRequest[]>;
tautulliMap: Map<string, TautulliUser> | null;
}
let rawCache: RawCache | null = null;
export function getRawCache(): RawCache | null {
return rawCache;
}
// ── Core build function ───────────────────────────────────────────────────────
async function buildStats(): Promise<DashboardStats> {
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
buildRadarrMap(),
buildSonarrMap(),
fetchAllUsers(),
buildTautulliMap(),
]);
const allRequests = new Map<number, OverseerrRequest[]>();
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(chunk.map((u) => fetchUserRequests(u.id)));
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap };
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
// ── Public API used by the route handler ─────────────────────────────────────
/**
* Returns stats, using the in-process cache.
* - force=true: always fetches fresh data and waits for it
* - force=false: returns cache immediately; if stale, kicks a background refresh
*/
export async function getStats(force = false): Promise<DashboardStats> {
if (force || !cache) {
const stats = await buildStats();
cache = { stats, at: Date.now() };
return stats;
}
const age = Date.now() - cache.at;
if (age > STALE_MS && !refreshing) {
refreshing = true;
buildStats()
.then((stats) => { cache = { stats, at: Date.now() }; })
.catch(() => {})
.finally(() => { refreshing = false; });
}
return cache.stats;
}
// ── Background poller ─────────────────────────────────────────────────────────
async function poll() {
if (refreshing) return;
refreshing = true;
try {
const stats = await buildStats();
cache = { stats, at: Date.now() };
console.log("[poller] Stats refreshed at", new Date().toISOString());
} catch (err) {
console.error("[poller] Refresh failed:", err);
} finally {
refreshing = false;
}
}
/**
* Starts the background poller. Called once from instrumentation.ts on server
* startup. Runs an initial fetch immediately, then repeats every 5 minutes.
*/
export function startBackgroundPoller() {
console.log("[poller] Starting (interval: 5 min)");
poll(); // immediate first run — no waiting for a client request
setInterval(poll, POLL_INTERVAL_MS);
}
+71 -1
View File
@@ -1,7 +1,8 @@
import { TautulliUser } from "@/lib/types";
import { TautulliUser, WatchDataPoint } from "@/lib/types";
import { getSettings } from "@/lib/settings";
interface TautulliRow {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -48,6 +49,7 @@ export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | nu
for (const row of json.response.data.data) {
const user: TautulliUser = {
user_id: row.user_id ?? 0,
friendly_name: row.friendly_name,
email: row.email ?? "",
plays: row.plays ?? 0,
@@ -78,3 +80,71 @@ export function lookupTautulliUser(
null
);
}
interface TautulliHistoryRow {
date: number; // unix timestamp (session start)
duration: number; // seconds watched
}
interface TautulliHistoryResponse {
response: {
result: string;
data: {
recordsFiltered: number;
recordsTotal: number;
data: TautulliHistoryRow[];
};
};
}
/**
* Fetches individual session history for a Tautulli user and aggregates by day.
* Returns an empty array if Tautulli is not configured or the call fails.
*/
export async function fetchUserWatchHistory(
tautulliUserId: number
): Promise<WatchDataPoint[]> {
const { tautulli } = getSettings();
const { url, apiKey } = tautulli;
if (!url || !apiKey || !tautulliUserId) return [];
let res: Response;
try {
res = await fetch(
`${url}/api/v2?apikey=${apiKey}&cmd=get_history&user_id=${tautulliUserId}&length=10000&order_column=date&order_dir=asc`,
{ cache: "no-store" }
);
} catch {
return [];
}
if (!res.ok) return [];
let json: TautulliHistoryResponse;
try {
json = await res.json() as TautulliHistoryResponse;
} catch {
return [];
}
if (json.response?.result !== "success") return [];
const byDate = new Map<string, { plays: number; durationSeconds: number }>();
for (const row of json.response.data.data ?? []) {
if (!row.date) continue;
const date = new Date(row.date * 1000).toISOString().slice(0, 10);
const existing = byDate.get(date) ?? { plays: 0, durationSeconds: 0 };
existing.plays += 1;
existing.durationSeconds += row.duration ?? 0;
byDate.set(date, existing);
}
return Array.from(byDate.entries())
.map(([date, { plays, durationSeconds }]) => ({
date,
plays,
durationHours: Math.round((durationSeconds / 3600) * 10) / 10,
}))
.sort((a, b) => a.date.localeCompare(b.date));
}
+30 -1
View File
@@ -11,7 +11,7 @@ export interface OverseerrUser {
export interface OverseerrRequest {
id: number;
type: "movie" | "tv";
status: number; // 1=pending, 2=approved, 3=declined, 4=available
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string; // ISO timestamp
media: {
tmdbId: number;
@@ -42,6 +42,7 @@ export interface SonarrSeries {
}
export interface TautulliUser {
user_id: number;
friendly_name: string;
email: string;
plays: number;
@@ -130,6 +131,34 @@ export interface AlertComment {
export type AlertCloseReason = "manual" | "resolved";
// ─── User page ────────────────────────────────────────────────────────────────
/** A single Overseerr request enriched with resolved media title and size */
export interface EnrichedRequest {
id: number;
type: "movie" | "tv";
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string;
mediaId: number; // tmdbId for movies, tvdbId for TV
title: string;
sizeOnDisk: number; // bytes
sizeGB: number;
}
/** One day of watch activity from Tautulli */
export interface WatchDataPoint {
date: string; // YYYY-MM-DD
plays: number;
durationHours: number;
}
export interface UserPageData {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
}
/** Full persisted alert returned by the API */
export interface Alert extends AlertCandidate {
id: number; // auto-increment DB id (used in URLs)