Build OverSnitch dashboard
Full implementation on top of the Next.js scaffold: - Leaderboard with per-user request count, storage, avg GB/req, and optional Tautulli watch stats (plays, watch hours), each with dense per-metric rank (#N/total) - SWR cache on /api/stats (5-min stale, force-refresh via button); client-side localStorage seed so the UI is instant on return visits - Alerting system: content-centric alerts (unfulfilled downloads, partial TV downloads, stale pending requests) and user-behavior alerts (ghost requester, low watch rate, declined streak) - Partial TV detection: flags ended series with <90% of episodes on disk - Alert persistence in data/alerts.json with open/closed state, auto-resolve when condition clears, manual close with per-category cooldown, and per-alert notes - Alert detail page rendered as a server component for instant load - Dark UI with Tailwind v4, severity-colored left borders, summary cards with icons, sortable leaderboard table Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
26
src/app/api/alerts/[id]/comments/route.ts
Normal file
26
src/app/api/alerts/[id]/comments/route.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { addComment } from "@/lib/db";
|
||||
|
||||
export async function POST(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
let body: { body: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!body.body?.trim()) {
|
||||
return Response.json({ error: "Comment body is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const comment = addComment(Number(id), body.body.trim());
|
||||
if (!comment) {
|
||||
return Response.json({ error: "Alert not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return Response.json(comment, { status: 201 });
|
||||
}
|
||||
42
src/app/api/alerts/[id]/route.ts
Normal file
42
src/app/api/alerts/[id]/route.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { getAlertById, closeAlert, reopenAlert } from "@/lib/db";
|
||||
|
||||
export async function GET(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const alert = getAlertById(Number(id));
|
||||
if (!alert) {
|
||||
return Response.json({ error: "Alert not found" }, { status: 404 });
|
||||
}
|
||||
return Response.json(alert);
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const numId = Number(id);
|
||||
|
||||
let body: { status: string };
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return Response.json({ error: "Invalid JSON" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (body.status === "closed") {
|
||||
const updated = closeAlert(numId);
|
||||
if (!updated) return Response.json({ error: "Alert not found" }, { status: 404 });
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
if (body.status === "open") {
|
||||
const updated = reopenAlert(numId);
|
||||
if (!updated) return Response.json({ error: "Alert not found" }, { status: 404 });
|
||||
return Response.json(updated);
|
||||
}
|
||||
|
||||
return Response.json({ error: "status must be 'open' or 'closed'" }, { status: 400 });
|
||||
}
|
||||
11
src/app/api/alerts/route.ts
Normal file
11
src/app/api/alerts/route.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { getAllAlerts } from "@/lib/db";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const alerts = getAllAlerts();
|
||||
return Response.json(alerts);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
65
src/app/api/stats/route.ts
Normal file
65
src/app/api/stats/route.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { buildRadarrMap } from "@/lib/radarr";
|
||||
import { buildSonarrMap } from "@/lib/sonarr";
|
||||
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
|
||||
import { buildTautulliMap } from "@/lib/tautulli";
|
||||
import { computeStats } from "@/lib/aggregate";
|
||||
import { DashboardStats, OverseerrRequest } from "@/lib/types";
|
||||
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
// ── Server-side SWR cache ────────────────────────────────────────────────────
|
||||
// Persists in the Node.js process between requests.
|
||||
// Background-refreshes after STALE_MS so reads always return instantly.
|
||||
|
||||
const STALE_MS = 5 * 60 * 1000; // start background refresh after 5 min
|
||||
let cache: { stats: DashboardStats; at: number } | null = null;
|
||||
let refreshing = false;
|
||||
|
||||
async function buildStats(): Promise<DashboardStats> {
|
||||
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
|
||||
buildRadarrMap(),
|
||||
buildSonarrMap(),
|
||||
fetchAllUsers(),
|
||||
buildTautulliMap(),
|
||||
]);
|
||||
|
||||
const allRequests = new Map<number, OverseerrRequest[]>();
|
||||
|
||||
for (let i = 0; i < users.length; i += BATCH_SIZE) {
|
||||
const chunk = users.slice(i, i + BATCH_SIZE);
|
||||
const results = await Promise.all(
|
||||
chunk.map((u) => fetchUserRequests(u.id))
|
||||
);
|
||||
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
|
||||
}
|
||||
|
||||
return computeStats(users, allRequests, radarrMap, sonarrMap, tautulliMap);
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const force = new URL(req.url).searchParams.has("force");
|
||||
|
||||
try {
|
||||
// Force (Refresh button) or cold start: wait for fresh data
|
||||
if (force || !cache) {
|
||||
const stats = await buildStats();
|
||||
cache = { stats, at: Date.now() };
|
||||
return Response.json(cache.stats);
|
||||
}
|
||||
|
||||
// Stale: kick off background refresh, return cache immediately
|
||||
const age = Date.now() - cache.at;
|
||||
if (age > STALE_MS && !refreshing) {
|
||||
refreshing = true;
|
||||
buildStats()
|
||||
.then((stats) => { cache = { stats, at: Date.now() }; })
|
||||
.catch(() => {})
|
||||
.finally(() => { refreshing = false; });
|
||||
}
|
||||
|
||||
return Response.json(cache.stats);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return Response.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user