Compare commits

..

6 Commits

Author SHA1 Message Date
josh 2b9883d99f Add first-run setup screen when required services aren't configured
Build and Push / build (push) Successful in 1m9s
When Radarr, Sonarr, or Overseerr is missing a URL or API key, the
stats API now returns 428 and the dashboard renders a full-page
setup form instead of the empty shell + fetch-error UI. The form
reuses the existing service/discord inputs (extracted out of the
settings modal so both can share them), and the background poller
skips silently until setup is complete.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:39:50 -04:00
josh 29e6933505 Fix EACCES on bind-mounted /app/data
Build and Push / build (push) Successful in 54s
Bind mounts override the image's chown, so the container's nextjs
user (uid 1001) couldn't write to /app/data when it was mounted from
a host dir owned by someone else. Start as root, fix ownership in an
entrypoint, then drop to nextjs via su-exec.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:21:37 -04:00
josh ead2cdbc3c Document Docker deployment and refreshed chart UX in README
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:09:51 -04:00
josh 03dcd606f8 Hardcode registry image in docker-compose
Build and Push / build (push) Successful in 3m44s
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:04:48 -04:00
josh 3eeac0fe10 Add Docker build and Gitea Actions CI for deployment
Multi-stage Alpine Dockerfile producing a standalone Next.js image
with the better-sqlite3 native addon included. Gitea workflow builds
on push to main and on v* tags, pushing to the Gitea registry using
the REGISTRY_URL variable and REGISTRY_TOKEN secret. Compose file
mounts ./data so alerts.db and settings.json persist across restarts.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:04:22 -04:00
josh 74588e50f6 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>
2026-04-19 10:00:21 -04:00
31 changed files with 1570 additions and 916 deletions
+27
View File
@@ -0,0 +1,27 @@
.git
.gitea
.github
.claude
.vscode
.idea
node_modules
.next
out
build
coverage
data
.env*
README.md
*.md
!package.json
Dockerfile
docker-compose*.yml
.dockerignore
*.log
.DS_Store
*.tsbuildinfo
next-env.d.ts
+48
View File
@@ -0,0 +1,48 @@
name: Build and Push
on:
push:
branches: [main]
tags: ["v*"]
workflow_dispatch:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Derive image reference
id: img
run: |
echo "name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
echo "sha_short=${GITHUB_SHA::7}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ vars.REGISTRY_URL }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Compute tags
id: tags
run: |
IMAGE="${{ vars.REGISTRY_URL }}/${{ steps.img.outputs.name }}"
TAGS="${IMAGE}:${{ steps.img.outputs.sha_short }}"
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
TAGS="${TAGS},${IMAGE}:latest"
fi
if [[ "${{ github.ref }}" == refs/tags/v* ]]; then
TAGS="${TAGS},${IMAGE}:${GITHUB_REF_NAME}"
fi
echo "tags=${TAGS}" >> $GITHUB_OUTPUT
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ${{ steps.tags.outputs.tags }}
cache-from: type=registry,ref=${{ vars.REGISTRY_URL }}/${{ steps.img.outputs.name }}:buildcache
cache-to: type=registry,ref=${{ vars.REGISTRY_URL }}/${{ steps.img.outputs.name }}:buildcache,mode=max
+45
View File
@@ -0,0 +1,45 @@
# syntax=docker/dockerfile:1.7
ARG NODE_VERSION=22-alpine
# ── deps: install node_modules (with native build tools for better-sqlite3) ──
FROM node:${NODE_VERSION} AS deps
RUN apk add --no-cache libc6-compat python3 make g++
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# ── builder: produce the standalone Next.js bundle ──
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# ── runner: minimal runtime image ──
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
RUN apk add --no-cache su-exec \
&& addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Stay as root so the entrypoint can fix bind-mount ownership, then drop
# privileges via su-exec before launching the server.
EXPOSE 3000
VOLUME ["/app/data"]
ENTRYPOINT ["docker-entrypoint.sh"]
CMD ["node", "server.js"]
+34 -17
View File
@@ -10,7 +10,7 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
- **Leaderboard** — per-user request count, total storage, average GB per request, and optional Tautulli watch stats (plays, watch hours), each ranked against the full userbase - **Leaderboard** — per-user request count, total storage, average GB per request, and optional Tautulli watch stats (plays, watch hours), each ranked against the full userbase
- **User detail pages** — click any user in the leaderboard to see their full profile: stat cards, an activity chart with 1W/1M/3M/1Y timeframes, and a complete request history sorted newest-first - **User detail pages** — click any user in the leaderboard to see their full profile: stat cards, an activity chart with 1W/1M/3M/1Y timeframes, and a complete request history sorted newest-first
- **Activity chart** — two modes: *Metrics* (requests, storage GB, watch hours as separate toggleable lines, with a Raw/Relative normalization toggle) and *Storage Load* (GB requested ÷ watch hours per bucket, with an all-time average reference line) - **Activity chart** — single metric picker (Requests · Storage · Watch Hours · Storage Load) × timeframe (1W/1M/3M/1Y), with both the user's own average and the server-wide average shown as reference lines. Storage Load is a running cumulative GB ÷ watch hours — a new request spikes the line up and subsequent watching drags it back down.
- **Alerting** — automatic alerts for stalled downloads, neglected requesters, and abusive patterns, with open/close state, notes, and auto-resolve when conditions clear - **Alerting** — automatic alerts for stalled downloads, neglected requesters, and abusive patterns, with open/close state, notes, and auto-resolve when conditions clear
- **Discord notifications** — posts a structured embed to a webhook whenever a new alert opens or a resolved one returns - **Discord notifications** — posts a structured embed to a webhook whenever a new alert opens or a resolved one returns
- **Settings UI** — configure all service URLs and API keys from the dashboard; no need to touch `.env.local` after initial setup - **Settings UI** — configure all service URLs and API keys from the dashboard; no need to touch `.env.local` after initial setup
@@ -20,29 +20,33 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
## Setup ## Setup
### 1. Clone and install ### Option A — Docker Compose (recommended)
Images are published to `gitea.thewrightserver.net/josh/oversnitch` on every push to `main` by the Gitea Actions workflow at [.gitea/workflows/build.yml](.gitea/workflows/build.yml).
On the deployment host:
```bash
# Pull the latest image and start
docker login gitea.thewrightserver.net # if the registry is private
docker compose pull
docker compose up -d
```
The bundled [docker-compose.yml](docker-compose.yml) mounts `./data` into `/app/data`, so `alerts.db` and `settings.json` persist across restarts. The dashboard listens on `http://localhost:3000` — after first boot, click the gear icon and enter your service URLs and API keys there.
To configure services via environment instead of the Settings UI, uncomment the relevant lines in `docker-compose.yml` (see the full list in *Option B*).
### Option B — Local Node
```bash ```bash
git clone https://gitea.thewrightserver.net/josh/OverSnitch.git git clone https://gitea.thewrightserver.net/josh/OverSnitch.git
cd OverSnitch cd OverSnitch
npm install npm install
npm run dev # or: npm run build && npm start
``` ```
### 2. Configure Configure through the Settings UI, or create `.env.local` with any of:
**Option A — Settings UI (recommended)**
Start the app and click the gear icon in the top-right corner. Enter your service URLs and API keys, hit **Test** to verify each connection, then **Save**.
```bash
npm run dev # or: npm run build && npm start
```
Settings are written to `data/settings.json` (gitignored).
**Option B — Environment variables**
Create `.env.local` in the project root. Values here are used as fallbacks when `data/settings.json` doesn't exist or doesn't contain an override.
```env ```env
# Required # Required
@@ -66,6 +70,19 @@ DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
# NODE_TLS_REJECT_UNAUTHORIZED=0 # NODE_TLS_REJECT_UNAUTHORIZED=0
``` ```
Values in `data/settings.json` (written by the Settings UI) take precedence over env vars.
### CI / Registry
The Gitea workflow expects two repository-level config items:
| Kind | Name | Value |
|---|---|---|
| Variable | `REGISTRY_URL` | e.g. `gitea.thewrightserver.net` |
| Secret | `REGISTRY_TOKEN` | a Gitea access token with `write:package` scope |
On every push to `main`, the workflow builds a multi-stage Alpine image and pushes `:latest` + `:<sha7>` tags. Pushing a `v*` tag additionally publishes that tag.
--- ---
## Discord Notifications ## Discord Notifications
+24
View File
@@ -0,0 +1,24 @@
services:
oversnitch:
image: gitea.thewrightserver.net/josh/oversnitch:latest
container_name: oversnitch
restart: unless-stopped
ports:
- "${HOST_PORT:-3000}:3000"
volumes:
# Persists alerts.db and settings.json across restarts
- ./data:/app/data
environment:
- NODE_ENV=production
# All service URLs/API keys can be configured via the Settings UI after
# first boot. Uncomment below to provide them via env instead.
# - SEERR_URL=
# - SEERR_API=
# - RADARR_URL=
# - RADARR_API=
# - SONARR_URL=
# - SONARR_API=
# - TAUTULLI_URL=
# - TAUTULLI_API=
# - DISCORD_WEBHOOK=
# - NODE_TLS_REJECT_UNAUTHORIZED=0
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# /app/data is usually a bind mount from the host, so whatever permissions the
# image set during build don't survive. Fix ownership on boot, then drop root.
if [ -d /app/data ]; then
chown -R nextjs:nodejs /app/data 2>/dev/null || true
fi
exec su-exec nextjs:nodejs "$@"
+1 -1
View File
@@ -1,7 +1,7 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
/* config options here */ output: "standalone",
}; };
export default nextConfig; export default nextConfig;
+4
View File
@@ -1,6 +1,10 @@
import { getStats } from "@/lib/statsBuilder"; import { getStats } from "@/lib/statsBuilder";
import { isConfigured } from "@/lib/settings";
export async function GET(req: Request) { export async function GET(req: Request) {
if (!isConfigured()) {
return Response.json({ error: "not_configured" }, { status: 428 });
}
const force = new URL(req.url).searchParams.has("force"); const force = new URL(req.url).searchParams.has("force");
try { try {
return Response.json(await getStats(force)); return Response.json(await getStats(force));
+74 -51
View File
@@ -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);
+21 -10
View File
@@ -2,25 +2,17 @@
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";
import RefreshButton from "@/components/RefreshButton"; import RefreshButton from "@/components/RefreshButton";
import SettingsModal from "@/components/SettingsModal"; import SettingsModal from "@/components/SettingsModal";
import SetupScreen from "@/components/SetupScreen";
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);
@@ -28,6 +20,7 @@ export default function Page() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false); const [settingsOpen, setSettingsOpen] = useState(false);
const [needsSetup, setNeedsSetup] = useState(false);
const didInit = useRef(false); const didInit = useRef(false);
const load = useCallback(async (force = false) => { const load = useCallback(async (force = false) => {
@@ -41,8 +34,15 @@ export default function Page() {
try { try {
const res = await fetch(force ? "/api/stats?force=1" : "/api/stats"); const res = await fetch(force ? "/api/stats?force=1" : "/api/stats");
const json = await res.json(); const json = await res.json();
if (res.status === 428 || json?.error === "not_configured") {
setNeedsSetup(true);
setData(null);
try { localStorage.removeItem(LS_KEY); } catch {}
return;
}
if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`); if (!res.ok) throw new Error(json.error ?? `HTTP ${res.status}`);
const stats = json as DashboardStats; const stats = json as DashboardStats;
setNeedsSetup(false);
setData(stats); setData(stats);
try { localStorage.setItem(LS_KEY, JSON.stringify(stats)); } catch {} try { localStorage.setItem(LS_KEY, JSON.stringify(stats)); } catch {}
} catch (e) { } catch (e) {
@@ -76,6 +76,17 @@ export default function Page() {
const openAlertCount = data?.summary.openAlertCount ?? 0; const openAlertCount = data?.summary.openAlertCount ?? 0;
const generatedAt = data?.generatedAt ?? null; const generatedAt = data?.generatedAt ?? null;
if (needsSetup) {
return (
<SetupScreen
onComplete={() => {
setNeedsSetup(false);
load(true);
}}
/>
);
}
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">
+252
View File
@@ -0,0 +1,252 @@
"use client";
import { useMemo, useState } from "react";
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
ReferenceLine,
} from "recharts";
import {
EnrichedRequest,
WatchDataPoint,
UserStat,
Timeframe,
ServerAverages,
} from "@/lib/types";
import {
buildUserChartPoints,
ChartPoint,
TIMEFRAMES,
} from "@/lib/userChart";
import { formatGB } from "@/lib/format";
type MetricKey = "requests" | "gb" | "watchHours" | "load";
interface MetricConfig {
key: MetricKey;
label: string;
color: string;
/** how the visible data values compare against ChartPoint fields */
dataKey: keyof ChartPoint;
/** show the line only when Tautulli is configured */
tautulliOnly?: boolean;
/** formats the raw numeric value for tooltip and reference-line labels */
format: (v: number) => string;
/** formats the Y axis tick */
tickFormat: (v: number) => string;
/** reserved Y axis width */
yWidth: number;
}
const METRICS: Record<MetricKey, MetricConfig> = {
requests: {
key: "requests",
label: "Requests",
color: "#3b82f6",
dataKey: "requests",
format: (v) => `${v}`,
tickFormat: (v) => `${v}`,
yWidth: 32,
},
gb: {
key: "gb",
label: "Storage",
color: "#facc15",
dataKey: "gb",
format: (v) => formatGB(v),
tickFormat: (v) => `${v}G`,
yWidth: 40,
},
watchHours: {
key: "watchHours",
label: "Watch Hours",
color: "#4ade80",
dataKey: "watchHours",
tautulliOnly: true,
format: (v) => `${v}h`,
tickFormat: (v) => `${v}h`,
yWidth: 38,
},
load: {
key: "load",
label: "Storage Load",
color: "#f97316",
dataKey: "load",
tautulliOnly: true,
format: (v) => `${v} GB/hr`,
tickFormat: (v) => `${v}G/h`,
yWidth: 44,
},
};
const SERVER_COLOR = "#94a3b8"; // slate-400
export default function UserActivityChart({
stat,
enrichedRequests,
watchHistory,
serverAverages,
}: {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[];
serverAverages: ServerAverages;
}) {
const hasTautulli = stat.plays !== null;
const [tf, setTf] = useState<Timeframe>("1M");
const [metric, setMetric] = useState<MetricKey>("gb");
const points = useMemo(
() => buildUserChartPoints(enrichedRequests, watchHistory, tf),
[enrichedRequests, watchHistory, tf]
);
const cfg = METRICS[metric];
const availableMetrics: MetricKey[] = (["requests", "gb", "watchHours", "load"] as MetricKey[])
.filter((k) => hasTautulli || !METRICS[k].tautulliOnly);
// User avg = mean of visible buckets (flows). For load, use the overall scalar.
const userAvg = useMemo(() => {
if (metric === "load") return stat.loadGBPerHour;
const vals = points
.map((p) => p[cfg.dataKey])
.filter((v): v is number => typeof v === "number");
if (vals.length === 0) return null;
return Math.round((vals.reduce((s, v) => s + v, 0) / vals.length) * 10) / 10;
}, [metric, points, cfg.dataKey, stat.loadGBPerHour]);
const serverAvg = serverAverages[tf][metric];
const isEmpty = points.every(
(p) => p.requests === 0 && p.gb === 0 && p.watchHours === 0
);
return (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/40 p-5 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1">
{availableMetrics.map((k) => {
const m = METRICS[k];
const active = k === metric;
return (
<button
key={k}
onClick={() => setMetric(k)}
className={`cursor-pointer rounded-lg px-2.5 py-1 text-xs font-medium transition-colors ${
active
? "text-white"
: "text-slate-500 hover:text-slate-300"
}`}
style={active ? { backgroundColor: `${m.color}33` } : undefined}
>
<span className="inline-flex items-center gap-1.5">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: m.color }} />
{m.label}
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-1">
{TIMEFRAMES.map((t) => (
<button
key={t}
onClick={() => setTf(t)}
className={`cursor-pointer rounded px-2.5 py-1 text-xs font-medium transition-colors ${
tf === t
? "bg-slate-700 text-white"
: "text-slate-500 hover:text-slate-300"
}`}
>
{t}
</button>
))}
</div>
</div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={points} margin={{ top: 4, right: 8, left: -8, bottom: 4 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis
dataKey="label"
tick={{ fill: "#475569", fontSize: 11 }}
axisLine={{ stroke: "#334155" }}
tickLine={false}
/>
<YAxis
tick={{ fill: "#475569", fontSize: 11 }}
axisLine={false}
tickLine={false}
tickFormatter={cfg.tickFormat}
width={cfg.yWidth}
/>
<Tooltip
contentStyle={{
background: "#0f172a",
border: "1px solid #334155",
borderRadius: "8px",
fontSize: "12px",
}}
labelStyle={{ color: "#94a3b8", marginBottom: "4px" }}
itemStyle={{ color: "#e2e8f0" }}
formatter={(value) => {
const num = typeof value === "number" ? value : Number(value);
return [cfg.format(num), cfg.label];
}}
/>
{userAvg !== null && (
<ReferenceLine
y={userAvg}
stroke={cfg.color}
strokeOpacity={0.55}
strokeDasharray="4 3"
label={{
value: "you",
position: "insideTopRight",
fill: cfg.color,
fontSize: 10,
}}
/>
)}
{serverAvg !== null && (
<ReferenceLine
y={serverAvg}
stroke={SERVER_COLOR}
strokeOpacity={0.45}
strokeDasharray="2 4"
label={{
value: "all users",
position: "insideBottomRight",
fill: SERVER_COLOR,
fontSize: 10,
}}
/>
)}
<Line
type="monotone"
dataKey={cfg.dataKey as string}
stroke={cfg.color}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls={false}
name={cfg.label}
/>
</LineChart>
</ResponsiveContainer>
{isEmpty && (
<p className="text-center text-sm text-slate-600 py-2">
No activity in this period
</p>
)}
</div>
);
}
+18 -555
View File
@@ -1,233 +1,18 @@
"use client"; "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>
); );
} }
+46
View File
@@ -0,0 +1,46 @@
import Link from "next/link";
import { UserStat } from "@/lib/types";
import { unixTimeAgo } from "@/lib/format";
export default function UserHeader({ stat }: { stat: UserStat }) {
const hasTautulli = stat.plays !== null;
return (
<>
<Link
href="/"
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer"
>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5">
<path strokeLinecap="round" strokeLinejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" />
</svg>
All Users
</Link>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<div className="flex items-center gap-2.5">
<h1 className="text-3xl font-bold tracking-tight text-white">{stat.displayName}</h1>
{stat.requestCount === 0 && (
<span className="rounded-full border border-slate-700 bg-slate-800 px-2 py-0.5 text-xs text-slate-500">
No requests
</span>
)}
</div>
<p className="mt-1 text-sm text-slate-500">{stat.email}</p>
</div>
<div className="flex flex-col items-end gap-1">
{hasTautulli && (
<span className="text-xs text-slate-600">
Last seen: <span className="text-slate-400">{unixTimeAgo(stat.tautulliLastSeen)}</span>
</span>
)}
<span className="text-xs text-slate-600">
#{stat.storageRank} of {stat.totalUsers} by storage
</span>
</div>
</div>
</>
);
}
+51
View File
@@ -0,0 +1,51 @@
import Link from "next/link";
import { Alert, AlertSeverity } from "@/lib/types";
const SEV_COLOR: Record<AlertSeverity, string> = {
danger: "border-l-red-500 bg-red-950/20",
warning: "border-l-yellow-500 bg-yellow-950/10",
info: "border-l-blue-500 bg-blue-950/20",
};
const SEV_TEXT: Record<AlertSeverity, string> = {
danger: "text-red-400",
warning: "text-yellow-400",
info: "text-blue-400",
};
export default function UserOpenAlerts({ alerts }: { alerts: Alert[] }) {
if (alerts.length === 0) return null;
return (
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">
Open Alerts
<span className="ml-2 inline-flex items-center justify-center rounded-full bg-yellow-500 text-black text-xs font-bold px-1.5 py-0.5 leading-none">
{alerts.length}
</span>
</h2>
<div className="space-y-2">
{alerts.map((alert) => (
<Link
key={alert.id}
href={`/alerts/${alert.id}`}
className={`block rounded-xl border border-slate-700/60 border-l-4 px-4 py-3.5 transition-colors hover:bg-slate-800/60 cursor-pointer ${SEV_COLOR[alert.severity]}`}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<span className={`text-xs font-semibold uppercase tracking-wider ${SEV_TEXT[alert.severity]}`}>
{alert.category}
</span>
<p className="mt-0.5 text-sm font-medium text-white leading-snug">{alert.title}</p>
<p className="mt-1 text-xs text-slate-500 line-clamp-2">{alert.description}</p>
</div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-4 w-4 shrink-0 text-slate-600 mt-0.5">
<path strokeLinecap="round" strokeLinejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
</svg>
</div>
</Link>
))}
</div>
</div>
);
}
+115
View File
@@ -0,0 +1,115 @@
"use client";
import { useMemo, useState } from "react";
import { EnrichedRequest } from "@/lib/types";
import { formatGB } from "@/lib/format";
import StatusBadge from "@/components/StatusBadge";
const INITIAL_COUNT = 20;
export default function UserRequestHistory({
requests,
}: {
requests: EnrichedRequest[];
}) {
const [showAll, setShowAll] = useState(false);
const sorted = useMemo(
() =>
[...requests].sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
),
[requests]
);
const visible = showAll ? sorted : sorted.slice(0, INITIAL_COUNT);
return (
<div className="space-y-3">
<h2 className="text-base font-semibold text-white">
Request History
<span className="ml-2 text-xs font-normal text-slate-600">
{sorted.length} total
</span>
</h2>
{sorted.length === 0 ? (
<div className="rounded-xl border border-slate-700/40 bg-slate-800/20 px-5 py-6 text-center text-sm text-slate-600">
No requests yet
</div>
) : (
<>
<div className="overflow-x-auto rounded-xl border border-slate-700/60">
<table className="w-full text-sm">
<thead className="bg-slate-800/80 border-b border-slate-700/60">
<tr>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Title</th>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Type</th>
<th className="py-2.5 px-4 text-left text-xs font-semibold uppercase tracking-wider text-slate-500">Status</th>
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Size</th>
<th className="py-2.5 px-4 text-right text-xs font-semibold uppercase tracking-wider text-slate-500">Requested</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{visible.map((req) => (
<tr key={req.id} className="bg-slate-900 hover:bg-slate-800/50 transition-colors">
<td className="py-2.5 px-4 font-medium text-white max-w-xs truncate">
{req.seerrUrl ? (
<a
href={req.seerrUrl}
target="_blank"
rel="noreferrer"
className="hover:text-yellow-400 transition-colors"
>
{req.title}
</a>
) : (
req.title
)}
</td>
<td className="py-2.5 px-4">
<span
className={`rounded border px-1.5 py-0.5 text-xs ${
req.type === "movie"
? "border-purple-700/40 bg-purple-500/10 text-purple-400"
: "border-cyan-700/40 bg-cyan-500/10 text-cyan-400"
}`}
>
{req.type === "movie" ? "Movie" : "TV"}
</span>
</td>
<td className="py-2.5 px-4">
<StatusBadge status={req.status} />
</td>
<td className="py-2.5 px-4 text-right font-mono text-xs tabular-nums text-slate-400">
{req.sizeGB > 0 ? formatGB(req.sizeGB) : <span className="text-slate-700"></span>}
</td>
<td className="py-2.5 px-4 text-right text-xs text-slate-600">
{new Date(req.createdAt).toLocaleDateString(undefined, {
month: "short",
day: "numeric",
year: "numeric",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
{sorted.length > INITIAL_COUNT && (
<button
onClick={() => setShowAll((v) => !v)}
className="cursor-pointer w-full rounded-lg border border-slate-700/40 bg-slate-800/20 py-2 text-sm text-slate-500 hover:text-slate-300 hover:bg-slate-800/40 transition-colors"
>
{showAll
? `Show recent ${INITIAL_COUNT}`
: `Show all ${sorted.length} requests`}
</button>
)}
</>
)}
</div>
);
}
+150
View File
@@ -0,0 +1,150 @@
import { UserStat } from "@/lib/types";
import { formatGB, formatHours } from "@/lib/format";
import RankChip from "@/components/RankChip";
type Accent = "yellow" | "green" | "orange" | null;
const ICONS = {
requests: "M3.75 9.776c.112-.017.227-.026.344-.026h15.812c.117 0 .232.009.344.026m-16.5 0a2.25 2.25 0 0 0-1.883 2.542l.857 6a2.25 2.25 0 0 0 2.227 1.932H19.05a2.25 2.25 0 0 0 2.227-1.932l.857-6a2.25 2.25 0 0 0-1.883-2.542m-16.5 0V6A2.25 2.25 0 0 1 6 3.75h3.879a1.5 1.5 0 0 1 1.06.44l2.122 2.12a1.5 1.5 0 0 0 1.06.44H18A2.25 2.25 0 0 1 20.25 9v.776",
storage: "M20.25 6.375c0 2.278-3.694 4.125-8.25 4.125S3.75 8.653 3.75 6.375m16.5 0c0-2.278-3.694-4.125-8.25-4.125S3.75 4.097 3.75 6.375m16.5 0v11.25c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125V6.375m16.5 2.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125m16.5 2.625c0 2.278-3.694 4.125-8.25 4.125s-8.25-1.847-8.25-4.125",
avg: "M12 3v17.25m0 0c-1.472 0-2.882.265-4.185.75M12 20.25c1.472 0 2.882.265 4.185.75M18.75 4.97A48.416 48.416 0 0 0 12 4.5c-2.291 0-4.545.16-6.75.47m13.5 0c1.01.143 2.01.317 3 .52m-3-.52 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.988 5.988 0 0 1-2.031.352 5.988 5.988 0 0 1-2.031-.352c-.483-.174-.711-.703-.59-1.202L18.75 4.971Zm-16.5.52c.99-.203 1.99-.377 3-.52m0 0 2.62 10.726c.122.499-.106 1.028-.589 1.202a5.989 5.989 0 0 1-2.031.352 5.989 5.989 0 0 1-2.032-.352c-.483-.174-.711-.703-.59-1.202L5.25 4.971Z",
plays: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0ZM10.5 8.25v7.5l6-3.75-6-3.75Z",
watch: "M3.375 19.5h17.25m-17.25 0a1.125 1.125 0 0 1-1.125-1.125M3.375 19.5h1.5C5.496 19.5 6 18.996 6 18.375m-3.75.125v-6.375c0-.621.504-1.125 1.125-1.125H4.5m0-3.375v3.375m0-3.375h12M4.5 7.5H18a.75.75 0 0 1 .75.75v9.375c0 .621-.504 1.125-1.125 1.125h-1.5m-13.5 0h13.5m0 0v-3.375m0 3.375v-9.375c0-.621.504-1.125 1.125-1.125H21",
load: "m3.75 13.5 10.5-11.25L12 10.5h8.25L9.75 21.75 12 13.5H3.75Z",
};
type IconKey = keyof typeof ICONS;
const ACCENT_CLASSES: Record<Exclude<Accent, null>, { box: string; value: string; icon: string }> = {
yellow: {
box: "bg-yellow-950/30 border-yellow-700/50",
value: "text-yellow-300",
icon: "text-yellow-500/70",
},
green: {
box: "bg-green-950/30 border-green-800/50",
value: "text-white",
icon: "text-green-500/70",
},
orange: {
box: "bg-orange-950/30 border-orange-800/50",
value: "text-orange-300",
icon: "text-orange-500/70",
},
};
const DEFAULT_CLASSES = {
box: "bg-slate-800/60 border-slate-700/60",
value: "text-white",
icon: "text-slate-600",
};
function Card({
label,
value,
icon,
rank,
total,
accent,
}: {
label: string;
value: string;
icon: IconKey;
rank?: number | null;
total?: number;
accent?: Accent;
}) {
const c = accent ? ACCENT_CLASSES[accent] : DEFAULT_CLASSES;
return (
<div className={`rounded-xl border p-5 flex flex-col gap-2 transition-colors ${c.box}`}>
<div className="flex items-center justify-between">
<span className="text-xs font-semibold uppercase tracking-widest text-slate-500">
{label}
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`h-4 w-4 ${c.icon}`}
>
<path strokeLinecap="round" strokeLinejoin="round" d={ICONS[icon]} />
</svg>
</div>
<span className={`text-3xl font-bold tabular-nums ${c.value}`}>{value}</span>
{rank !== undefined && total !== undefined && (
<RankChip rank={rank ?? null} total={total} />
)}
</div>
);
}
export default function UserStatCards({ stat }: { stat: UserStat }) {
const hasTautulli = stat.plays !== null;
const total = stat.totalUsers;
const grid = hasTautulli
? "grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6"
: "grid grid-cols-2 gap-3 sm:grid-cols-3";
return (
<div className={grid}>
<Card
label="Requests"
value={stat.requestCount.toLocaleString()}
icon="requests"
rank={stat.requestRank}
total={total}
/>
<Card
label="Storage"
value={formatGB(stat.totalGB)}
icon="storage"
rank={stat.storageRank}
total={total}
accent="yellow"
/>
<Card
label="Avg / Request"
value={stat.requestCount > 0 ? formatGB(stat.avgGB) : "—"}
icon="avg"
/>
{hasTautulli && (
<Card
label="Plays"
value={(stat.plays ?? 0).toLocaleString()}
icon="plays"
rank={stat.playsRank}
total={total}
/>
)}
{hasTautulli && (
<Card
label="Watch Time"
value={
stat.watchHours !== null && stat.watchHours > 0
? formatHours(stat.watchHours)
: "0h"
}
icon="watch"
rank={stat.watchRank}
total={total}
accent="green"
/>
)}
{hasTautulli && (
<Card
label="Storage Load"
value={
stat.loadGBPerHour !== null ? `${stat.loadGBPerHour.toFixed(1)} GB/hr` : "—"
}
icon="load"
rank={stat.loadRank}
total={total}
accent="orange"
/>
)}
</div>
);
}
+6 -22
View File
@@ -3,6 +3,8 @@
import { useState } from "react"; import { 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>
)} )}
+15
View File
@@ -0,0 +1,15 @@
export default function RankChip({
rank,
total,
}: {
rank: number | null;
total: number;
}) {
if (rank === null) return <span className="text-slate-700"></span>;
return (
<span className="text-xs font-mono text-slate-500">
#{rank}
<span className="text-slate-700">/{total}</span>
</span>
);
}
+2 -234
View File
@@ -2,21 +2,8 @@
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef } from "react";
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings"; import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
import ServiceSection from "@/components/settings/ServiceSection";
// ── Icons ───────────────────────────────────────────────────────────────────── import DiscordSection from "@/components/settings/DiscordSection";
function EyeIcon({ open }: { open: boolean }) {
return open ? (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
) : (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
}
function XIcon() { function XIcon() {
return ( return (
@@ -26,219 +13,6 @@ function XIcon() {
); );
} }
// ── Per-service section ───────────────────────────────────────────────────────
type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
interface SectionProps {
id: ServiceKey;
label: string;
placeholder: string;
optional?: boolean;
config: ServiceConfig;
onChange: (patch: Partial<ServiceConfig>) => void;
}
function ServiceSection({ id, label, placeholder, optional, config, onChange }: SectionProps) {
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
// Clear test result when inputs change
function handleUrlChange(v: string) {
setTestResult(null);
onChange({ url: v });
}
function handleKeyChange(v: string) {
setTestResult(null);
onChange({ apiKey: v });
}
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
{optional && (
<span className="text-xs text-slate-600 font-normal">optional</span>
)}
</div>
<div className="space-y-2">
{/* URL */}
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
<input
type="text"
value={config.url}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
</div>
{/* API Key */}
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={config.apiKey}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="••••••••••••••••••••••••••••••••"
spellCheck={false}
autoComplete="off"
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
tabIndex={-1}
aria-label={showKey ? "Hide API key" : "Show API key"}
>
<EyeIcon open={showKey} />
</button>
</div>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Testing…" : "Test"}
</button>
</div>
</div>
</div>
{/* Test result */}
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
// ── Discord section (webhook URL only, no API key) ────────────────────────────
interface DiscordSectionProps {
config: DiscordConfig;
onChange: (patch: Partial<DiscordConfig>) => void;
}
function DiscordSection({ config, onChange }: DiscordSectionProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
function handleChange(v: string) {
setTestResult(null);
onChange({ webhookUrl: v });
}
const canTest = config.webhookUrl.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
<span className="text-xs text-slate-600 font-normal">optional</span>
</div>
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
<div className="flex flex-1 gap-2">
<input
type="text"
value={config.webhookUrl}
onChange={(e) => handleChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/…"
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Sending…" : "Test"}
</button>
</div>
</div>
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
// ── Modal ─────────────────────────────────────────────────────────────────────
interface Props { interface Props {
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
@@ -261,7 +35,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null); const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null);
const panelRef = useRef<HTMLDivElement>(null); const panelRef = useRef<HTMLDivElement>(null);
// Load current settings when modal opens
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
setSaveResult(null); setSaveResult(null);
@@ -273,7 +46,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, [open]); }, [open]);
// Close on Escape
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
@@ -283,7 +55,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
return () => document.removeEventListener("keydown", onKey); return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]); }, [open, onClose]);
// Close on backdrop click
function handleBackdrop(e: React.MouseEvent) { function handleBackdrop(e: React.MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) { if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose(); onClose();
@@ -336,7 +107,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
ref={panelRef} ref={panelRef}
className="relative w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]" className="relative w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]"
> >
{/* Header */}
<div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0"> <div className="flex items-center justify-between px-6 py-5 border-b border-slate-800 shrink-0">
<h2 className="text-base font-semibold text-white">Settings</h2> <h2 className="text-base font-semibold text-white">Settings</h2>
<button <button
@@ -348,7 +118,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
</button> </button>
</div> </div>
{/* Body */}
<div className="overflow-y-auto flex-1 px-6 py-5"> <div className="overflow-y-auto flex-1 px-6 py-5">
{loading ? ( {loading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
@@ -396,7 +165,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
)} )}
</div> </div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3"> <div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3">
<div className="text-xs"> <div className="text-xs">
{saveResult === "saved" && ( {saveResult === "saved" && (
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
import ServiceSection from "@/components/settings/ServiceSection";
import DiscordSection from "@/components/settings/DiscordSection";
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
const EMPTY_SETTINGS: AppSettings = {
radarr: EMPTY_CONFIG,
sonarr: EMPTY_CONFIG,
seerr: EMPTY_CONFIG,
tautulli: EMPTY_CONFIG,
discord: { webhookUrl: "" },
};
function filled(c: ServiceConfig) {
return c.url.trim() !== "" && c.apiKey.trim() !== "";
}
export default function SetupScreen({ onComplete }: { onComplete: () => void }) {
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
setError(null);
setSettings((prev) => ({ ...prev, [service]: { ...prev[service], ...partial } }));
}
function patchDiscord(partial: Partial<DiscordConfig>) {
setError(null);
setSettings((prev) => ({ ...prev, discord: { ...prev.discord, ...partial } }));
}
const canComplete = filled(settings.radarr) && filled(settings.sonarr) && filled(settings.seerr);
async function handleSave() {
setSaving(true);
setError(null);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
onComplete();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]">
<div className="px-6 py-6 border-b border-slate-800 shrink-0">
<h1 className="text-2xl font-bold tracking-tight">
Welcome to Over<span className="text-yellow-400">Snitch</span>
</h1>
<p className="text-slate-400 text-sm mt-2">
Connect your services to get started. You can change any of these later in Settings.
</p>
</div>
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
<ServiceSection
id="radarr"
label="Radarr"
placeholder="http://radarr:7878"
config={settings.radarr}
onChange={(p) => patch("radarr", p)}
/>
<ServiceSection
id="sonarr"
label="Sonarr"
placeholder="http://sonarr:8989"
config={settings.sonarr}
onChange={(p) => patch("sonarr", p)}
/>
<ServiceSection
id="seerr"
label="Overseerr / Jellyseerr"
placeholder="http://overseerr:5055"
config={settings.seerr}
onChange={(p) => patch("seerr", p)}
/>
<div className="pt-1 text-xs uppercase tracking-wider text-slate-600 font-semibold">
Optional
</div>
<ServiceSection
id="tautulli"
label="Tautulli"
placeholder="http://tautulli:8181"
optional
config={settings.tautulli}
onChange={(p) => patch("tautulli", p)}
/>
<DiscordSection
config={settings.discord}
onChange={(p) => patchDiscord(p)}
/>
</div>
<div className="px-6 py-4 border-t border-slate-800 shrink-0 space-y-3">
{error && (
<div className="text-xs text-red-400">Save failed: {error}</div>
)}
<button
onClick={handleSave}
disabled={!canComplete || saving}
className="w-full rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2.5 text-sm font-semibold text-black transition-colors"
>
{saving ? "Saving…" : "Complete Setup"}
</button>
{!canComplete && (
<p className="text-xs text-slate-600 text-center">
Fill in URL and API key for Radarr, Sonarr, and Overseerr to continue.
</p>
)}
</div>
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
// Overseerr media.status codes:
// 1=Unknown, 2=Pending, 3=Processing, 4=Partially Available, 5=Available
const LABEL: Record<number, string> = {
1: "Unknown",
2: "Pending",
3: "Processing",
4: "Partial",
5: "Available",
};
const COLOR: Record<number, string> = {
1: "bg-slate-700/30 text-slate-500 border-slate-600/40",
2: "bg-yellow-500/15 text-yellow-400 border-yellow-700/40",
3: "bg-blue-500/15 text-blue-400 border-blue-700/40",
4: "bg-cyan-500/15 text-cyan-400 border-cyan-700/40",
5: "bg-green-500/15 text-green-400 border-green-700/40",
};
export default function StatusBadge({ status }: { status: number }) {
return (
<span
className={`inline-flex items-center rounded border px-1.5 py-0.5 text-xs font-medium ${
COLOR[status] ?? "bg-slate-700 text-slate-400 border-slate-600"
}`}
>
{LABEL[status] ?? `Status ${status}`}
</span>
);
}
+3 -11
View File
@@ -1,3 +1,5 @@
import { formatGB, formatHours } from "@/lib/format";
interface SummaryCardsProps { 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" />
)} )}
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { DiscordConfig } from "@/lib/settings";
interface Props {
config: DiscordConfig;
onChange: (patch: Partial<DiscordConfig>) => void;
}
export default function DiscordSection({ config, onChange }: Props) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
function handleChange(v: string) {
setTestResult(null);
onChange({ webhookUrl: v });
}
const canTest = config.webhookUrl.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
<span className="text-xs text-slate-600 font-normal">optional</span>
</div>
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
<div className="flex flex-1 gap-2">
<input
type="text"
value={config.webhookUrl}
onChange={(e) => handleChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/…"
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Sending…" : "Test"}
</button>
</div>
</div>
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { ServiceConfig } from "@/lib/settings";
function EyeIcon({ open }: { open: boolean }) {
return open ? (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
) : (
<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">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
}
export type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
interface Props {
id: ServiceKey;
label: string;
placeholder: string;
optional?: boolean;
config: ServiceConfig;
onChange: (patch: Partial<ServiceConfig>) => void;
}
export default function ServiceSection({ id, label, placeholder, optional, config, onChange }: Props) {
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
function handleUrlChange(v: string) {
setTestResult(null);
onChange({ url: v });
}
function handleKeyChange(v: string) {
setTestResult(null);
onChange({ apiKey: v });
}
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
{optional && (
<span className="text-xs text-slate-600 font-normal">optional</span>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
<input
type="text"
value={config.url}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
</div>
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={config.apiKey}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="••••••••••••••••••••••••••••••••"
spellCheck={false}
autoComplete="off"
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
tabIndex={-1}
aria-label={showKey ? "Hide API key" : "Show API key"}
>
<EyeIcon open={showKey} />
</button>
</div>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Testing…" : "Test"}
</button>
</div>
</div>
</div>
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<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 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
+21 -12
View File
@@ -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,
+46
View File
@@ -0,0 +1,46 @@
import { OverseerrRequest, MediaEntry, EnrichedRequest } from "@/lib/types";
import { bytesToGB } from "@/lib/aggregate";
export function enrichRequests(
userRequests: OverseerrRequest[],
radarrMap: Map<number, MediaEntry>,
sonarrMap: Map<number, MediaEntry>,
seerrBaseUrl?: string
): EnrichedRequest[] {
return userRequests.map((req) => {
let sizeOnDisk = 0;
let title = req.media.title ?? "";
if (req.type === "movie") {
const entry = radarrMap.get(req.media.tmdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
} else if (req.type === "tv" && req.media.tvdbId) {
const entry = sonarrMap.get(req.media.tvdbId);
sizeOnDisk = entry?.sizeOnDisk ?? 0;
if (entry?.title) title = entry.title;
}
if (!title) {
title = req.type === "movie"
? `Movie #${req.media.tmdbId}`
: `Show #${req.media.tmdbId}`;
}
const seerrUrl = seerrBaseUrl
? `${seerrBaseUrl}/${req.type === "movie" ? "movie" : "tv"}/${req.media.tmdbId}`
: undefined;
return {
id: req.id,
type: req.type,
status: req.status,
createdAt: req.createdAt,
mediaId: req.type === "movie" ? req.media.tmdbId : (req.media.tvdbId ?? 0),
title,
sizeOnDisk,
sizeGB: bytesToGB(sizeOnDisk),
seerrUrl,
};
});
}
+27
View File
@@ -0,0 +1,27 @@
export function formatGB(gb: number): string {
if (gb >= 1000) return `${(gb / 1000).toFixed(2)} TB`;
return `${gb.toFixed(1)} GB`;
}
export function formatHours(h: number): string {
if (h >= 1000) return `${(h / 1000).toFixed(1)}k h`;
return `${h.toFixed(0)}h`;
}
export function timeAgo(iso: string | null | undefined): string {
if (!iso) return "Never";
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
if (days < 30) return `${days}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", year: "numeric" });
}
export function unixTimeAgo(ts: number | null): string {
if (ts === null) return "Never";
return timeAgo(new Date(ts * 1000).toISOString());
}
+6
View File
@@ -71,6 +71,12 @@ export function getSettings(): AppSettings {
}; };
} }
/** Returns true iff the three services required for the dashboard to function (Radarr, Sonarr, Overseerr/Jellyseerr) have both a URL and API key. */
export function isConfigured(s: AppSettings = getSettings()): boolean {
const filled = (c: ServiceConfig) => c.url.trim() !== "" && c.apiKey.trim() !== "";
return filled(s.radarr) && filled(s.sonarr) && filled(s.seerr);
}
/** Saves the provided settings to disk and returns the merged result. */ /** Saves the provided settings to disk and returns the merged result. */
export function saveSettings(settings: AppSettings): AppSettings { export function saveSettings(settings: AppSettings): AppSettings {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true }); if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
+27 -3
View File
@@ -9,9 +9,16 @@
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 { isConfigured } from "@/lib/settings";
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 +35,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 +62,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);
} }
@@ -88,6 +111,7 @@ export async function getStats(force = false): Promise<DashboardStats> {
async function poll() { async function poll() {
if (refreshing) return; if (refreshing) return;
if (!isConfigured()) return;
refreshing = true; refreshing = true;
try { try {
const stats = await buildStats(); const stats = await buildStats();
+18
View File
@@ -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 */
+97
View File
@@ -0,0 +1,97 @@
import { EnrichedRequest, WatchDataPoint, Timeframe } from "@/lib/types";
export const TIMEFRAMES: Timeframe[] = ["1W", "1M", "3M", "1Y"];
export const TF_CONFIG: Record<Timeframe, { rangeDays: number; bucketDays: number }> = {
"1W": { rangeDays: 7, bucketDays: 1 },
"1M": { rangeDays: 30, bucketDays: 1 },
"3M": { rangeDays: 91, bucketDays: 7 },
"1Y": { rangeDays: 365, bucketDays: 30 },
};
export interface ChartPoint {
label: string;
requests: number;
gb: number;
watchHours: number;
/**
* Running cumulative GB ÷ cumulative watch hours as of the end of this bucket —
* including all history before the window. A new request bumps it up; more
* watching drags it back down. null until the user has any watch hours.
*/
load: number | null;
}
export function buildUserChartPoints(
enrichedRequests: EnrichedRequest[],
watchHistory: WatchDataPoint[],
tf: Timeframe
): ChartPoint[] {
const now = Date.now();
const MS = 86_400_000;
const { rangeDays, bucketDays } = TF_CONFIG[tf];
const numBuckets = Math.ceil(rangeDays / bucketDays);
const points: ChartPoint[] = Array.from({ length: numBuckets }, (_, i) => {
const midMs = now - (numBuckets - 1 - i + 0.5) * bucketDays * MS;
const d = new Date(midMs);
const label =
bucketDays >= 28
? d.toLocaleDateString(undefined, { month: "short", year: "2-digit" })
: d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
return { label, requests: 0, gb: 0, watchHours: 0, load: null };
});
// Per-bucket flows: count requests / GB / watch hours that landed inside the bucket.
for (const req of enrichedRequests) {
const ageMs = now - new Date(req.createdAt).getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].requests += 1;
points[idx].gb = Math.round((points[idx].gb + req.sizeGB) * 10) / 10;
}
}
for (const wh of watchHistory) {
const ageMs = now - new Date(wh.date + "T12:00:00").getTime();
if (ageMs < 0 || ageMs > rangeDays * MS) continue;
const idx = numBuckets - 1 - Math.floor(ageMs / (bucketDays * MS));
if (idx >= 0 && idx < numBuckets) {
points[idx].watchHours = Math.round((points[idx].watchHours + wh.durationHours) * 10) / 10;
}
}
// Running load: sweep all requests / history (including before the window) and
// accumulate GB + hours up to the end of each bucket.
const reqSorted = [...enrichedRequests].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
);
const watchSorted = [...watchHistory].sort((a, b) => a.date.localeCompare(b.date));
let reqIdx = 0;
let watchIdx = 0;
let cumGB = 0;
let cumHours = 0;
for (let i = 0; i < numBuckets; i++) {
const bucketEndMs = now - (numBuckets - 1 - i) * bucketDays * MS;
while (
reqIdx < reqSorted.length &&
new Date(reqSorted[reqIdx].createdAt).getTime() <= bucketEndMs
) {
cumGB += reqSorted[reqIdx].sizeGB;
reqIdx++;
}
while (
watchIdx < watchSorted.length &&
new Date(watchSorted[watchIdx].date + "T12:00:00").getTime() <= bucketEndMs
) {
cumHours += watchSorted[watchIdx].durationHours;
watchIdx++;
}
points[i].load = cumHours > 0 ? Math.round((cumGB / cumHours) * 10) / 10 : null;
}
return points;
}