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:
2026-04-12 11:13:57 -04:00
parent ef061ea910
commit f871f86284
25 changed files with 2084 additions and 86 deletions

78
src/lib/tautulli.ts Normal file
View File

@@ -0,0 +1,78 @@
import { TautulliUser } from "@/lib/types";
interface TautulliRow {
friendly_name: string;
email: string;
plays: number;
duration: number;
last_seen: number | null;
}
interface TautulliResponse {
response: {
result: string;
data: {
data: TautulliRow[];
};
};
}
/**
* Returns a Map<lowercaseEmail, TautulliUser>.
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
*/
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
const url = process.env.TAUTULLI_URL;
const key = process.env.TAUTULLI_API;
if (!url || !key) return null;
const res = await fetch(
`${url}/api/v2?apikey=${key}&cmd=get_users_table&length=1000&order_column=friendly_name&order_dir=asc`,
{ cache: "no-store" }
);
if (!res.ok) {
throw new Error(`Tautulli API error: ${res.status} ${res.statusText}`);
}
const json: TautulliResponse = await res.json();
if (json.response.result !== "success") {
throw new Error(`Tautulli API returned non-success result`);
}
const map = new Map<string, TautulliUser>();
for (const row of json.response.data.data) {
const user: TautulliUser = {
friendly_name: row.friendly_name,
email: row.email ?? "",
plays: row.plays ?? 0,
duration: row.duration ?? 0,
last_seen: row.last_seen ?? null,
};
if (user.email) {
map.set(user.email.toLowerCase(), user);
}
// Also index by friendly_name as fallback key
if (user.friendly_name) {
map.set(`name:${user.friendly_name.toLowerCase()}`, user);
}
}
return map;
}
export function lookupTautulliUser(
tautulliMap: Map<string, TautulliUser>,
email: string,
displayName: string
): TautulliUser | null {
return (
tautulliMap.get(email.toLowerCase()) ??
tautulliMap.get(`name:${displayName.toLowerCase()}`) ??
null
);
}