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:
@@ -1,8 +1,63 @@
|
|||||||
import { getStats, getRawCache } from "@/lib/statsBuilder";
|
import { getStats, getRawCache, RawCache } from "@/lib/statsBuilder";
|
||||||
import { lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
|
|
||||||
import { getAllAlerts } from "@/lib/db";
|
import { getAllAlerts } from "@/lib/db";
|
||||||
import { bytesToGB } from "@/lib/aggregate";
|
import { enrichRequests } from "@/lib/enrichRequests";
|
||||||
import { EnrichedRequest, UserPageData } from "@/lib/types";
|
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(
|
export async function GET(
|
||||||
_req: Request,
|
_req: Request,
|
||||||
@@ -15,73 +70,41 @@ export async function GET(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Find the user in the cached stats (triggers a build if cache is cold)
|
|
||||||
const stats = await getStats();
|
const stats = await getStats();
|
||||||
const stat = stats.users.find((u) => u.userId === userId);
|
const stat = stats.users.find((u) => u.userId === userId);
|
||||||
if (!stat) {
|
if (!stat) {
|
||||||
return Response.json({ error: "User not found" }, { status: 404 });
|
return Response.json({ error: "User not found" }, { status: 404 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enrich requests with resolved title + size from cached media maps
|
|
||||||
const raw = getRawCache();
|
const raw = getRawCache();
|
||||||
const userRequests = raw?.allRequests.get(userId) ?? [];
|
if (!raw) {
|
||||||
|
return Response.json({ error: "Raw cache unavailable" }, { status: 503 });
|
||||||
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
|
const seerrBaseUrl = getSettings().seerr.url || undefined;
|
||||||
? await fetchUserWatchHistory(tautulliUserId)
|
const enrichedRequests = enrichRequests(
|
||||||
: [];
|
raw.allRequests.get(userId) ?? [],
|
||||||
|
raw.radarrMap,
|
||||||
|
raw.sonarrMap,
|
||||||
|
seerrBaseUrl
|
||||||
|
);
|
||||||
|
|
||||||
// Open alerts involving this user
|
const watchHistory = raw.watchHistoryMap.get(userId) ?? [];
|
||||||
const allAlerts = getAllAlerts();
|
|
||||||
const openAlerts = allAlerts.filter(
|
const openAlerts = getAllAlerts().filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.status === "open" &&
|
a.status === "open" &&
|
||||||
(a.userId === userId || a.requesterIds?.includes(userId))
|
(a.userId === userId || a.requesterIds?.includes(userId))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const serverAverages = computeServerAverages(stats.users, raw);
|
||||||
|
|
||||||
const result: UserPageData = {
|
const result: UserPageData = {
|
||||||
stat,
|
stat,
|
||||||
enrichedRequests,
|
enrichedRequests,
|
||||||
watchHistory,
|
watchHistory,
|
||||||
openAlerts,
|
openAlerts,
|
||||||
|
serverAverages,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Response.json(result);
|
return Response.json(result);
|
||||||
|
|||||||
+1
-10
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { DashboardStats } from "@/lib/types";
|
import { DashboardStats } from "@/lib/types";
|
||||||
|
import { timeAgo } from "@/lib/format";
|
||||||
import SummaryCards from "@/components/SummaryCards";
|
import SummaryCards from "@/components/SummaryCards";
|
||||||
import LeaderboardTable from "@/components/LeaderboardTable";
|
import LeaderboardTable from "@/components/LeaderboardTable";
|
||||||
import AlertsPanel from "@/components/AlertsPanel";
|
import AlertsPanel from "@/components/AlertsPanel";
|
||||||
@@ -11,16 +12,6 @@ import SettingsModal from "@/components/SettingsModal";
|
|||||||
type Tab = "leaderboard" | "alerts";
|
type Tab = "leaderboard" | "alerts";
|
||||||
const LS_KEY = "oversnitch_stats";
|
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() {
|
export default function Page() {
|
||||||
const [tab, setTab] = useState<Tab>("leaderboard");
|
const [tab, setTab] = useState<Tab>("leaderboard");
|
||||||
const [data, setData] = useState<DashboardStats | null>(null);
|
const [data, setData] = useState<DashboardStats | null>(null);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,233 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { UserPageData } from "@/lib/types";
|
||||||
LineChart,
|
import UserHeader from "./UserHeader";
|
||||||
Line,
|
import UserStatCards from "./UserStatCards";
|
||||||
XAxis,
|
import UserActivityChart from "./UserActivityChart";
|
||||||
YAxis,
|
import UserRequestHistory from "./UserRequestHistory";
|
||||||
CartesianGrid,
|
import UserOpenAlerts from "./UserOpenAlerts";
|
||||||
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 }) {
|
export default function UserDetail({ userId }: { userId: number }) {
|
||||||
const [data, setData] = useState<UserPageData | null>(null);
|
const [data, setData] = useState<UserPageData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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(() => {
|
useEffect(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -242,28 +27,6 @@ export default function UserDetail({ userId }: { userId: number }) {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [userId]);
|
}, [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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8">
|
<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 { stat, enrichedRequests, watchHistory, openAlerts, serverAverages } = 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 (
|
return (
|
||||||
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
|
||||||
|
<UserHeader stat={stat} />
|
||||||
{/* Back */}
|
<UserStatCards stat={stat} />
|
||||||
<Link href="/" className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer">
|
<UserActivityChart
|
||||||
<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">
|
stat={stat}
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
|
enrichedRequests={enrichedRequests}
|
||||||
</svg>
|
watchHistory={watchHistory}
|
||||||
All Users
|
serverAverages={serverAverages}
|
||||||
</Link>
|
/>
|
||||||
|
<UserRequestHistory requests={enrichedRequests} />
|
||||||
{/* Header */}
|
<UserOpenAlerts alerts={openAlerts} />
|
||||||
<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>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,8 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { UserStat } from "@/lib/types";
|
import { UserStat } from "@/lib/types";
|
||||||
|
import { formatGB, formatHours } from "@/lib/format";
|
||||||
|
import RankChip from "@/components/RankChip";
|
||||||
|
|
||||||
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
|
||||||
|
|
||||||
@@ -11,24 +13,6 @@ interface LeaderboardTableProps {
|
|||||||
hasTautulli: boolean;
|
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 }) {
|
function SortChevrons({ active, asc }: { active: boolean; asc: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className="ml-1 inline-flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
|
<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()}
|
{user.requestCount.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex justify-end">
|
<div className="mt-0.5 flex justify-end">
|
||||||
<Rank rank={user.requestRank} total={total} />
|
<RankChip rank={user.requestRank} total={total} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -152,7 +136,7 @@ export default function LeaderboardTable({
|
|||||||
{formatGB(user.totalGB)}
|
{formatGB(user.totalGB)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex justify-end">
|
<div className="mt-0.5 flex justify-end">
|
||||||
<Rank rank={user.storageRank} total={total} />
|
<RankChip rank={user.storageRank} total={total} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
@@ -168,7 +152,7 @@ export default function LeaderboardTable({
|
|||||||
{user.plays !== null ? user.plays.toLocaleString() : "—"}
|
{user.plays !== null ? user.plays.toLocaleString() : "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex justify-end">
|
<div className="mt-0.5 flex justify-end">
|
||||||
<Rank rank={user.playsRank} total={total} />
|
<RankChip rank={user.playsRank} total={total} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
@@ -184,7 +168,7 @@ export default function LeaderboardTable({
|
|||||||
: "—"}
|
: "—"}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 flex justify-end">
|
<div className="mt-0.5 flex justify-end">
|
||||||
<Rank rank={user.watchRank} total={total} />
|
<RankChip rank={user.watchRank} total={total} />
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { formatGB, formatHours } from "@/lib/format";
|
||||||
|
|
||||||
interface SummaryCardsProps {
|
interface SummaryCardsProps {
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
totalRequests: number;
|
totalRequests: number;
|
||||||
@@ -7,16 +9,6 @@ interface SummaryCardsProps {
|
|||||||
onAlertsClick?: () => void;
|
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
|
// Heroicons outline paths
|
||||||
const ICONS = {
|
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",
|
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="Users" value={totalUsers.toLocaleString()} icon="users" />
|
||||||
<Card label="Requests" value={totalRequests.toLocaleString()} icon="requests" />
|
<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 && (
|
{totalWatchHours !== null && (
|
||||||
<Card label="Watch Time" value={formatHours(totalWatchHours)} icon="watch" accent="green" />
|
<Card label="Watch Time" value={formatHours(totalWatchHours)} icon="watch" accent="green" />
|
||||||
)}
|
)}
|
||||||
|
|||||||
+21
-12
@@ -98,25 +98,34 @@ export function computeStats(
|
|||||||
)
|
)
|
||||||
: null;
|
: 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 totalGB = bytesToGB(raw.totalBytes);
|
||||||
const avgGB =
|
const avgGB =
|
||||||
raw.requestCount > 0
|
raw.requestCount > 0
|
||||||
? Math.round((totalGB / raw.requestCount) * 10) / 10
|
? Math.round((totalGB / raw.requestCount) * 10) / 10
|
||||||
: 0;
|
: 0;
|
||||||
|
const loadGBPerHour =
|
||||||
return {
|
hasTautulli && raw.watchHours !== null && raw.watchHours > 0
|
||||||
...raw,
|
? Math.round((totalGB / raw.watchHours) * 10) / 10
|
||||||
totalGB,
|
: null;
|
||||||
avgGB,
|
return { ...raw, totalGB, avgGB, loadGBPerHour };
|
||||||
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 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
|
// Generate alert candidates and persist to DB
|
||||||
const candidates = generateAlertCandidates(
|
const candidates = generateAlertCandidates(
|
||||||
userStats,
|
userStats,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
@@ -9,9 +9,15 @@
|
|||||||
import { buildRadarrMap } from "@/lib/radarr";
|
import { buildRadarrMap } from "@/lib/radarr";
|
||||||
import { buildSonarrMap } from "@/lib/sonarr";
|
import { buildSonarrMap } from "@/lib/sonarr";
|
||||||
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
|
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
|
||||||
import { buildTautulliMap } from "@/lib/tautulli";
|
import { buildTautulliMap, lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
|
||||||
import { computeStats } from "@/lib/aggregate";
|
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 BATCH_SIZE = 5;
|
||||||
const STALE_MS = 5 * 60 * 1000;
|
const STALE_MS = 5 * 60 * 1000;
|
||||||
@@ -28,6 +34,8 @@ export interface RawCache {
|
|||||||
sonarrMap: Map<number, MediaEntry>;
|
sonarrMap: Map<number, MediaEntry>;
|
||||||
allRequests: Map<number, OverseerrRequest[]>;
|
allRequests: Map<number, OverseerrRequest[]>;
|
||||||
tautulliMap: Map<string, TautulliUser> | null;
|
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;
|
let rawCache: RawCache | null = null;
|
||||||
@@ -53,7 +61,21 @@ async function buildStats(): Promise<DashboardStats> {
|
|||||||
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
|
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);
|
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,11 +78,14 @@ export interface UserStat {
|
|||||||
plays: number | null;
|
plays: number | null;
|
||||||
watchHours: number | null;
|
watchHours: number | null;
|
||||||
tautulliLastSeen: number | null; // unix timestamp (seconds), null if no Tautulli data
|
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)
|
// Per-metric ranks (1 = top user for that metric, null = Tautulli not available)
|
||||||
storageRank: number;
|
storageRank: number;
|
||||||
requestRank: number;
|
requestRank: number;
|
||||||
playsRank: number | null;
|
playsRank: number | null;
|
||||||
watchRank: number | null;
|
watchRank: number | null;
|
||||||
|
loadRank: number | null;
|
||||||
totalUsers: number;
|
totalUsers: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +146,8 @@ export interface EnrichedRequest {
|
|||||||
title: string;
|
title: string;
|
||||||
sizeOnDisk: number; // bytes
|
sizeOnDisk: number; // bytes
|
||||||
sizeGB: number;
|
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 */
|
/** One day of watch activity from Tautulli */
|
||||||
@@ -152,11 +157,24 @@ export interface WatchDataPoint {
|
|||||||
durationHours: number;
|
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 {
|
export interface UserPageData {
|
||||||
stat: UserStat;
|
stat: UserStat;
|
||||||
enrichedRequests: EnrichedRequest[];
|
enrichedRequests: EnrichedRequest[];
|
||||||
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
|
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
|
||||||
openAlerts: Alert[];
|
openAlerts: Alert[];
|
||||||
|
serverAverages: ServerAverages;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Full persisted alert returned by the API */
|
/** Full persisted alert returned by the API */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user