From f871f86284c971b4ccb3b9485b158673f4ad2852 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sun, 12 Apr 2026 11:13:57 -0400 Subject: [PATCH] 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 --- .gitignore | 3 + package-lock.json | 4 +- package.json | 2 +- src/app/alerts/[id]/AlertDetail.tsx | 170 ++++++++++++ src/app/alerts/[id]/page.tsx | 14 + src/app/api/alerts/[id]/comments/route.ts | 26 ++ src/app/api/alerts/[id]/route.ts | 42 +++ src/app/api/alerts/route.ts | 11 + src/app/api/stats/route.ts | 65 +++++ src/app/globals.css | 25 +- src/app/layout.tsx | 6 +- src/app/page.tsx | 239 ++++++++++++---- src/components/AlertsPanel.tsx | 178 ++++++++++++ src/components/LeaderboardTable.tsx | 189 +++++++++++++ src/components/PercentileBadge.tsx | 23 ++ src/components/RefreshButton.tsx | 32 +++ src/components/SummaryCards.tsx | 123 +++++++++ src/lib/aggregate.ts | 146 ++++++++++ src/lib/alerts.ts | 319 ++++++++++++++++++++++ src/lib/db.ts | 243 ++++++++++++++++ src/lib/overseerr.ts | 51 ++++ src/lib/radarr.ts | 19 ++ src/lib/sonarr.ts | 28 ++ src/lib/tautulli.ts | 78 ++++++ src/lib/types.ts | 134 +++++++++ 25 files changed, 2084 insertions(+), 86 deletions(-) create mode 100644 src/app/alerts/[id]/AlertDetail.tsx create mode 100644 src/app/alerts/[id]/page.tsx create mode 100644 src/app/api/alerts/[id]/comments/route.ts create mode 100644 src/app/api/alerts/[id]/route.ts create mode 100644 src/app/api/alerts/route.ts create mode 100644 src/app/api/stats/route.ts create mode 100644 src/components/AlertsPanel.tsx create mode 100644 src/components/LeaderboardTable.tsx create mode 100644 src/components/PercentileBadge.tsx create mode 100644 src/components/RefreshButton.tsx create mode 100644 src/components/SummaryCards.tsx create mode 100644 src/lib/aggregate.ts create mode 100644 src/lib/alerts.ts create mode 100644 src/lib/db.ts create mode 100644 src/lib/overseerr.ts create mode 100644 src/lib/radarr.ts create mode 100644 src/lib/sonarr.ts create mode 100644 src/lib/tautulli.ts create mode 100644 src/lib/types.ts diff --git a/.gitignore b/.gitignore index 5ef6a52..b1a4d73 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,6 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# alert persistence +/data/alerts.json diff --git a/package-lock.json b/package-lock.json index 416649a..597e581 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "oversnitch-tmp", + "name": "oversnitch", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "oversnitch-tmp", + "name": "oversnitch", "version": "0.1.0", "dependencies": { "next": "16.2.3", diff --git a/package.json b/package.json index 09307d1..95a37ae 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "oversnitch-tmp", + "name": "oversnitch", "version": "0.1.0", "private": true, "scripts": { diff --git a/src/app/alerts/[id]/AlertDetail.tsx b/src/app/alerts/[id]/AlertDetail.tsx new file mode 100644 index 0000000..6437e0b --- /dev/null +++ b/src/app/alerts/[id]/AlertDetail.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { Alert, AlertSeverity } from "@/lib/types"; + +const severityAccent: Record = { + danger: "border-l-red-500", + warning: "border-l-yellow-500", + info: "border-l-blue-500", +}; + +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 `${Math.floor(hrs / 24)}d ago`; +} + +function shortDate(iso: string): string { + return new Date(iso).toLocaleDateString(undefined, { + month: "short", day: "numeric", hour: "numeric", minute: "2-digit", + }); +} + +export default function AlertDetail({ initialAlert }: { initialAlert: Alert }) { + const [alert, setAlert] = useState(initialAlert); + const [actionLoading, setActionLoading] = useState(false); + const [commentText, setCommentText] = useState(""); + const [commentLoading, setCommentLoading] = useState(false); + const [error, setError] = useState(null); + + async function toggleStatus() { + setActionLoading(true); + setError(null); + try { + const newStatus = alert.status === "open" ? "closed" : "open"; + const res = await fetch(`/api/alerts/${alert.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ status: newStatus }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + setAlert(await res.json()); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setActionLoading(false); + } + } + + async function submitComment(e: React.FormEvent) { + e.preventDefault(); + if (!commentText.trim()) return; + setCommentLoading(true); + setError(null); + try { + const res = await fetch(`/api/alerts/${alert.id}/comments`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ body: commentText.trim() }), + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const comment = await res.json(); + setAlert((prev) => ({ ...prev, comments: [...prev.comments, comment] })); + setCommentText(""); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setCommentLoading(false); + } + } + + const isOpen = alert.status === "open"; + const isResolved = alert.closeReason === "resolved"; + const statusTime = isOpen ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen); + + return ( +
+ + + + + + All Alerts + + + {error && ( +
+ {error} +
+ )} + + {/* Alert card */} +
+ + {/* Status row */} +
+ + {isOpen && } + {isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"} + · + {timeAgo(statusTime)} + + + +
+ + {/* Title + description */} +
+

{alert.title}

+

{alert.description}

+
+
+ + {/* Notes */} +
+

Notes

+ + {alert.comments.length === 0 && ( +

No notes yet.

+ )} + + {alert.comments.map((c) => ( +
+

{shortDate(c.createdAt)}

+

{c.body}

+
+ ))} + +
+