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>
This commit is contained in:
2026-04-19 10:00:21 -04:00
parent b2c1642065
commit 74588e50f6
18 changed files with 994 additions and 664 deletions
+74 -51
View File
@@ -1,8 +1,63 @@
import { getStats, getRawCache } from "@/lib/statsBuilder";
import { lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
import { getStats, getRawCache, RawCache } from "@/lib/statsBuilder";
import { getAllAlerts } from "@/lib/db";
import { bytesToGB } from "@/lib/aggregate";
import { EnrichedRequest, UserPageData } from "@/lib/types";
import { enrichRequests } from "@/lib/enrichRequests";
import { getSettings } from "@/lib/settings";
import { buildUserChartPoints, TIMEFRAMES } from "@/lib/userChart";
import {
ServerAverages,
Timeframe,
TimeframeServerAverages,
UserPageData,
UserStat,
} from "@/lib/types";
function meanOrNull(values: (number | null)[]): number | null {
const clean = values.filter((v): v is number => v !== null);
if (clean.length === 0) return null;
return Math.round((clean.reduce((s, v) => s + v, 0) / clean.length) * 10) / 10;
}
function computeServerAverages(users: UserStat[], raw: RawCache): ServerAverages {
const result = {} as Record<Timeframe, TimeframeServerAverages>;
for (const tf of TIMEFRAMES) {
const userPoints = users.map((u) => {
const reqs = enrichRequests(
raw.allRequests.get(u.userId) ?? [],
raw.radarrMap,
raw.sonarrMap
);
const wh = raw.watchHistoryMap.get(u.userId) ?? [];
return { user: u, points: buildUserChartPoints(reqs, wh, tf) };
});
// Per-user per-bucket means, then mean across users.
const userMeans = userPoints.map(({ user, points }) => ({
hasTautulli: user.plays !== null,
requests: meanOrNull(points.map((p) => p.requests)),
gb: meanOrNull(points.map((p) => p.gb)),
watchHours: meanOrNull(points.map((p) => p.watchHours)),
load: meanOrNull(points.map((p) => p.load)),
}));
const tautulliMeans = userMeans.filter((m) => m.hasTautulli);
result[tf] = {
requests: meanOrNull(userMeans.map((m) => m.requests)) ?? 0,
gb: meanOrNull(userMeans.map((m) => m.gb)) ?? 0,
watchHours:
tautulliMeans.length > 0
? meanOrNull(tautulliMeans.map((m) => m.watchHours))
: null,
load:
tautulliMeans.length > 0
? meanOrNull(tautulliMeans.map((m) => m.load))
: null,
};
}
return result;
}
export async function GET(
_req: Request,
@@ -15,73 +70,41 @@ export async function GET(
}
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;
if (!raw) {
return Response.json({ error: "Raw cache unavailable" }, { status: 503 });
}
const watchHistory = tautulliUserId
? await fetchUserWatchHistory(tautulliUserId)
: [];
const seerrBaseUrl = getSettings().seerr.url || undefined;
const enrichedRequests = enrichRequests(
raw.allRequests.get(userId) ?? [],
raw.radarrMap,
raw.sonarrMap,
seerrBaseUrl
);
// Open alerts involving this user
const allAlerts = getAllAlerts();
const openAlerts = allAlerts.filter(
const watchHistory = raw.watchHistoryMap.get(userId) ?? [];
const openAlerts = getAllAlerts().filter(
(a) =>
a.status === "open" &&
(a.userId === userId || a.requesterIds?.includes(userId))
);
const serverAverages = computeServerAverages(stats.users, raw);
const result: UserPageData = {
stat,
enrichedRequests,
watchHistory,
openAlerts,
serverAverages,
};
return Response.json(result);
+1 -10
View File
@@ -2,6 +2,7 @@
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";
@@ -11,16 +12,6 @@ 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);
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { useMemo, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import {
EnrichedRequest,
WatchDataPoint,
UserStat,
Timeframe,
ServerAverages,
} from "@/lib/types";
import {
buildUserChartPoints,
ChartPoint,
TIMEFRAMES,
} from "@/lib/userChart";
import { formatGB } from "@/lib/format";
type MetricKey = "requests" | "gb" | "watchHours" | "load";
interface MetricConfig {
key: MetricKey;
label: string;
color: string;
/** how the visible data values compare against ChartPoint fields */
dataKey: keyof ChartPoint;
/** show the line only when Tautulli is configured */
tautulliOnly?: boolean;
/** formats the raw numeric value for tooltip and reference-line labels */
format: (v: number) => string;
/** formats the Y axis tick */
tickFormat: (v: number) => string;
/** reserved Y axis width */
yWidth: number;
}
const METRICS: Record<MetricKey, MetricConfig> = {
requests: {
key: "requests",
label: "Requests",
color: "#3b82f6",
dataKey: "requests",
format: (v) => `${v}`,
tickFormat: (v) => `${v}`,
yWidth: 32,
},
gb: {
key: "gb",
label: "Storage",
color: "#facc15",
dataKey: "gb",
format: (v) => formatGB(v),
tickFormat: (v) => `${v}G`,
yWidth: 40,
},
watchHours: {
key: "watchHours",
label: "Watch Hours",
color: "#4ade80",
dataKey: "watchHours",
tautulliOnly: true,
format: (v) => `${v}h`,
tickFormat: (v) => `${v}h`,
yWidth: 38,
},
load: {
key: "load",
label: "Storage Load",
color: "#f97316",
dataKey: "load",
tautulliOnly: true,
format: (v) => `${v} GB/hr`,
tickFormat: (v) => `${v}G/h`,
yWidth: 44,
},
};
const SERVER_COLOR = "#94a3b8"; // slate-400
export default function UserActivityChart({
stat,
enrichedRequests,
watchHistory,
serverAverages,
}: {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[];
serverAverages: ServerAverages;
}) {
const hasTautulli = stat.plays !== null;
const [tf, setTf] = useState<Timeframe>("1M");
const [metric, setMetric] = useState<MetricKey>("gb");
const points = useMemo(
() => buildUserChartPoints(enrichedRequests, watchHistory, tf),
[enrichedRequests, watchHistory, tf]
);
const cfg = METRICS[metric];
const availableMetrics: MetricKey[] = (["requests", "gb", "watchHours", "load"] as MetricKey[])
.filter((k) => hasTautulli || !METRICS[k].tautulliOnly);
// User avg = mean of visible buckets (flows). For load, use the overall scalar.
const userAvg = useMemo(() => {
if (metric === "load") return stat.loadGBPerHour;
const vals = points
.map((p) => p[cfg.dataKey])
.filter((v): v is number => typeof v === "number");
if (vals.length === 0) return null;
return Math.round((vals.reduce((s, v) => s + v, 0) / vals.length) * 10) / 10;
}, [metric, points, cfg.dataKey, stat.loadGBPerHour]);
const serverAvg = serverAverages[tf][metric];
const isEmpty = points.every(
(p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0
);
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1">
{availableMetrics.map((k) => {
const m = METRICS[k];
const active = k === metric;
return (
<button
key={k}
onClick={() => setMetric(k)}
className={`cursor-pointer rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
active
? "text-white"
: "text-slate-500 hover:text-slate-300"
}`}
style={active ? { backgroundColor: `${m.color}33` } : undefined}
>
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: m.color }} />
{m.label}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1">
{TIMEFRAMES.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-slate-700 text-white"
: "text-slate-500 hover:text-slate-300"
}`}
>
{t}
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={points} margin={{ top: 4, right: 8, left: -8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis
dataKey="label"
tick={{ fill: "#475569", fontSize: 11 }}
axisLine={{ stroke: "#334155" }}
tickLine={false}
/>
<YAxis
tick={{ fill: "#475569", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={cfg.tickFormat}
width={cfg.yWidth}
/>
<Tooltip
contentStyle={{
background: "#0f172a",
border: "1px solid #334155",
borderRadius: "8px",
fontSize: "12px",
}}
labelStyle={{ color: "#94a3b8", marginBottom: "4px" }}
itemStyle={{ color: "#e2e8f0" }}
formatter={(value) => {
const num = typeof value === "number" ? value : Number(value);
return [cfg.format(num), cfg.label];
}}
/>
{userAvg !== null && (
<ReferenceLine
y={userAvg}
stroke={cfg.color}
strokeOpacity={0.55}
strokeDasharray="4 3"
label={{
value: "you",
position: "insideTopRight",
fill: cfg.color,
fontSize: 10,
}}
/>
)}
{serverAvg !== null && (
<ReferenceLine
y={serverAvg}
stroke={SERVER_COLOR}
strokeOpacity={0.45}
strokeDasharray="2 4"
label={{
value: "all users",
position: "insideBottomRight",
fill: SERVER_COLOR,
fontSize: 10,
}}
/>
)}
<Line
type="monotone"
dataKey={cfg.dataKey as string}
stroke={cfg.color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
name={cfg.label}
/>
</LineChart>
</ResponsiveContainer>
{isEmpty && (
<p className="text-center text-sm text-slate-600 py-2">
No activity in this period
</p>
)}
</div>
);
}
+18 -555
View File
@@ -1,233 +1,18 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { useEffect, useState } 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 ─────────────────────────────────────────────────────────────
import { UserPageData } from "@/lib/types";
import UserHeader from "./UserHeader";
import UserStatCards from "./UserStatCards";
import UserActivityChart from "./UserActivityChart";
import UserRequestHistory from "./UserRequestHistory";
import UserOpenAlerts from "./UserOpenAlerts";
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);
@@ -242,28 +27,6 @@ export default function UserDetail({ userId }: { userId: number }) {
.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">
@@ -295,320 +58,20 @@ export default function UserDetail({ userId }: { userId: number }) {
);
}
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";
const { stat, enrichedRequests, watchHistory, openAlerts, serverAverages } = data;
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>
)}
<UserHeader stat={stat} />
<UserStatCards stat={stat} />
<UserActivityChart
stat={stat}
enrichedRequests={enrichedRequests}
watchHistory={watchHistory}
serverAverages={serverAverages}
/>
<UserRequestHistory requests={enrichedRequests} />
<UserOpenAlerts alerts={openAlerts} />
</main>
);
}
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { UserStat } from "@/lib/types";
import { unixTimeAgo } from "@/lib/format";
export default function UserHeader({ stat }: { stat: UserStat }) {
const hasTautulli = stat.plays !== null;
return (
<>
<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="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>
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { Alert, AlertSeverity } from "@/lib/types";
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",
};
export default function UserOpenAlerts({ alerts }: { alerts: Alert[] }) {
if (alerts.length === 0) return null;
return (
<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">
{alerts.length}
</span>
</h2>
<div className="space-y-2">
{alerts.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>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useMemo, useState } from "react";
import { EnrichedRequest } from "@/lib/types";
import { formatGB } from "@/lib/format";
import StatusBadge from "@/components/StatusBadge";
const INITIAL_COUNT = 20;
export default function UserRequestHistory({
requests,
}: {
requests: EnrichedRequest[];
}) {
const [showAll, setShowAll] = useState(false);
const sorted = useMemo(
() =>
[...requests].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
),
[requests]
);
const visible = showAll ? sorted : sorted.slice(0, INITIAL_COUNT);
return (
<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">
{sorted.length} total
</span>
</h2>
{sorted.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">
{visible.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.seerrUrl ? (
<a
href={req.seerrUrl}
target="_blank"
rel="noreferrer"
className="hover:text-yellow-400 transition-colors"
>
{req.title}
</a>
) : (
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>
{sorted.length > INITIAL_COUNT && (
<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 ${INITIAL_COUNT}`
: `Show all ${sorted.length} requests`}
</button>
)}
</>
)}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { UserStat } from "@/lib/types";
import { formatGB, formatHours } from "@/lib/format";
import RankChip from "@/components/RankChip";
type Accent = "yellow" | "green" | "orange" | null;
const ICONS = {
requests: "M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776",
storage: "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 2.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125m16.5 2.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125",
avg: "M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.032-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z",
plays: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM10.5 8.25v7.5l6-3.75-6-3.75Z",
watch: "M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75.125v-6.375c0-.621.504-1.125 1.125-1.125H4.5m0-3.375v3.375m0-3.375h12M4.5 7.5H18a.75.75 0 0 1 .75.75v9.375c0 .621-.504 1.125-1.125 1.125h-1.5m-13.5 0h13.5m0 0v-3.375m0 3.375v-9.375c0-.621.504-1.125 1.125-1.125H21",
load: "m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z",
};
type IconKey = keyof typeof ICONS;
const ACCENT_CLASSES: Record<Exclude<Accent, null>, { box: string; value: string; icon: string }> = {
yellow: {
box: "bg-yellow-950/30 border-yellow-700/50",
value: "text-yellow-300",
icon: "text-yellow-500/70",
},
green: {
box: "bg-green-950/30 border-green-800/50",
value: "text-white",
icon: "text-green-500/70",
},
orange: {
box: "bg-orange-950/30 border-orange-800/50",
value: "text-orange-300",
icon: "text-orange-500/70",
},
};
const DEFAULT_CLASSES = {
box: "bg-slate-800/60 border-slate-700/60",
value: "text-white",
icon: "text-slate-600",
};
function Card({
label,
value,
icon,
rank,
total,
accent,
}: {
label: string;
value: string;
icon: IconKey;
rank?: number | null;
total?: number;
accent?: Accent;
}) {
const c = accent ? ACCENT_CLASSES[accent] : DEFAULT_CLASSES;
return (
<div className={`rounded-xl border p-5 flex flex-col gap-2 transition-colors ${c.box}`}>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-widest text-slate-500">
{label}
</span>
<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 ${c.icon}`}
>
<path strokeLinecap="round" strokeLinejoin="round" d={ICONS[icon]} />
</svg>
</div>
<span className={`text-3xl font-bold tabular-nums ${c.value}`}>{value}</span>
{rank !== undefined && total !== undefined && (
<RankChip rank={rank ?? null} total={total} />
)}
</div>
);
}
export default function UserStatCards({ stat }: { stat: UserStat }) {
const hasTautulli = stat.plays !== null;
const total = stat.totalUsers;
const grid = hasTautulli
? "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"
: "grid grid-cols-2 gap-3 sm:grid-cols-3";
return (
<div className={grid}>
<Card
label="Requests"
value={stat.requestCount.toLocaleString()}
icon="requests"
rank={stat.requestRank}
total={total}
/>
<Card
label="Storage"
value={formatGB(stat.totalGB)}
icon="storage"
rank={stat.storageRank}
total={total}
accent="yellow"
/>
<Card
label="Avg / Request"
value={stat.requestCount > 0 ? formatGB(stat.avgGB) : "—"}
icon="avg"
/>
{hasTautulli && (
<Card
label="Plays"
value={(stat.plays ?? 0).toLocaleString()}
icon="plays"
rank={stat.playsRank}
total={total}
/>
)}
{hasTautulli && (
<Card
label="Watch Time"
value={
stat.watchHours !== null && stat.watchHours > 0
? formatHours(stat.watchHours)
: "0h"
}
icon="watch"
rank={stat.watchRank}
total={total}
accent="green"
/>
)}
{hasTautulli && (
<Card
label="Storage Load"
value={
stat.loadGBPerHour !== null ? `${stat.loadGBPerHour.toFixed(1)} GB/hr` : "—"
}
icon="load"
rank={stat.loadRank}
total={total}
accent="orange"
/>
)}
</div>
);
}
+6 -22
View File
@@ -3,6 +3,8 @@
import { useState } from "react";
import Link from "next/link";
import { UserStat } from "@/lib/types";
import { formatGB, formatHours } from "@/lib/format";
import RankChip from "@/components/RankChip";
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
@@ -11,24 +13,6 @@ interface LeaderboardTableProps {
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">
@@ -142,7 +126,7 @@ export default function LeaderboardTable({
{user.requestCount.toLocaleString()}
</div>
<div className="mt-0.5 flex justify-end">
<Rank rank={user.requestRank} total={total} />
<RankChip rank={user.requestRank} total={total} />
</div>
</td>
@@ -152,7 +136,7 @@ export default function LeaderboardTable({
{formatGB(user.totalGB)}
</div>
<div className="mt-0.5 flex justify-end">
<Rank rank={user.storageRank} total={total} />
<RankChip rank={user.storageRank} total={total} />
</div>
</td>
@@ -168,7 +152,7 @@ export default function LeaderboardTable({
{user.plays !== null ? user.plays.toLocaleString() : "—"}
</div>
<div className="mt-0.5 flex justify-end">
<Rank rank={user.playsRank} total={total} />
<RankChip rank={user.playsRank} total={total} />
</div>
</td>
)}
@@ -184,7 +168,7 @@ export default function LeaderboardTable({
: "—"}
</div>
<div className="mt-0.5 flex justify-end">
<Rank rank={user.watchRank} total={total} />
<RankChip rank={user.watchRank} total={total} />
</div>
</td>
)}
+15
View File
@@ -0,0 +1,15 @@
export default function RankChip({
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>
);
}
+29
View File
@@ -0,0 +1,29 @@
// Overseerr media.status codes:
// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available
const LABEL: Record<number, string> = {
1: "Unknown",
2: "Pending",
3: "Processing",
4: "Partial",
5: "Available",
};
const 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",
};
export default function StatusBadge({ status }: { status: number }) {
return (
<span
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${
COLOR[status] ?? "bg-slate-700 text-slate-400 border-slate-600"
}`}
>
{LABEL[status] ?? `Status ${status}`}
</span>
);
}
+3 -11
View File
@@ -1,3 +1,5 @@
import { formatGB, formatHours } from "@/lib/format";
interface SummaryCardsProps {
totalUsers: number;
totalRequests: number;
@@ -7,16 +9,6 @@ interface SummaryCardsProps {
onAlertsClick?: () => void;
}
function formatStorage(gb: number): string {
if (gb >= 1000) return `${(gb / 1000).toFixed(1)} TB`;
return `${gb.toFixed(1)} GB`;
}
function formatHours(h: number): string {
if (h >= 1000) return `${(h / 1000).toFixed(1)}k hrs`;
return `${h.toFixed(0)} hrs`;
}
// Heroicons outline paths
const ICONS = {
users: "M15 19.128a9.38 9.38 0 0 0 2.625.372 9.337 9.337 0 0 0 4.121-.952 4.125 4.125 0 0 0-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 0 1 8.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0 1 11.964-3.07M12 6.375a3.375 3.375 0 1 1-6.75 0 3.375 3.375 0 0 1 6.75 0Zm8.25 2.25a2.625 2.625 0 1 1-5.25 0 2.625 2.625 0 0 1 5.25 0Z",
@@ -106,7 +98,7 @@ export default function SummaryCards({
>
<Card label="Users" value={totalUsers.toLocaleString()} icon="users" />
<Card label="Requests" value={totalRequests.toLocaleString()} icon="requests" />
<Card label="Storage" value={formatStorage(totalStorageGB)} icon="storage" />
<Card label="Storage" value={formatGB(totalStorageGB)} icon="storage" />
{totalWatchHours !== null && (
<Card label="Watch Time" value={formatHours(totalWatchHours)} icon="watch" accent="green" />
)}
+21 -12
View File
@@ -98,25 +98,34 @@ export function computeStats(
)
: null;
const userStats: UserStat[] = rawStats.map((raw) => {
// First pass: compute totalGB, avgGB, and loadGBPerHour (needs totalGB)
const enriched = rawStats.map((raw) => {
const totalGB = bytesToGB(raw.totalBytes);
const avgGB =
raw.requestCount > 0
? Math.round((totalGB / raw.requestCount) * 10) / 10
: 0;
return {
...raw,
totalGB,
avgGB,
storageRank: storageRanks.get(raw.userId) ?? totalUsers,
requestRank: requestRanks.get(raw.userId) ?? totalUsers,
playsRank: playsRanks?.get(raw.userId) ?? null,
watchRank: watchRanks?.get(raw.userId) ?? null,
totalUsers,
};
const loadGBPerHour =
hasTautulli && raw.watchHours !== null && raw.watchHours > 0
? Math.round((totalGB / raw.watchHours) * 10) / 10
: null;
return { ...raw, totalGB, avgGB, loadGBPerHour };
});
const loadRanks = hasTautulli
? computeRanks(enriched.map((r) => ({ userId: r.userId, value: r.loadGBPerHour })))
: null;
const userStats: UserStat[] = enriched.map((raw) => ({
...raw,
storageRank: storageRanks.get(raw.userId) ?? totalUsers,
requestRank: requestRanks.get(raw.userId) ?? totalUsers,
playsRank: playsRanks?.get(raw.userId) ?? null,
watchRank: watchRanks?.get(raw.userId) ?? null,
loadRank: loadRanks?.get(raw.userId) ?? null,
totalUsers,
}));
// Generate alert candidates and persist to DB
const candidates = generateAlertCandidates(
userStats,
+46
View File
@@ -0,0 +1,46 @@
import { OverseerrRequest, MediaEntry, EnrichedRequest } from "@/lib/types";
import { bytesToGB } from "@/lib/aggregate";
export function enrichRequests(
userRequests: OverseerrRequest[],
radarrMap: Map<number, MediaEntry>,
sonarrMap: Map<number, MediaEntry>,
seerrBaseUrl?: string
): EnrichedRequest[] {
return userRequests.map((req) => {
let sizeOnDisk = 0;
let title = req.media.title ?? "";
if (req.type === "movie") {
const entry = 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 = 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}`;
}
const seerrUrl = seerrBaseUrl
? `${seerrBaseUrl}/${req.type === "movie" ? "movie" : "tv"}/${req.media.tmdbId}`
: undefined;
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),
seerrUrl,
};
});
}
+27
View File
@@ -0,0 +1,27 @@
export function formatGB(gb: number): string {
if (gb >= 1000) return `${(gb / 1000).toFixed(2)} TB`;
return `${gb.toFixed(1)} GB`;
}
export function formatHours(h: number): string {
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
return `${h.toFixed(0)}h`;
}
export 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" });
}
export function unixTimeAgo(ts: number | null): string {
if (ts === null) return "Never";
return timeAgo(new Date(ts * 1000).toISOString());
}
+25 -3
View File
@@ -9,9 +9,15 @@
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap } from "@/lib/tautulli";
import { buildTautulliMap, lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { DashboardStats, MediaEntry, OverseerrRequest, TautulliUser } from "@/lib/types";
import {
DashboardStats,
MediaEntry,
OverseerrRequest,
TautulliUser,
WatchDataPoint,
} from "@/lib/types";
const BATCH_SIZE = 5;
const STALE_MS = 5 * 60 * 1000;
@@ -28,6 +34,8 @@ export interface RawCache {
sonarrMap: Map<number, MediaEntry>;
allRequests: Map<number, OverseerrRequest[]>;
tautulliMap: Map<string, TautulliUser> | null;
/** Per-user daily watch history, keyed by Overseerr user id. Empty map if Tautulli isn't configured. */
watchHistoryMap: Map<number, WatchDataPoint[]>;
}
let rawCache: RawCache | null = null;
@@ -53,7 +61,21 @@ async function buildStats(): Promise<DashboardStats> {
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap };
const watchHistoryMap = new Map<number, WatchDataPoint[]>();
if (tautulliMap) {
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(async (u) => {
const tu = lookupTautulliUser(tautulliMap, u.email, u.displayName);
return tu ? fetchUserWatchHistory(tu.user_id) : [];
})
);
chunk.forEach((u, idx) => watchHistoryMap.set(u.id, results[idx]));
}
}
rawCache = { radarrMap, sonarrMap, allRequests, tautulliMap, watchHistoryMap };
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
}
+18
View File
@@ -78,11 +78,14 @@ export interface UserStat {
plays: number | null;
watchHours: number | null;
tautulliLastSeen: number | null; // unix timestamp (seconds), null if no Tautulli data
// GB requested ÷ hours watched. null when Tautulli is off or the user has no watch time.
loadGBPerHour: number | null;
// Per-metric ranks (1 = top user for that metric, null = Tautulli not available)
storageRank: number;
requestRank: number;
playsRank: number | null;
watchRank: number | null;
loadRank: number | null;
totalUsers: number;
}
@@ -143,6 +146,8 @@ export interface EnrichedRequest {
title: string;
sizeOnDisk: number; // bytes
sizeGB: number;
/** Deep link to the item's page in Seerr. Undefined if seerr.url isn't configured. */
seerrUrl?: string;
}
/** One day of watch activity from Tautulli */
@@ -152,11 +157,24 @@ export interface WatchDataPoint {
durationHours: number;
}
export type Timeframe = "1W" | "1M" | "3M" | "1Y";
/** Per-bucket means across all users for the given timeframe. */
export interface TimeframeServerAverages {
requests: number;
gb: number;
watchHours: number | null; // null if Tautulli is off
load: number | null; // null if Tautulli is off
}
export type ServerAverages = Record<Timeframe, TimeframeServerAverages>;
export interface UserPageData {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
serverAverages: ServerAverages;
}
/** Full persisted alert returned by the API */
+97
View File
@@ -0,0 +1,97 @@
import { EnrichedRequest, WatchDataPoint, Timeframe } from "@/lib/types";
export const TIMEFRAMES: Timeframe[] = ["1W", "1M", "3M", "1Y"];
export 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 },
};
export interface ChartPoint {
label: string;
requests: number;
gb: number;
watchHours: number;
/**
* Running cumulative GB ÷ cumulative watch hours as of the end of this bucket —
* including all history before the window. A new request bumps it up; more
* watching drags it back down. null until the user has any watch hours.
*/
load: number | null;
}
export function buildUserChartPoints(
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, watchHours: 0, load: null };
});
// Per-bucket flows: count requests / GB / watch hours that landed inside the bucket.
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].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10;
}
}
// Running load: sweep all requests / history (including before the window) and
// accumulate GB + hours up to the end of each bucket.
const reqSorted = [...enrichedRequests].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const watchSorted = [...watchHistory].sort((a, b) => a.date.localeCompare(b.date));
let reqIdx = 0;
let watchIdx = 0;
let cumGB = 0;
let cumHours = 0;
for (let i = 0; i < numBuckets; i++) {
const bucketEndMs = now - (numBuckets - 1 - i) * bucketDays * MS;
while (
reqIdx < reqSorted.length &&
new Date(reqSorted[reqIdx].createdAt).getTime() <= bucketEndMs
) {
cumGB += reqSorted[reqIdx].sizeGB;
reqIdx++;
}
while (
watchIdx < watchSorted.length &&
new Date(watchSorted[watchIdx].date + "T12:00:00").getTime() <= bucketEndMs
) {
cumHours += watchSorted[watchIdx].durationHours;
watchIdx++;
}
points[i].load = cumHours > 0 ? Math.round((cumGB / cumHours) * 10) / 10 : null;
}
return points;
}