Compare commits

..

1 Commits

Author SHA1 Message Date
josh dc369b13a7 Initial commit 2026-04-12 11:12:32 -04:00
65 changed files with 0 additions and 8207 deletions
-27
View File
@@ -1,27 +0,0 @@
.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
@@ -1,48 +0,0 @@
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
-51
View File
@@ -1,51 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# alert persistence
/data/alerts.json
/data/alerts.json.migrated
/data/alerts.db
/data/alerts.db-shm
/data/alerts.db-wal
# settings (API keys — never commit)
/data/settings.json
-5
View File
@@ -1,5 +0,0 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices.
<!-- END:nextjs-agent-rules -->
-1
View File
@@ -1 +0,0 @@
@AGENTS.md
-45
View File
@@ -1,45 +0,0 @@
# 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"]
-236
View File
@@ -1,238 +1,2 @@
# OverSnitch
A self-hosted dashboard for monitoring Overseerr/Jellyseerr users — who's requesting, how much storage they're consuming, how often they actually watch what they request, and whether anything needs your attention.
Built with Next.js 16, TypeScript, and Tailwind CSS.
---
## Features
- **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
- **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
- **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
- **SWR caching** — stats are cached server-side for 5 minutes and seeded from localStorage on the client, so the dashboard is instant on return visits
---
## Setup
### 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
git clone https://gitea.thewrightserver.net/josh/OverSnitch.git
cd OverSnitch
npm install
npm run dev # or: npm run build && npm start
```
Configure through the Settings UI, or create `.env.local` with any of:
```env
# Required
SEERR_URL=http://overseerr:5055
SEERR_API=your_overseerr_api_key
RADARR_URL=http://radarr:7878
RADARR_API=your_radarr_api_key
SONARR_URL=http://sonarr:8989
SONARR_API=your_sonarr_api_key
# Optional — enables watch time stats and ghost/watch-rate alerts
TAUTULLI_URL=http://tautulli:8181
TAUTULLI_API=your_tautulli_api_key
# Optional — Discord webhook for new-alert notifications
DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
# Optional — if your services use self-signed certs
# 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
When configured, OverSnitch posts a structured embed to your Discord channel whenever an alert is newly opened or reopens after being resolved. Already-open alerts refreshing their data do not re-notify.
Each embed is formatted by category:
| Category | Fields shown |
|---|---|
| Not Downloaded | Requested by · Approved N ago · Status |
| Incomplete Download | Requested by · Approved N ago · Downloaded X/Y episodes |
| Pending Approval | Requested by · Waiting N days |
| Ghost Requester | User · description |
| Low Watch Rate | User · Watch rate · Plays · Requests |
Configure the webhook URL in the Settings UI or via `DISCORD_WEBHOOK` in `.env.local`. Use the **Test** button to send a sample embed before saving.
---
## Alerts
Alerts are generated on every stats refresh and persisted in `data/alerts.db` (SQLite, gitignored). They have two states — **Open** and **Closed** — and can be manually closed with a per-category cooldown, or auto-resolved when the underlying condition clears.
The alert detail page shows structured metadata (requesters, age, episode progress bars, watch-rate stats), direct links to the item in Radarr/Sonarr, a link to the Overseerr/Jellyseerr media page, and a comment thread for notes.
### Content alerts
These are keyed per piece of media, not per user. If multiple users requested the same item they're grouped into a single alert.
---
#### Not Downloaded
> A movie or TV show was approved but no file exists in Radarr/Sonarr.
| Parameter | Default | Description |
|---|---|---|
| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. Prevents noise on brand-new requests. |
- Skipped if Radarr reports `isAvailable: false` (unreleased) or Sonarr reports `status: "upcoming"`.
- **Auto-resolves** when the file appears.
- Manual close: no cooldown — reopens on the next refresh if the file still isn't there.
---
#### Incomplete Download
> An ended TV series is missing one or more episodes.
| Parameter | Default | Description |
|---|---|---|
| `UNFULFILLED_MIN_AGE_HOURS` | `12` | Hours since approval before alerting. |
| Completion threshold | `100%` | Any missing episode on a finished series triggers this alert. |
- Only fires for series with `status: "ended"` in Sonarr. Continuing shows are excluded because missing episodes may not have aired yet.
- Completion is calculated as `episodeFileCount / totalEpisodeCount` (not Sonarr's `percentOfEpisodes`, which measures against monitored episodes only).
- **Auto-resolves** when all episodes are on disk.
- Manual close: no cooldown — reopens on the next refresh if episodes are still missing.
---
#### Pending Approval
> A request has been sitting unapproved for too long.
| Parameter | Default | Description |
|---|---|---|
| `PENDING_MIN_AGE_DAYS` | `2` | Days a request must be pending before alerting. |
- One alert per request item, not per user.
- Skipped if the content is unreleased.
- **Auto-resolves** when the request is approved or declined.
- Manual close: no cooldown — reopens on the next refresh if still pending.
---
### User behavior alerts
These fire once per user. Ghost Requester takes priority over Low Watch Rate — a user will only ever have one behavior alert open at a time. Both require the user to be "established" (at least one request older than `USER_MIN_AGE_DAYS`) to avoid flagging new users.
> Requires Tautulli to be configured.
| Parameter | Default | Description |
|---|---|---|
| `USER_MIN_AGE_DAYS` | `14` | Days since oldest request before a user is eligible for behavior alerts. |
---
#### Ghost Requester
> A user hasn't watched anything on Plex since before their last N approved requests were made.
Rather than checking lifetime play counts, this looks at recency: if a user's last Plex activity predates all of their most recent N approved requests, they're not watching what they're requesting.
| Parameter | Default | Description |
|---|---|---|
| `GHOST_RECENT_REQUESTS` | `5` | Number of recent approved requests to evaluate. Also the minimum required before the alert can fire. |
- Manual close cooldown: **7 days**.
---
#### Low Watch Rate
> A user watches a small fraction of what they request.
| Parameter | Default | Description |
|---|---|---|
| `MIN_REQUESTS_WATCHRATE` | `10` | Minimum requests before the ratio is considered meaningful. |
| `LOW_WATCH_RATE` | `0.2` | Ratio of `plays / requests` below which an alert fires (default: under 20%). |
- Manual close cooldown: **7 days**.
---
### System alerts
#### No Tautulli Watch Data
> Tautulli is configured but no plays matched any Overseerr user.
This usually means emails don't align between the two services. Check that users have the same email address in both Overseerr and Tautulli (or that display names match as a fallback).
- Manual close: no cooldown.
---
## Alert lifecycle
```
Condition detected
[OPEN] ◄──────────────────────────────────────┐
│ │
┌────┴───────────────┐ Cooldown │
│ │ expires │
▼ ▼ │
Condition Manually │
clears closed │
│ │ │
▼ ▼ │
[AUTO-RESOLVED] [CLOSED] ──── Condition ────────┘
no cooldown cooldown returns after
reopens suppresses cooldown
immediately re-open
```
- **Auto-resolved** alerts reopen immediately if the condition returns.
- **Content alerts** (unfulfilled, pending) have no cooldown on manual close — they reopen on the next refresh if the condition still exists. Closing is an acknowledgment, not a suppression.
- **User-behavior alerts** (ghost, watchrate) suppress re-opening for 7 days after a manual close.
- Reopening a manually closed alert via the UI always clears the cooldown immediately.
-24
View File
@@ -1,24 +0,0 @@
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
@@ -1,10 +0,0 @@
#!/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 "$@"
-7
View File
@@ -1,7 +0,0 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;
-2596
View File
File diff suppressed because it is too large Load Diff
-26
View File
@@ -1,26 +0,0 @@
{
"name": "oversnitch",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"better-sqlite3": "^12.8.0",
"next": "16.2.3",
"react": "19.2.4",
"react-dom": "19.2.4",
"recharts": "^3.8.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"typescript": "^5"
}
}
-7
View File
@@ -1,7 +0,0 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
-1
View File
@@ -1 +0,0 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

Before

Width:  |  Height:  |  Size: 391 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

Before

Width:  |  Height:  |  Size: 1.0 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

Before

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

Before

Width:  |  Height:  |  Size: 128 B

-1
View File
@@ -1 +0,0 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

Before

Width:  |  Height:  |  Size: 385 B

-594
View File
@@ -1,594 +0,0 @@
"use client";
import { useState, useRef, useEffect, type ReactNode } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Alert, AlertSeverity, AlertComment } from "@/lib/types";
const severityAccent: Record<AlertSeverity, string> = {
danger: "border-l-red-500",
warning: "border-l-yellow-500",
info: "border-l-blue-500",
};
const severityText: Record<AlertSeverity, string> = {
danger: "text-red-400",
warning: "text-yellow-400",
info: "text-blue-400",
};
const severityLabel: Record<AlertSeverity, string> = {
danger: "Critical",
warning: "Warning",
info: "Info",
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
if (mins < 1) return "just now";
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
return `${Math.floor(hrs / 24)}d ago`;
}
function fullDate(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
month: "short", day: "numeric", year: "numeric",
hour: "numeric", minute: "2-digit",
});
}
function shortDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
month: "short", day: "numeric", hour: "numeric", minute: "2-digit",
});
}
// ── Chip ──────────────────────────────────────────────────────────────────────
function Chip({ label, dim }: { label: string; dim?: boolean }) {
return (
<span className={`inline-flex items-center rounded-md border px-2.5 py-1 text-xs font-medium ${
dim
? "border-slate-700/40 bg-slate-800/40 text-slate-500"
: "border-slate-700 bg-slate-800 text-slate-300"
}`}>
{label}
</span>
);
}
// ── External link icon ────────────────────────────────────────────────────────
function ExternalIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25" />
</svg>
);
}
// ── Episode progress bar ──────────────────────────────────────────────────────
function EpisodeBar({ downloaded, total }: { downloaded: number; total: number }) {
const pct = Math.round((downloaded / total) * 100);
return (
<div className="space-y-1.5 pt-1">
<div className="flex justify-between items-center">
<span className="text-xs font-medium text-slate-400">Episodes downloaded</span>
<span className="text-xs tabular-nums text-slate-500">{downloaded} / {total} ({pct}%)</span>
</div>
<div className="h-1.5 w-full rounded-full bg-slate-700 overflow-hidden">
<div
className="h-full rounded-full bg-yellow-500/80 transition-all"
style={{ width: `${pct}%` }}
/>
</div>
</div>
);
}
// ── Structured description for content alerts ────────────────────────────────
interface DescRow { label: string; value?: string; chips?: ReactNode }
function DescriptionTable({ rows }: { rows: DescRow[] }) {
return (
<div className="space-y-2.5">
{rows.map(({ label, value, chips }) => (
<div key={label} className="flex gap-3 text-sm">
<span className="w-28 shrink-0 text-slate-600">{label}</span>
{chips ?? <span className="text-slate-300">{value}</span>}
</div>
))}
</div>
);
}
interface ContentDescriptionProps {
description: string;
category: string;
requesterIds?: number[];
seerrUrl?: string;
}
function RequesterChips({
names,
requesterIds,
seerrUrl,
}: {
names: string[];
requesterIds?: number[];
seerrUrl?: string;
}) {
return (
<span>
{names.map((name, i) => {
const uid = requesterIds?.[i];
const href = uid && seerrUrl ? `${seerrUrl}/users/${uid}` : null;
return (
<span key={i}>
{i > 0 && <span className="text-slate-600">, </span>}
{href ? (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-slate-300 hover:text-white transition-colors"
>
{name}
</a>
) : (
<span className="text-slate-300">{name}</span>
)}
</span>
);
})}
</span>
);
}
function ContentDescription({ description, category, requesterIds, seerrUrl }: ContentDescriptionProps) {
if (category === "unfulfilled") {
// Partial TV: "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
const partial = description.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
if (partial) {
const [, age, reqStr] = partial;
const names = reqStr.split(", ").filter(Boolean);
return (
<DescriptionTable rows={[
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
{ label: "Approved", value: `${age} ago` },
]} />
);
}
// Complete miss: "Approved N ago but no file found in Radarr/Sonarr. Requested by Y."
const complete = description.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
if (complete) {
const [, age, detail, reqStr] = complete;
const names = reqStr.split(", ").filter(Boolean);
return (
<DescriptionTable rows={[
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
{ label: "Approved", value: `${age} ago` },
{ label: "Details", value: detail.charAt(0).toUpperCase() + detail.slice(1) },
]} />
);
}
}
if (category === "pending") {
// "Awaiting approval for N days. Requested by Y."
const m = description.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
if (m) {
const [, age, reqStr] = m;
const names = reqStr.split(", ").filter(Boolean);
return (
<DescriptionTable rows={[
{ label: "Requested by", chips: <RequesterChips names={names} requesterIds={requesterIds} seerrUrl={seerrUrl} /> },
{ label: "Waiting", value: age },
]} />
);
}
}
// Fallback: plain prose (ghost, watchrate handled separately, tautulli-no-matches)
return <p className="text-sm text-slate-400 leading-relaxed">{description}</p>;
}
// ── Watch rate stat block ─────────────────────────────────────────────────────
function WatchrateBlock({ plays, requests, pct }: { plays: number; requests: number; pct: number }) {
return (
<div className="rounded-lg border border-slate-700/60 bg-slate-900/50 px-4 py-3">
<div className="flex items-end gap-4 flex-wrap">
<div>
<div className="text-xl font-bold tabular-nums text-white">{plays.toLocaleString()}</div>
<div className="text-xs text-slate-500 mt-0.5">plays</div>
</div>
<div className="text-slate-700 pb-4 text-base">/</div>
<div>
<div className="text-xl font-bold tabular-nums text-white">{requests}</div>
<div className="text-xs text-slate-500 mt-0.5">requests</div>
</div>
<div className="text-slate-700 pb-4 text-base">=</div>
<div>
<div className="text-xl font-bold tabular-nums text-blue-400">{pct}%</div>
<div className="text-xs text-slate-500 mt-0.5">watch rate</div>
</div>
</div>
<p className="mt-2 text-xs text-slate-600">Alert threshold: &lt;20%</p>
</div>
);
}
// ── Comment row ───────────────────────────────────────────────────────────────
function CommentRow({ comment }: { comment: AlertComment }) {
const isSystem = comment.author === "system";
if (isSystem) {
return (
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-slate-800" />
<div className="flex items-center gap-1.5 text-xs text-slate-600 shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-3 w-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
<span className="italic">{comment.body}</span>
<span className="text-slate-700">·</span>
<span>{timeAgo(comment.createdAt)}</span>
</div>
<div className="h-px flex-1 bg-slate-800" />
</div>
);
}
return (
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-slate-700 flex items-center justify-center shrink-0">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-3.5 w-3.5 text-slate-400">
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.501 20.118a7.5 7.5 0 0 1 14.998 0A17.933 17.933 0 0 1 12 21.75c-2.676 0-5.216-.584-7.499-1.632Z" />
</svg>
</div>
<span className="text-xs font-semibold text-slate-300">User</span>
<span className="text-xs text-slate-600">{shortDate(comment.createdAt)}</span>
</div>
<div className="ml-8">
<p className="text-sm text-slate-300 whitespace-pre-wrap leading-relaxed bg-slate-800/60 rounded-lg border border-slate-700/40 px-4 py-3">
{comment.body}
</p>
</div>
</div>
);
}
// ── Helpers: parse structured data from description prose ─────────────────────
function parseEpisodeCounts(desc: string): { downloaded: number; total: number } | null {
const m = desc.match(/\((\d+)\/(\d+)\)/);
if (!m) return null;
return { downloaded: parseInt(m[1]), total: parseInt(m[2]) };
}
function parseWatchrateStats(desc: string): { plays: number; requests: number; pct: number } | null {
const pctM = desc.match(/~(\d+)%/);
const playsM = desc.match(/\((\d+) plays/);
const reqM = desc.match(/plays, (\d+) requests\)/);
if (!pctM || !playsM || !reqM) return null;
return { pct: parseInt(pctM[1]), plays: parseInt(playsM[1]), requests: parseInt(reqM[1]) };
}
// ── Main ──────────────────────────────────────────────────────────────────────
interface Props {
initialAlert: Alert;
radarrUrl?: string;
sonarrUrl?: string;
seerrUrl?: string;
}
export default function AlertDetail({ initialAlert, radarrUrl, sonarrUrl, seerrUrl }: Props) {
const router = useRouter();
const [alert, setAlert] = useState<Alert>(initialAlert);
const [actionLoading, setActionLoading] = useState(false);
const [commentText, setCommentText] = useState("");
const [commentLoading, setCommentLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const bottomRef = useRef<HTMLDivElement>(null);
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [alert.comments.length]);
async function toggleStatus() {
setActionLoading(true);
setError(null);
try {
const newStatus = alert.status === "open" ? "closed" : "open";
const res = await fetch(`/api/alerts/${alert.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status: newStatus }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const updated: Alert = await res.json();
setAlert(updated);
try {
const raw = localStorage.getItem("oversnitch_stats");
if (raw) {
const stats = JSON.parse(raw);
const delta = updated.status === "open" ? 1 : -1;
stats.summary.openAlertCount = Math.max(0, (stats.summary.openAlertCount ?? 0) + delta);
localStorage.setItem("oversnitch_stats", JSON.stringify(stats));
}
} catch {}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setActionLoading(false);
}
}
async function submitComment(e: React.FormEvent) {
e.preventDefault();
if (!commentText.trim()) return;
setCommentLoading(true);
setError(null);
try {
const res = await fetch(`/api/alerts/${alert.id}/comments`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: commentText.trim() }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const comment = await res.json();
setAlert((prev) => ({ ...prev, comments: [...prev.comments, comment] }));
setCommentText("");
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setCommentLoading(false);
}
}
const isOpen = alert.status === "open";
const isResolved = alert.closeReason === "resolved";
// ── Derived data ────────────────────────────────────────────────────────────
// Search fallback when media exists in description but not yet in *arr
const searchUrl =
!alert.mediaUrl && alert.mediaTitle
? alert.mediaType === "movie" && radarrUrl
? `${radarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
: alert.mediaType === "tv" && sonarrUrl
? `${sonarrUrl}/add/new?term=${encodeURIComponent(alert.mediaTitle)}`
: null
: null;
// User dashboard link for behavior alerts
const userLink =
(alert.category === "ghost" || alert.category === "watchrate") && alert.userId
? `/?tab=leaderboard`
: null;
// Category-specific parsed data
const episodeCounts =
alert.category === "unfulfilled" && alert.mediaType === "tv"
? parseEpisodeCounts(alert.description)
: null;
const watchrateStats =
alert.category === "watchrate" ? parseWatchrateStats(alert.description) : null;
return (
<main className="mx-auto max-w-5xl px-6 py-8 space-y-6">
{/* Back */}
<button
onClick={() => router.back()}
className="inline-flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-300 transition-colors cursor-pointer"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
</svg>
All Alerts
</button>
{error && (
<div className="rounded-lg border border-red-800 bg-red-950/30 px-4 py-3 text-red-300 text-sm">
{error}
</div>
)}
{/* ── Alert overview ──────────────────────────────────────────────── */}
<div className={`rounded-xl bg-slate-800/40 border border-slate-700/60 border-l-4 overflow-hidden ${severityAccent[alert.severity]}`}>
<div className="px-6 py-6 space-y-4">
{/* Top row: severity + status | action buttons */}
<div className="flex items-center justify-between gap-3 flex-wrap">
{/* Left: severity + status + timestamps */}
<div className="flex items-center gap-2.5 flex-wrap">
<span className={`text-xs font-bold uppercase tracking-widest ${severityText[alert.severity]}`}>
{severityLabel[alert.severity]}
</span>
<span className="text-slate-700">·</span>
<span className={`inline-flex items-center gap-1.5 text-xs font-semibold ${
isOpen ? "text-green-400" : isResolved ? "text-teal-400" : "text-slate-500"
}`}>
{isOpen && <span className="h-1.5 w-1.5 rounded-full bg-green-400 shrink-0" />}
{isOpen ? "Open" : isResolved ? "Auto-resolved" : "Closed"}
</span>
<span className="text-slate-700">·</span>
<span className="text-xs text-slate-500">Opened {timeAgo(alert.firstSeen)}</span>
<span className="text-slate-700">·</span>
<span className="text-xs text-slate-500">Checked {timeAgo(alert.lastSeen)}</span>
</div>
{/* Right: action buttons */}
<div className="flex items-center gap-2 flex-wrap">
{/* Search fallback (not yet in *arr) */}
{searchUrl && (
<a
href={searchUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-400 hover:text-white transition-colors"
>
{alert.mediaType === "movie" ? "Search in Radarr" : "Search in Sonarr"}
<ExternalIcon />
</a>
)}
{/* Primary: view in Radarr/Sonarr */}
{alert.mediaUrl && (
<a
href={alert.mediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
>
{alert.mediaType === "movie" ? "View in Radarr" : "View in Sonarr"}
<ExternalIcon />
</a>
)}
{/* View in Seerr */}
{alert.seerrMediaUrl && (
<a
href={alert.seerrMediaUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-700/60 hover:bg-slate-700 px-3 py-1.5 text-xs font-semibold text-slate-300 hover:text-white transition-colors"
>
View in Seerr
<ExternalIcon />
</a>
)}
{/* Close / Reopen */}
<button
onClick={toggleStatus}
disabled={actionLoading}
className={`rounded-lg px-4 py-1.5 text-xs font-semibold transition-colors disabled:opacity-40 disabled:cursor-not-allowed ${
isOpen
? "bg-slate-700 hover:bg-slate-600 text-white"
: "bg-green-900/50 hover:bg-green-800/50 text-green-300 border border-green-800"
}`}
>
{actionLoading ? "…" : isOpen ? "Close" : "Reopen"}
</button>
</div>
</div>
{/* Title */}
<h1 className="text-2xl font-bold text-white leading-snug">{alert.title}</h1>
{/* Category body */}
<div className="space-y-3">
{/* Description — structured for content alerts, prose for others */}
{alert.category !== "watchrate" && (
<ContentDescription
description={alert.description}
category={alert.category}
requesterIds={alert.requesterIds}
seerrUrl={seerrUrl}
/>
)}
{/* Watchrate structured stat block */}
{watchrateStats && (
<WatchrateBlock
plays={watchrateStats.plays}
requests={watchrateStats.requests}
pct={watchrateStats.pct}
/>
)}
{/* Episode progress bar for partial TV downloads */}
{episodeCounts && (
<EpisodeBar
downloaded={episodeCounts.downloaded}
total={episodeCounts.total}
/>
)}
{/* User chip linking to dashboard for behavior alerts */}
{userLink && alert.userName && (
<div className="flex items-center gap-2 pt-0.5">
<span className="text-xs text-slate-600">User</span>
<Link
href={userLink}
className="inline-flex items-center gap-1.5 rounded-md border border-slate-700 bg-slate-800 hover:bg-slate-700 px-2.5 py-1 text-xs font-medium text-slate-300 hover:text-white transition-colors"
>
{alert.userName}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3 w-3">
<path strokeLinecap="round" strokeLinejoin="round" d="M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3" />
</svg>
</Link>
</div>
)}
</div>
{/* Metadata footer — closed/resolved date only */}
{!isOpen && alert.closedAt && (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-slate-700/30">
<Chip label={`${isResolved ? "Resolved" : "Closed"} ${fullDate(alert.closedAt)}`} dim />
</div>
)}
</div>
</div>
{/* ── Comments ────────────────────────────────────────────────────── */}
<section className="space-y-4">
<h2 className="text-xs font-semibold uppercase tracking-widest text-slate-600">
Comments
</h2>
<div className="space-y-4">
{alert.comments.length === 0 && (
<p className="text-sm text-slate-700">No comments yet.</p>
)}
{alert.comments.map((c) => (
<CommentRow key={c.id} comment={c} />
))}
<div ref={bottomRef} />
</div>
<form onSubmit={submitComment} className="flex flex-col gap-2 pt-2 border-t border-slate-800">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
if (commentText.trim()) submitComment(e as unknown as React.FormEvent);
}
}}
placeholder="Add a comment…"
rows={3}
className="w-full rounded-lg bg-slate-800/40 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-4 py-3 focus:outline-none resize-none transition-colors"
/>
<div className="flex items-center justify-between">
<span className="text-xs text-slate-700"> to submit</span>
<button
type="submit"
disabled={commentLoading || !commentText.trim()}
className="rounded-lg bg-slate-700 hover:bg-slate-600 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-medium text-white transition-colors"
>
{commentLoading ? "Saving…" : "Comment"}
</button>
</div>
</form>
</section>
</main>
);
}
-23
View File
@@ -1,23 +0,0 @@
import { getAlertById } from "@/lib/db";
import { getSettings } from "@/lib/settings";
import { notFound } from "next/navigation";
import AlertDetail from "./AlertDetail";
export default async function AlertPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const alert = getAlertById(Number(id));
if (!alert) notFound();
const { radarr, sonarr, seerr } = getSettings();
return (
<AlertDetail
initialAlert={alert}
radarrUrl={radarr.url || undefined}
sonarrUrl={sonarr.url || undefined}
seerrUrl={seerr.url || undefined}
/>
);
}
-26
View File
@@ -1,26 +0,0 @@
import { addComment } from "@/lib/db";
export async function POST(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
let body: { body: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (!body.body?.trim()) {
return Response.json({ error: "Comment body is required" }, { status: 400 });
}
const comment = addComment(Number(id), body.body.trim());
if (!comment) {
return Response.json({ error: "Alert not found" }, { status: 404 });
}
return Response.json(comment, { status: 201 });
}
-42
View File
@@ -1,42 +0,0 @@
import { getAlertById, closeAlert, reopenAlert } from "@/lib/db";
export async function GET(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const alert = getAlertById(Number(id));
if (!alert) {
return Response.json({ error: "Alert not found" }, { status: 404 });
}
return Response.json(alert);
}
export async function PATCH(
req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const numId = Number(id);
let body: { status: string };
try {
body = await req.json();
} catch {
return Response.json({ error: "Invalid JSON" }, { status: 400 });
}
if (body.status === "closed") {
const updated = closeAlert(numId);
if (!updated) return Response.json({ error: "Alert not found" }, { status: 404 });
return Response.json(updated);
}
if (body.status === "open") {
const updated = reopenAlert(numId);
if (!updated) return Response.json({ error: "Alert not found" }, { status: 404 });
return Response.json(updated);
}
return Response.json({ error: "status must be 'open' or 'closed'" }, { status: 400 });
}
-11
View File
@@ -1,11 +0,0 @@
import { getAllAlerts } from "@/lib/db";
export async function GET() {
try {
const alerts = getAllAlerts();
return Response.json(alerts);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
}
}
-16
View File
@@ -1,16 +0,0 @@
import { getSettings, saveSettings, AppSettings } from "@/lib/settings";
export async function GET() {
return Response.json(getSettings());
}
export async function PUT(req: Request) {
try {
const body = await req.json() as AppSettings;
const saved = saveSettings(body);
return Response.json(saved);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 400 });
}
}
-121
View File
@@ -1,121 +0,0 @@
/**
* POST /api/settings/test
* Tests connectivity to a service using the provided URL + API key.
* Does NOT save anything — purely a connectivity check.
*/
import { sendDiscordTestNotification } from "@/lib/discord";
interface TestBody {
service: "radarr" | "sonarr" | "seerr" | "tautulli" | "discord";
url: string;
apiKey: string;
}
interface TestResult {
ok: boolean;
message: string;
}
const TIMEOUT_MS = 10_000;
async function testRadarr(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(`${url}/api/v3/system/status`, {
headers: { "X-Api-Key": apiKey },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { version?: string };
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
}
async function testSonarr(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(`${url}/api/v3/system/status`, {
headers: { "X-Api-Key": apiKey },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { version?: string };
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
}
async function testSeerr(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(`${url}/api/v1/status`, {
headers: { "X-Api-Key": apiKey },
signal: AbortSignal.timeout(TIMEOUT_MS),
});
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { version?: string };
return { ok: true, message: `Connected${data.version ? ` (v${data.version})` : ""}` };
}
async function testTautulli(url: string, apiKey: string): Promise<TestResult> {
const res = await fetch(
`${url}/api/v2?apikey=${encodeURIComponent(apiKey)}&cmd=get_server_info`,
{ signal: AbortSignal.timeout(TIMEOUT_MS) }
);
if (!res.ok) return { ok: false, message: `HTTP ${res.status} ${res.statusText}` };
const data = await res.json() as { response?: { result?: string; data?: { pms_version?: string } } };
if (data.response?.result !== "success") {
return { ok: false, message: "Tautulli returned a non-success result" };
}
const ver = data.response.data?.pms_version;
return { ok: true, message: `Connected${ver ? ` (Plex ${ver})` : ""}` };
}
export async function POST(req: Request) {
try {
const { service, url, apiKey } = await req.json() as TestBody;
// Discord only needs the webhook URL (passed as `url`)
if (service === "discord") {
if (!url) {
return Response.json({ ok: false, message: "Webhook URL is required" }, { status: 400 });
}
try {
new URL(url);
} catch {
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
}
await sendDiscordTestNotification(url.trim());
return Response.json({ ok: true, message: "Test message sent — check your channel" });
}
if (!url || !apiKey) {
return Response.json({ ok: false, message: "URL and API key are required" }, { status: 400 });
}
// Basic URL validation
try {
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol)) {
return Response.json({ ok: false, message: "URL must use http or https" }, { status: 400 });
}
} catch {
return Response.json({ ok: false, message: "Invalid URL" }, { status: 400 });
}
const trimmed = url.replace(/\/+$/, "");
let result: TestResult;
switch (service) {
case "radarr": result = await testRadarr(trimmed, apiKey); break;
case "sonarr": result = await testSonarr(trimmed, apiKey); break;
case "seerr": result = await testSeerr(trimmed, apiKey); break;
case "tautulli": result = await testTautulli(trimmed, apiKey); break;
default:
return Response.json({ ok: false, message: "Unknown service" }, { status: 400 });
}
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
// Provide friendlier messages for common failures
const friendly = message.includes("fetch failed") || message.includes("ECONNREFUSED")
? "Connection refused — check the URL and that the service is running"
: message.includes("TimeoutError") || message.includes("abort")
? "Connection timed out"
: message;
return Response.json({ ok: false, message: friendly });
}
}
-15
View File
@@ -1,15 +0,0 @@
import { getStats } from "@/lib/statsBuilder";
import { isConfigured } from "@/lib/settings";
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");
try {
return Response.json(await getStats(force));
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
}
}
-115
View File
@@ -1,115 +0,0 @@
import { getStats, getRawCache, RawCache } from "@/lib/statsBuilder";
import { getAllAlerts } from "@/lib/db";
import { enrichRequests } from "@/lib/enrichRequests";
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(
_req: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params;
const userId = parseInt(id, 10);
if (isNaN(userId)) {
return Response.json({ error: "Invalid user ID" }, { status: 400 });
}
try {
const stats = await getStats();
const stat = stats.users.find((u) => u.userId === userId);
if (!stat) {
return Response.json({ error: "User not found" }, { status: 404 });
}
const raw = getRawCache();
if (!raw) {
return Response.json({ error: "Raw cache unavailable" }, { status: 503 });
}
const seerrBaseUrl = getSettings().seerr.url || undefined;
const enrichedRequests = enrichRequests(
raw.allRequests.get(userId) ?? [],
raw.radarrMap,
raw.sonarrMap,
seerrBaseUrl
);
const watchHistory = raw.watchHistoryMap.get(userId) ?? [];
const openAlerts = getAllAlerts().filter(
(a) =>
a.status === "open" &&
(a.userId === userId || a.requesterIds?.includes(userId))
);
const serverAverages = computeServerAverages(stats.users, raw);
const result: UserPageData = {
stat,
enrichedRequests,
watchHistory,
openAlerts,
serverAverages,
};
return Response.json(result);
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return Response.json({ error: message }, { status: 500 });
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

-7
View File
@@ -1,7 +0,0 @@
@import "tailwindcss";
body {
background: #020617; /* slate-950 */
color: #f8fafc;
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
}
-33
View File
@@ -1,33 +0,0 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "OverSnitch",
description: "Overseerr user request analytics",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full bg-slate-950 text-white">{children}</body>
</html>
);
}
-210
View File
@@ -1,210 +0,0 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { DashboardStats } from "@/lib/types";
import { timeAgo } from "@/lib/format";
import SummaryCards from "@/components/SummaryCards";
import LeaderboardTable from "@/components/LeaderboardTable";
import AlertsPanel from "@/components/AlertsPanel";
import RefreshButton from "@/components/RefreshButton";
import SettingsModal from "@/components/SettingsModal";
import SetupScreen from "@/components/SetupScreen";
type Tab = "leaderboard" | "alerts";
const LS_KEY = "oversnitch_stats";
export default function Page() {
const [tab, setTab] = useState<Tab>("leaderboard");
const [data, setData] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [settingsOpen, setSettingsOpen] = useState(false);
const [needsSetup, setNeedsSetup] = useState(false);
const didInit = useRef(false);
const load = useCallback(async (force = false) => {
setData((prev) => {
if (prev) setRefreshing(true);
else setLoading(true);
return prev;
});
setError(null);
try {
const res = await fetch(force ? "/api/stats?force=1" : "/api/stats");
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}`);
const stats = json as DashboardStats;
setNeedsSetup(false);
setData(stats);
try { localStorage.setItem(LS_KEY, JSON.stringify(stats)); } catch {}
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
if (didInit.current) return;
didInit.current = true;
try {
const raw = localStorage.getItem(LS_KEY);
if (raw) setData(JSON.parse(raw) as DashboardStats);
} catch {}
load();
}, [load]);
// Poll every 2 minutes to keep the UI fresh against the server cache.
// The server itself refreshes every 5 min via the background poller.
useEffect(() => {
const id = setInterval(() => load(), 2 * 60 * 1000);
return () => clearInterval(id);
}, [load]);
const hasTautulli = data?.summary.totalWatchHours !== null;
const openAlertCount = data?.summary.openAlertCount ?? 0;
const generatedAt = data?.generatedAt ?? null;
if (needsSetup) {
return (
<SetupScreen
onComplete={() => {
setNeedsSetup(false);
load(true);
}}
/>
);
}
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">
Over<span className="text-yellow-400">Snitch</span>
</h1>
<p className="text-slate-400 text-sm mt-1.5">Request &amp; usage analytics</p>
</div>
<div className="flex flex-col items-end gap-1.5 shrink-0">
<div className="flex items-center gap-2">
<button
onClick={() => setSettingsOpen(true)}
className="rounded-lg border border-slate-700/60 bg-slate-800/40 hover:bg-slate-800 p-2 text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Settings"
title="Settings"
>
<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="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.43l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</button>
<RefreshButton onRefresh={() => load(true)} loading={refreshing || loading} />
</div>
{generatedAt && (
<span className="text-xs text-slate-600">
{refreshing
? <span className="text-yellow-600/80">Refreshing</span>
: <>Updated {timeAgo(generatedAt)}</>
}
</span>
)}
</div>
</div>
{/* First-ever load spinner */}
{loading && !data && (
<div className="flex flex-col items-center justify-center py-24 gap-4">
<svg
className="animate-spin h-8 w-8 text-yellow-400"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<div className="text-center space-y-1">
<p className="text-slate-300 text-sm font-medium">Fetching data</p>
<p className="text-slate-600 text-xs">This only takes a moment on first load.</p>
</div>
</div>
)}
{/* Error */}
{error && (
<div className="rounded-xl border border-red-800 bg-red-950/40 px-5 py-4 text-sm">
<span className="font-semibold text-red-400">Error: </span>
<span className="text-red-300">{error}</span>
</div>
)}
{data && (
<>
<SummaryCards
totalUsers={data.summary.totalUsers}
totalRequests={data.summary.totalRequests}
totalStorageGB={data.summary.totalStorageGB}
totalWatchHours={data.summary.totalWatchHours}
openAlertCount={data.summary.openAlertCount}
onAlertsClick={() => setTab("alerts")}
/>
{/* Tab bar */}
<div className="border-b border-slate-700/60">
<div className="flex gap-1 -mb-px">
{(["leaderboard", "alerts"] as const).map((t) => (
<button
key={t}
onClick={() => setTab(t)}
className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors cursor-pointer ${
tab === t
? "border-yellow-400 text-white"
: "border-transparent text-slate-500 hover:text-slate-300"
}`}
>
{t === "alerts" ? (
<span className="flex items-center gap-2">
Alerts
{openAlertCount > 0 && (
<span className="rounded-full bg-yellow-500 text-black text-xs font-bold px-1.5 py-0.5 leading-none">
{openAlertCount}
</span>
)}
</span>
) : (
"Leaderboard"
)}
</button>
))}
</div>
</div>
{tab === "leaderboard" && (
<LeaderboardTable users={data.users} hasTautulli={hasTautulli} />
)}
{tab === "alerts" && <AlertsPanel />}
</>
)}
<SettingsModal
open={settingsOpen}
onClose={() => setSettingsOpen(false)}
onSaved={() => load(true)}
/>
</main>
);
}
-252
View File
@@ -1,252 +0,0 @@
"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>
);
}
-77
View File
@@ -1,77 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { UserPageData } from "@/lib/types";
import UserHeader from "./UserHeader";
import UserStatCards from "./UserStatCards";
import UserActivityChart from "./UserActivityChart";
import UserRequestHistory from "./UserRequestHistory";
import UserOpenAlerts from "./UserOpenAlerts";
export default function UserDetail({ userId }: { userId: number }) {
const [data, setData] = useState<UserPageData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then((r) => r.json())
.then((json) => {
if (json.error) throw new Error(json.error);
setData(json as UserPageData);
})
.catch((e) => setError(e instanceof Error ? e.message : String(e)))
.finally(() => setLoading(false));
}, [userId]);
if (loading) {
return (
<main className="mx-auto max-w-6xl px-4 py-8">
<div className="flex flex-col items-center justify-center py-24 gap-4">
<svg className="animate-spin h-8 w-8 text-yellow-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p className="text-slate-400 text-sm">Loading user data</p>
</div>
</main>
);
}
if (error || !data) {
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-4">
<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="rounded-xl border border-red-800 bg-red-950/40 px-5 py-4 text-sm">
<span className="font-semibold text-red-400">Error: </span>
<span className="text-red-300">{error ?? "User not found"}</span>
</div>
</main>
);
}
const { stat, enrichedRequests, watchHistory, openAlerts, serverAverages } = data;
return (
<main className="mx-auto max-w-6xl px-4 py-8 space-y-6">
<UserHeader stat={stat} />
<UserStatCards stat={stat} />
<UserActivityChart
stat={stat}
enrichedRequests={enrichedRequests}
watchHistory={watchHistory}
serverAverages={serverAverages}
/>
<UserRequestHistory requests={enrichedRequests} />
<UserOpenAlerts alerts={openAlerts} />
</main>
);
}
-46
View File
@@ -1,46 +0,0 @@
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
@@ -1,51 +0,0 @@
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
@@ -1,115 +0,0 @@
"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
@@ -1,150 +0,0 @@
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>
);
}
-10
View File
@@ -1,10 +0,0 @@
import UserDetail from "./UserDetail";
export default async function UserPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <UserDetail userId={Number(id)} />;
}
-178
View File
@@ -1,178 +0,0 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { Alert, AlertSeverity } from "@/lib/types";
const severityIcon: Record<AlertSeverity, string> = {
danger:
"M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z",
warning:
"M12 9v3.75m9.303 3.376c.866 1.5-.217 3.374-1.948 3.374H2.645c-1.73 0-2.813-1.874-1.948-3.374l7.658-13.25c.866-1.5 3.032-1.5 3.898 0zM12 15.75h.007v.008H12v-.008z",
info: "m11.25 11.25.041-.02a.75.75 0 0 1 1.063.852l-.708 2.836a.75.75 0 0 0 1.063.853l.041-.021M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9-3.75h.008v.008H12V8.25Z",
};
const severityColor: Record<AlertSeverity, string> = {
danger: "text-red-400",
warning: "text-yellow-400",
info: "text-blue-400",
};
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const mins = Math.floor(diff / 60_000);
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);
return `${days}d ago`;
}
export default function AlertsPanel() {
const [alerts, setAlerts] = useState<Alert[]>([]);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState<"open" | "all">("open");
useEffect(() => {
fetch("/api/alerts")
.then((r) => r.json())
.then(setAlerts)
.finally(() => setLoading(false));
}, []);
const filtered =
filter === "open" ? alerts.filter((a) => a.status === "open") : alerts;
const openCount = alerts.filter((a) => a.status === "open").length;
if (loading) {
return (
<div className="text-slate-600 text-sm py-12 text-center">
Loading alerts
</div>
);
}
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="flex rounded-lg overflow-hidden border border-slate-700 text-xs font-medium">
{(["open", "all"] as const).map((f) => (
<button
key={f}
onClick={() => setFilter(f)}
className={`px-3 py-1.5 transition-colors ${
filter === f
? "bg-slate-700 text-white"
: "bg-transparent text-slate-500 hover:text-slate-300"
}`}
>
{f === "open" ? `Open (${openCount})` : `All (${alerts.length})`}
</button>
))}
</div>
<span className="text-xs text-slate-600">
Click an alert to view details, add notes, or close it.
</span>
</div>
{filtered.length === 0 && (
<div className="rounded-xl border border-slate-700/60 bg-slate-800/30 p-12 text-center">
<p className="text-slate-500 text-sm">
{filter === "open"
? "No open alerts — everything looks healthy."
: "No alerts recorded yet."}
</p>
</div>
)}
{/* Alert rows */}
<div className="space-y-2">
{filtered.map((alert) => (
<Link
key={alert.id}
href={`/alerts/${alert.id}`}
className={`flex items-start gap-3 rounded-xl border p-4 transition-colors ${
alert.status === "closed"
? "border-slate-700/40 bg-slate-800/20 opacity-50 hover:opacity-70"
: "border-slate-700/60 bg-slate-800/60 hover:bg-slate-800"
}`}
>
{/* Severity icon */}
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className={`h-5 w-5 mt-0.5 shrink-0 ${severityColor[alert.severity]}`}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d={severityIcon[alert.severity]}
/>
</svg>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-white text-sm leading-snug">
{alert.title}
</span>
{alert.status === "closed" && (
<span
className={`text-xs rounded-full px-2 py-0.5 font-medium ${
alert.closeReason === "resolved"
? "bg-teal-950/60 text-teal-500 border border-teal-800/60"
: "bg-slate-700/60 text-slate-500 border border-slate-600/60"
}`}
>
{alert.closeReason === "resolved" ? "Auto-resolved" : "Closed"}
</span>
)}
{alert.comments.length > 0 && (
<span className="inline-flex items-center gap-1 text-xs text-slate-600">
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="h-3.5 w-3.5"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M2.25 12.76c0 1.6 1.123 2.994 2.707 3.227 1.087.16 2.185.283 3.293.369V21l4.076-4.076a1.526 1.526 0 0 1 1.037-.443 48.282 48.282 0 0 0 5.68-.494c1.584-.233 2.707-1.626 2.707-3.228V6.741c0-1.602-1.123-2.995-2.707-3.228A48.394 48.394 0 0 0 12 3c-2.392 0-4.744.175-7.043.513C3.373 3.746 2.25 5.14 2.25 6.741v6.018Z"
/>
</svg>
{alert.comments.length}
</span>
)}
</div>
<p className="text-sm text-slate-500 mt-0.5 line-clamp-2 leading-relaxed">
{alert.description}
</p>
</div>
{/* Time + chevron */}
<div className="flex items-center gap-1 shrink-0 mt-0.5">
<span className="text-xs text-slate-700">{timeAgo(alert.status === "open" ? alert.firstSeen : (alert.closedAt ?? alert.firstSeen))}</span>
<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 text-slate-700"
>
<path strokeLinecap="round" strokeLinejoin="round" d="M8.25 4.5l7.5 7.5-7.5 7.5" />
</svg>
</div>
</Link>
))}
</div>
</div>
);
}
-181
View File
@@ -1,181 +0,0 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { UserStat } from "@/lib/types";
import { formatGB, formatHours } from "@/lib/format";
import RankChip from "@/components/RankChip";
type SortKey = "totalBytes" | "requestCount" | "avgGB" | "plays" | "watchHours";
interface LeaderboardTableProps {
users: UserStat[];
hasTautulli: boolean;
}
function SortChevrons({ active, asc }: { active: boolean; asc: boolean }) {
return (
<span className="ml-1 inline-flex flex-col gap-px opacity-0 group-hover:opacity-100 transition-opacity">
{active ? (
<svg
className="h-3 w-3 text-yellow-400"
viewBox="0 0 12 12"
fill="currentColor"
>
{asc
? <path d="M6 2 L10 8 L2 8 Z" />
: <path d="M6 10 L10 4 L2 4 Z" />
}
</svg>
) : (
<svg className="h-3 w-3 text-slate-600" viewBox="0 0 12 12" fill="currentColor">
<path d="M6 1 L9 5 L3 5 Z M6 11 L9 7 L3 7 Z" />
</svg>
)}
</span>
);
}
export default function LeaderboardTable({
users,
hasTautulli,
}: LeaderboardTableProps) {
const [sortKey, setSortKey] = useState<SortKey>("totalBytes");
const [sortAsc, setSortAsc] = useState(false);
const sorted = [...users].sort((a, b) => {
const av = (a[sortKey] ?? -1) as number;
const bv = (b[sortKey] ?? -1) as number;
return sortAsc ? av - bv : bv - av;
});
function handleSort(key: SortKey) {
if (key === sortKey) setSortAsc((p) => !p);
else { setSortKey(key); setSortAsc(false); }
}
function Th({
label,
col,
right,
}: {
label: string;
col?: SortKey;
right?: boolean;
}) {
const active = col === sortKey;
return (
<th
onClick={col ? () => handleSort(col) : undefined}
className={[
"group py-3 px-4 text-xs font-semibold uppercase tracking-wider whitespace-nowrap select-none",
right ? "text-right" : "text-left",
col ? "cursor-pointer" : "",
active ? "text-yellow-400" : "text-slate-500 hover:text-slate-300",
].join(" ")}
>
{label}
{col && <SortChevrons active={active} asc={sortAsc} />}
</th>
);
}
const total = users.length;
return (
<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 label="#" />
<Th label="User" />
<Th label="Requests" col="requestCount" right />
<Th label="Storage" col="totalBytes" right />
<Th label="Avg / Req" col="avgGB" right />
{hasTautulli && <Th label="Plays" col="plays" right />}
{hasTautulli && <Th label="Watch Time" col="watchHours" right />}
</tr>
</thead>
<tbody className="divide-y divide-slate-700/30">
{sorted.map((user, idx) => (
<tr
key={user.userId}
className="bg-slate-900 hover:bg-slate-800/50 transition-colors"
>
{/* Row index */}
<td className="py-3 px-4 text-slate-700 font-mono text-xs w-10 tabular-nums">
{idx + 1}
</td>
{/* User */}
<td className="py-3 px-4">
<Link
href={`/users/${user.userId}`}
className="group/name cursor-pointer"
>
<div className="font-medium text-white leading-snug group-hover/name:text-yellow-300 transition-colors">
{user.displayName}
</div>
<div className="text-xs text-slate-600 mt-0.5">{user.email}</div>
</Link>
</td>
{/* Requests */}
<td className="py-3 px-4 text-right">
<div className="text-slate-300 font-mono tabular-nums">
{user.requestCount.toLocaleString()}
</div>
<div className="mt-0.5 flex justify-end">
<RankChip rank={user.requestRank} total={total} />
</div>
</td>
{/* Storage */}
<td className="py-3 px-4 text-right">
<div className="text-white font-semibold font-mono tabular-nums">
{formatGB(user.totalGB)}
</div>
<div className="mt-0.5 flex justify-end">
<RankChip rank={user.storageRank} total={total} />
</div>
</td>
{/* Avg / Request */}
<td className="py-3 px-4 text-right text-slate-500 font-mono text-xs tabular-nums">
{user.requestCount > 0 ? formatGB(user.avgGB) : "—"}
</td>
{/* Plays */}
{hasTautulli && (
<td className="py-3 px-4 text-right">
<div className="text-slate-300 font-mono tabular-nums">
{user.plays !== null ? user.plays.toLocaleString() : "—"}
</div>
<div className="mt-0.5 flex justify-end">
<RankChip rank={user.playsRank} total={total} />
</div>
</td>
)}
{/* Watch Time */}
{hasTautulli && (
<td className="py-3 px-4 text-right">
<div className="text-slate-500 font-mono text-xs tabular-nums">
{user.watchHours !== null && user.watchHours > 0
? formatHours(user.watchHours)
: user.watchHours === 0
? "0h"
: "—"}
</div>
<div className="mt-0.5 flex justify-end">
<RankChip rank={user.watchRank} total={total} />
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
interface PercentileBadgeProps {
label: string;
}
const colorMap: Record<string, string> = {
"Top 1%": "bg-yellow-400 text-black",
"Top 5%": "bg-yellow-500 text-black",
"Top 10%": "bg-green-500 text-white",
"Top 25%": "bg-green-700 text-white",
"Top 50%": "bg-blue-600 text-white",
"Bottom 50%": "bg-slate-600 text-slate-300",
};
export default function PercentileBadge({ label }: PercentileBadgeProps) {
const colorClass = colorMap[label] ?? "bg-slate-600 text-slate-300";
return (
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-semibold ${colorClass}`}
>
{label}
</span>
);
}
-15
View File
@@ -1,15 +0,0 @@
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>
);
}
-32
View File
@@ -1,32 +0,0 @@
"use client";
interface RefreshButtonProps {
onRefresh: () => void;
loading: boolean;
}
export default function RefreshButton({ onRefresh, loading }: RefreshButtonProps) {
return (
<button
onClick={onRefresh}
disabled={loading}
className="flex items-center gap-2 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 hover:border-slate-600 disabled:opacity-40 disabled:cursor-not-allowed px-3.5 py-1.5 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
<svg
className={`h-3.5 w-3.5 ${loading ? "animate-spin" : ""}`}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
{loading ? "Loading…" : "Refresh"}
</button>
);
}
-196
View File
@@ -1,196 +0,0 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
import ServiceSection from "@/components/settings/ServiceSection";
import DiscordSection from "@/components/settings/DiscordSection";
function XIcon() {
return (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-5 w-5">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
);
}
interface Props {
open: boolean;
onClose: () => void;
onSaved?: () => void;
}
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
const EMPTY_SETTINGS: AppSettings = {
radarr: EMPTY_CONFIG,
sonarr: EMPTY_CONFIG,
seerr: EMPTY_CONFIG,
tautulli: EMPTY_CONFIG,
discord: { webhookUrl: "" },
};
export default function SettingsModal({ open, onClose, onSaved }: Props) {
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
setSaveResult(null);
setLoading(true);
fetch("/api/settings")
.then((r) => r.json())
.then((data: AppSettings) => setSettings(data))
.catch(() => {})
.finally(() => setLoading(false));
}, [open]);
useEffect(() => {
if (!open) return;
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [open, onClose]);
function handleBackdrop(e: React.MouseEvent) {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
onClose();
}
}
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
setSaveResult(null);
setSettings((prev) => ({
...prev,
[service]: { ...prev[service], ...partial },
}));
}
function patchDiscord(partial: Partial<DiscordConfig>) {
setSaveResult(null);
setSettings((prev) => ({
...prev,
discord: { ...prev.discord, ...partial },
}));
}
async function handleSave() {
setSaving(true);
setSaveResult(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}`);
setSaveResult("saved");
onSaved?.();
} catch {
setSaveResult("error");
} finally {
setSaving(false);
}
}
if (!open) return null;
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
onClick={handleBackdrop}
>
<div
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]"
>
<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>
<button
onClick={onClose}
className="text-slate-500 hover:text-slate-300 transition-colors"
aria-label="Close"
>
<XIcon />
</button>
</div>
<div className="overflow-y-auto flex-1 px-6 py-5">
{loading ? (
<div className="flex justify-center py-8">
<svg className="animate-spin h-5 w-5 text-slate-500" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
</div>
) : (
<div className="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)}
/>
<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>
<div className="flex items-center justify-between px-6 py-4 border-t border-slate-800 shrink-0 gap-3">
<div className="text-xs">
{saveResult === "saved" && (
<span className="text-green-400">Saved click Refresh to reload data</span>
)}
{saveResult === "error" && (
<span className="text-red-400">Save failed check the console</span>
)}
</div>
<div className="flex gap-2 shrink-0">
<button
onClick={onClose}
className="rounded-lg border border-slate-700 bg-slate-800/60 hover:bg-slate-700 px-4 py-2 text-sm font-medium text-slate-300 hover:text-white transition-colors"
>
Cancel
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2 text-sm font-semibold text-black transition-colors"
>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
);
}
-129
View File
@@ -1,129 +0,0 @@
"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
@@ -1,29 +0,0 @@
// 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>
);
}
-115
View File
@@ -1,115 +0,0 @@
import { formatGB, formatHours } from "@/lib/format";
interface SummaryCardsProps {
totalUsers: number;
totalRequests: number;
totalStorageGB: number;
totalWatchHours: number | null;
openAlertCount: number;
onAlertsClick?: () => void;
}
// Heroicons outline paths
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",
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",
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",
alert: "M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126ZM12 15.75h.007v.008H12v-.008Z",
};
function Card({
label,
value,
sub,
icon,
accent,
onClick,
}: {
label: string;
value: string;
sub?: string;
icon: keyof typeof ICONS;
accent?: "yellow" | "green";
onClick?: () => void;
}) {
const base = "rounded-xl border p-5 flex flex-col gap-2 transition-colors";
const interactive = onClick ? "cursor-pointer" : "";
let colorClass: string;
if (accent === "yellow") {
colorClass = "bg-yellow-950/30 border-yellow-700/50 hover:bg-yellow-950/50";
} else if (accent === "green") {
colorClass = "bg-green-950/30 border-green-800/50";
} else {
colorClass = `bg-slate-800/60 border-slate-700/60 ${onClick ? "hover:bg-slate-800 hover:border-slate-600" : ""}`;
}
return (
<div onClick={onClick} className={`${base} ${colorClass} ${interactive}`}>
<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 ${
accent === "yellow"
? "text-yellow-500/70"
: accent === "green"
? "text-green-500/70"
: "text-slate-600"
}`}
>
<path strokeLinecap="round" strokeLinejoin="round" d={ICONS[icon]} />
</svg>
</div>
<span className={`text-3xl font-bold tabular-nums ${accent === "yellow" ? "text-yellow-300" : "text-white"}`}>
{value}
</span>
{sub && (
<span className={`text-xs ${accent === "yellow" ? "text-yellow-600" : "text-slate-600"}`}>
{sub}
</span>
)}
</div>
);
}
export default function SummaryCards({
totalUsers,
totalRequests,
totalStorageGB,
totalWatchHours,
openAlertCount,
onAlertsClick,
}: SummaryCardsProps) {
const colCount = (totalWatchHours !== null ? 1 : 0) + 4;
return (
<div
className={`grid grid-cols-2 gap-3 ${
colCount >= 5 ? "sm:grid-cols-5" : "sm:grid-cols-4"
}`}
>
<Card label="Users" value={totalUsers.toLocaleString()} icon="users" />
<Card label="Requests" value={totalRequests.toLocaleString()} icon="requests" />
<Card label="Storage" value={formatGB(totalStorageGB)} icon="storage" />
{totalWatchHours !== null && (
<Card label="Watch Time" value={formatHours(totalWatchHours)} icon="watch" accent="green" />
)}
<Card
label="Open Alerts"
value={openAlertCount.toString()}
sub={openAlertCount > 0 ? "Tap to review" : "All clear"}
icon="alert"
accent={openAlertCount > 0 ? "yellow" : undefined}
onClick={openAlertCount > 0 ? onAlertsClick : undefined}
/>
</div>
);
}
@@ -1,86 +0,0 @@
"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
@@ -1,138 +0,0 @@
"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>
);
}
-14
View File
@@ -1,14 +0,0 @@
/**
* Next.js instrumentation hook — runs once when the server starts.
* Used to kick off the background stats poller so alerts are generated
* and Discord notifications are sent even when no client is connected.
*/
export async function register() {
// Only run in the Node.js runtime (not Edge).
// better-sqlite3 and the fetch clients require Node.js APIs.
if (process.env.NEXT_RUNTIME === "edge") return;
const { startBackgroundPoller } = await import("@/lib/statsBuilder");
startBackgroundPoller();
}
-162
View File
@@ -1,162 +0,0 @@
import {
OverseerrUser,
OverseerrRequest,
TautulliUser,
MediaEntry,
UserStat,
DashboardStats,
} from "@/lib/types";
import { lookupTautulliUser } from "@/lib/tautulli";
import { generateAlertCandidates } from "@/lib/alerts";
import { upsertAlerts } from "@/lib/db";
import { sendDiscordNotifications } from "@/lib/discord";
export function bytesToGB(bytes: number): number {
return Math.round((bytes / 1024 / 1024 / 1024) * 10) / 10;
}
/** Compute dense rank (1 = highest value, ties share the same rank). */
function computeRanks(
items: Array<{ userId: number; value: number | null }>
): Map<number, number> {
const withValues = items.filter((i) => i.value !== null) as Array<{
userId: number;
value: number;
}>;
const sorted = [...withValues].sort((a, b) => b.value - a.value);
const rankMap = new Map<number, number>();
let currentRank = 1;
for (let i = 0; i < sorted.length; i++) {
if (i > 0 && sorted[i].value < sorted[i - 1].value) {
currentRank = i + 1;
}
rankMap.set(sorted[i].userId, currentRank);
}
return rankMap;
}
export function computeStats(
users: OverseerrUser[],
allRequests: Map<number, OverseerrRequest[]>,
radarrMap: Map<number, MediaEntry>,
sonarrMap: Map<number, MediaEntry>,
tautulliMap: Map<string, TautulliUser> | null
): DashboardStats {
const hasTautulli = tautulliMap !== null;
// Compute raw per-user totals
const rawStats = users.map((user) => {
const requests = allRequests.get(user.id) ?? [];
let totalBytes = 0;
for (const req of requests) {
if (req.type === "movie") {
totalBytes += radarrMap.get(req.media.tmdbId)?.sizeOnDisk ?? 0;
} else if (req.type === "tv" && req.media.tvdbId) {
totalBytes += sonarrMap.get(req.media.tvdbId)?.sizeOnDisk ?? 0;
}
}
let plays: number | null = null;
let watchHours: number | null = null;
let tautulliLastSeen: number | null = null;
if (hasTautulli && tautulliMap) {
const tu = lookupTautulliUser(tautulliMap, user.email, user.displayName);
plays = tu?.plays ?? 0;
watchHours = tu ? Math.round((tu.duration / 3600) * 10) / 10 : 0;
tautulliLastSeen = tu?.last_seen ?? null;
}
return {
userId: user.id,
displayName: user.displayName,
email: user.email,
requestCount: requests.length,
totalBytes,
plays,
watchHours,
tautulliLastSeen,
};
});
const totalUsers = rawStats.length;
// Compute per-metric ranks
const storageRanks = computeRanks(
rawStats.map((r) => ({ userId: r.userId, value: r.totalBytes }))
);
const requestRanks = computeRanks(
rawStats.map((r) => ({ userId: r.userId, value: r.requestCount }))
);
const playsRanks = hasTautulli
? computeRanks(rawStats.map((r) => ({ userId: r.userId, value: r.plays })))
: null;
const watchRanks = hasTautulli
? computeRanks(
rawStats.map((r) => ({ userId: r.userId, value: r.watchHours }))
)
: null;
// First pass: compute totalGB, avgGB, and loadGBPerHour (needs totalGB)
const enriched = rawStats.map((raw) => {
const totalGB = bytesToGB(raw.totalBytes);
const avgGB =
raw.requestCount > 0
? Math.round((totalGB / raw.requestCount) * 10) / 10
: 0;
const loadGBPerHour =
hasTautulli && raw.watchHours !== null && raw.watchHours > 0
? Math.round((totalGB / raw.watchHours) * 10) / 10
: null;
return { ...raw, totalGB, avgGB, loadGBPerHour };
});
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
const candidates = generateAlertCandidates(
userStats,
allRequests,
radarrMap,
sonarrMap,
hasTautulli
);
const { openCount: openAlertCount, newAlerts } = upsertAlerts(candidates);
if (newAlerts.length > 0) {
sendDiscordNotifications(newAlerts).catch(() => {});
}
const totalRequests = userStats.reduce((s, u) => s + u.requestCount, 0);
const totalStorageGB = bytesToGB(
userStats.reduce((s, u) => s + u.totalBytes, 0)
);
const totalWatchHours = hasTautulli
? Math.round(userStats.reduce((s, u) => s + (u.watchHours ?? 0), 0) * 10) /
10
: null;
return {
users: userStats,
summary: {
totalUsers,
totalRequests,
totalStorageGB,
totalWatchHours,
openAlertCount,
},
generatedAt: new Date().toISOString(),
};
}
-330
View File
@@ -1,330 +0,0 @@
import { UserStat, OverseerrRequest, MediaEntry, AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// ─── Tunables ─────────────────────────────────────────────────────────────────
/** A movie/show must have been approved at least this many hours before we alert on it */
const UNFULFILLED_MIN_AGE_HOURS = 12;
/** A pending request must be this many days old before we alert on it */
const PENDING_MIN_AGE_DAYS = 2;
/** User must have made their first request at least this long ago before behavior alerts */
const USER_MIN_AGE_DAYS = 14;
/** Ghost requester: how many of the user's most recent approved requests to evaluate */
const GHOST_RECENT_REQUESTS = 5;
/** Watch-rate threshold — below this fraction (plays/requests) triggers a warning */
const LOW_WATCH_RATE = 0.2;
/** Minimum requests before a low watch rate alert fires */
const MIN_REQUESTS_WATCHRATE = 10;
// ─── Helpers ──────────────────────────────────────────────────────────────────
function hoursSince(iso: string): number {
return (Date.now() - new Date(iso).getTime()) / 3_600_000;
}
function daysSince(iso: string): number {
return hoursSince(iso) / 24;
}
function formatAge(hours: number): string {
if (hours < 24) return `${Math.floor(hours)} hours`;
const days = Math.floor(hours / 24);
return days === 1 ? "1 day" : `${days} days`;
}
// ─── Generator ───────────────────────────────────────────────────────────────
export function generateAlertCandidates(
userStats: UserStat[],
allRequests: Map<number, OverseerrRequest[]>,
radarrMap: Map<number, MediaEntry>,
sonarrMap: Map<number, MediaEntry>,
hasTautulli: boolean
): AlertCandidate[] {
const candidates: AlertCandidate[] = [];
const { radarr: radarrSettings, sonarr: sonarrSettings, seerr: seerrSettings } = getSettings();
// ── CONTENT-CENTRIC: one alert per piece of media ──────────────────────────
const flaggedMovies = new Set<number>();
const flaggedShows = new Set<number>();
interface UnfilledEntry {
entry: MediaEntry;
requestedBy: string[];
requestedByIds: number[];
tmdbId?: number; // TV shows only — needed to build the Seerr URL (movies use map key)
oldestAgeHours: number;
partial?: boolean;
}
const unfilledMovies = new Map<number, UnfilledEntry>();
const unfilledShows = new Map<number, UnfilledEntry>();
for (const user of userStats) {
const requests = allRequests.get(user.userId) ?? [];
for (const req of requests) {
if (req.status !== 2) continue;
const ageHours = hoursSince(req.createdAt);
if (ageHours < UNFULFILLED_MIN_AGE_HOURS) continue;
if (req.type === "movie") {
const entry = radarrMap.get(req.media.tmdbId);
if (entry && !entry.available) continue;
if (entry && entry.sizeOnDisk > 0) continue;
const title = entry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
const existing = unfilledMovies.get(req.media.tmdbId);
if (existing) {
if (!existing.requestedBy.includes(user.displayName)) {
existing.requestedBy.push(user.displayName);
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
} else {
unfilledMovies.set(req.media.tmdbId, {
entry: { title, sizeOnDisk: 0, available: true },
requestedBy: [user.displayName],
requestedByIds: [user.userId],
oldestAgeHours: ageHours,
});
}
} else if (req.type === "tv" && req.media.tvdbId) {
const entry = sonarrMap.get(req.media.tvdbId);
if (entry && !entry.available) continue;
const isNothingDownloaded = !entry || entry.sizeOnDisk === 0;
// Partial: ended series missing any episodes
const isPartiallyDownloaded =
entry !== undefined &&
entry.sizeOnDisk > 0 &&
entry.seriesStatus === "ended" &&
entry.episodeFileCount !== undefined &&
entry.totalEpisodeCount !== undefined &&
entry.episodeFileCount < entry.totalEpisodeCount;
if (!isNothingDownloaded && !isPartiallyDownloaded) continue;
const title = entry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
const partial = !isNothingDownloaded && isPartiallyDownloaded;
const existing = unfilledShows.get(req.media.tvdbId);
if (existing) {
if (!existing.requestedBy.includes(user.displayName)) {
existing.requestedBy.push(user.displayName);
existing.requestedByIds.push(user.userId);
}
existing.oldestAgeHours = Math.max(existing.oldestAgeHours, ageHours);
if (partial) existing.partial = true;
} else {
unfilledShows.set(req.media.tvdbId, {
entry: { title, sizeOnDisk: entry?.sizeOnDisk ?? 0, available: true },
requestedBy: [user.displayName],
requestedByIds: [user.userId],
tmdbId: req.media.tmdbId,
oldestAgeHours: ageHours,
partial,
});
}
}
}
}
for (const [tmdbId, { entry, requestedBy, requestedByIds, oldestAgeHours }] of unfilledMovies) {
if (flaggedMovies.has(tmdbId)) continue;
flaggedMovies.add(tmdbId);
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const radarrEntry = radarrMap.get(tmdbId);
const mediaUrl = radarrEntry?.titleSlug && radarrSettings.url
? `${radarrSettings.url}/movie/${radarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = seerrSettings.url ? `${seerrSettings.url}/movie/${tmdbId}` : undefined;
candidates.push({
key: `unfulfilled:movie:${tmdbId}`,
category: "unfulfilled",
severity: "warning",
title: `Not Downloaded: ${entry.title}`,
description: `Approved ${formatAge(oldestAgeHours)} ago but no file found in Radarr. Requested by ${byStr}.`,
mediaId: tmdbId,
mediaType: "movie",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
for (const [tvdbId, { entry, requestedBy, requestedByIds, tmdbId: showTmdbId, oldestAgeHours, partial }] of unfilledShows) {
if (flaggedShows.has(tvdbId)) continue;
flaggedShows.add(tvdbId);
const sonarrEntry = sonarrMap.get(tvdbId);
const byStr = requestedBy.slice(0, 3).join(", ") + (requestedBy.length > 3 ? ` +${requestedBy.length - 3}` : "");
const pct = partial && sonarrEntry?.episodeFileCount !== undefined && sonarrEntry.totalEpisodeCount
? Math.round((sonarrEntry.episodeFileCount / sonarrEntry.totalEpisodeCount) * 100)
: null;
const description = pct !== null
? `Only ${pct}% of episodes downloaded (${sonarrEntry!.episodeFileCount}/${sonarrEntry!.totalEpisodeCount}). Approved ${formatAge(oldestAgeHours)} ago. Requested by ${byStr}.`
: `Approved ${formatAge(oldestAgeHours)} ago but no files found in Sonarr. Requested by ${byStr}.`;
const mediaUrl = sonarrEntry?.titleSlug && sonarrSettings.url
? `${sonarrSettings.url}/series/${sonarrEntry.titleSlug}`
: undefined;
const seerrMediaUrl = showTmdbId && seerrSettings.url
? `${seerrSettings.url}/tv/${showTmdbId}`
: undefined;
candidates.push({
key: `unfulfilled:tv:${tvdbId}`,
category: "unfulfilled",
severity: "warning",
title: partial ? `Incomplete Download: ${entry.title}` : `Not Downloaded: ${entry.title}`,
description,
mediaId: tvdbId,
mediaType: "tv",
mediaTitle: entry.title,
mediaUrl,
seerrMediaUrl,
requesterIds: requestedByIds,
});
}
// ── CONTENT-CENTRIC: stale pending requests ───────────────────────────────
const flaggedPending = new Set<number>();
for (const user of userStats) {
const requests = allRequests.get(user.userId) ?? [];
for (const req of requests) {
if (req.status !== 1) continue;
if (daysSince(req.createdAt) < PENDING_MIN_AGE_DAYS) continue;
const ageStr = formatAge(hoursSince(req.createdAt));
if (req.type === "movie" && !flaggedPending.has(req.id)) {
const movieEntry = radarrMap.get(req.media.tmdbId);
if (movieEntry && !movieEntry.available) continue;
flaggedPending.add(req.id);
const title = movieEntry?.title ?? req.media.title ?? `Movie #${req.media.tmdbId}`;
candidates.push({
key: `pending:req:${req.id}`,
category: "pending",
severity: "warning",
title: `Pending Approval: ${title}`,
description: `Awaiting approval for ${ageStr}. Requested by ${user.displayName}.`,
mediaId: req.media.tmdbId,
mediaType: "movie",
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/movie/${req.media.tmdbId}` : undefined,
});
} else if (req.type === "tv" && req.media.tvdbId && !flaggedPending.has(req.id)) {
const showEntry = sonarrMap.get(req.media.tvdbId);
if (showEntry && !showEntry.available) continue;
flaggedPending.add(req.id);
const title = showEntry?.title ?? req.media.title ?? `Show #${req.media.tvdbId}`;
candidates.push({
key: `pending:req:${req.id}`,
category: "pending",
severity: "warning",
title: `Pending Approval: ${title}`,
description: `Awaiting approval for ${ageStr}. Requested by ${user.displayName}.`,
mediaId: req.media.tvdbId,
mediaType: "tv",
mediaTitle: title,
userId: user.userId,
userName: user.displayName,
requesterIds: [user.userId],
seerrMediaUrl: seerrSettings.url ? `${seerrSettings.url}/tv/${req.media.tmdbId}` : undefined,
});
}
}
}
// ── USER-BEHAVIOR ─────────────────────────────────────────────────────────
for (const user of userStats) {
const requests = allRequests.get(user.userId) ?? [];
if (requests.length === 0) continue;
const oldestRequestAge = Math.max(...requests.map((r) => daysSince(r.createdAt)));
if (oldestRequestAge < USER_MIN_AGE_DAYS) continue;
// ── Ghost Requester ───────────────────────────────────────────────────
// Fires if the user hasn't watched anything since before their last
// GHOST_RECENT_REQUESTS approved requests were made.
if (hasTautulli) {
const approved = requests
.filter((r) => r.status === 2)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
if (approved.length >= GHOST_RECENT_REQUESTS) {
const nthRequest = approved[GHOST_RECENT_REQUESTS - 1];
const nthDate = new Date(nthRequest.createdAt).getTime();
// tautulliLastSeen is unix seconds; null means never seen
const lastSeenMs = user.tautulliLastSeen ? user.tautulliLastSeen * 1000 : null;
const hasNotWatchedSinceRequesting = lastSeenMs === null || lastSeenMs < nthDate;
if (hasNotWatchedSinceRequesting) {
candidates.push({
key: `ghost:${user.userId}`,
category: "ghost",
severity: "warning",
title: `Ghost Requester`,
description: `${user.displayName} has made ${approved.length} requests but hasn't watched anything since before their last ${GHOST_RECENT_REQUESTS} were approved.`,
userId: user.userId,
userName: user.displayName,
});
continue; // ghost takes priority over low watch rate
}
}
}
// ── Low Watch Rate ────────────────────────────────────────────────────
if (
hasTautulli &&
user.plays !== null &&
user.plays > 0 &&
user.requestCount >= MIN_REQUESTS_WATCHRATE
) {
const ratio = user.plays / user.requestCount;
if (ratio < LOW_WATCH_RATE) {
const pct = Math.round(ratio * 100);
candidates.push({
key: `watchrate:${user.userId}`,
category: "watchrate",
severity: "info",
title: `Low Watch Rate`,
description: `${user.displayName} watches ~${pct}% of what they request (${user.plays.toLocaleString()} plays, ${user.requestCount} requests).`,
userId: user.userId,
userName: user.displayName,
});
}
}
}
// ── SYSTEM: Tautulli configured but no matches ────────────────────────────
if (hasTautulli) {
const totalPlays = userStats.reduce((s, u) => s + (u.plays ?? 0), 0);
if (totalPlays === 0 && userStats.length > 0) {
candidates.push({
key: "tautulli-no-matches",
category: "tautulli-no-matches",
severity: "danger",
title: "No Tautulli Watch Data",
description:
"Tautulli is configured but no plays were matched to any user. Check that emails align between Overseerr and Tautulli.",
});
}
}
// Sort: danger → warning → info
const order: Record<string, number> = { danger: 0, warning: 1, info: 2 };
candidates.sort((a, b) => order[a.severity] - order[b.severity]);
return candidates;
}
-489
View File
@@ -1,489 +0,0 @@
/**
* SQLite-backed alert store using better-sqlite3.
* Lives at data/alerts.db (gitignored).
*
* Uses a global singleton so Next.js hot-reload doesn't open multiple
* connections. WAL mode is enabled for concurrent read performance.
*/
import Database from "better-sqlite3";
import { mkdirSync, existsSync, readFileSync, renameSync } from "fs";
import { join } from "path";
import {
AlertCandidate,
AlertStatus,
AlertCloseReason,
AlertComment,
Alert,
} from "./types";
const DATA_DIR = join(process.cwd(), "data");
const DB_PATH = join(DATA_DIR, "alerts.db");
const LEGACY_JSON_PATH = join(DATA_DIR, "alerts.json");
const LEGACY_MIGRATED_PATH = join(DATA_DIR, "alerts.json.migrated");
// Cooldown days applied on MANUAL close.
// 0 = no cooldown: content alerts reopen immediately on the next refresh if
// the condition still exists. Closing is an acknowledgment, not a suppression.
// >0 = user-behavior alerts: suppress re-opening for this many days so a single
// acknowledgment isn't immediately undone by the next refresh.
const COOLDOWN: Record<string, number> = {
unfulfilled: 0,
pending: 0,
ghost: 7,
watchrate: 7,
"tautulli-no-matches": 0,
};
const DEFAULT_COOLDOWN = 0;
// ── Singleton ──────────────────────────────────────────────────────────────────
declare global {
// eslint-disable-next-line no-var
var __alertsDb: Database.Database | undefined;
}
function getDb(): Database.Database {
if (global.__alertsDb) return global.__alertsDb;
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
const db = new Database(DB_PATH);
db.pragma("journal_mode = WAL");
db.pragma("foreign_keys = ON");
initSchema(db);
maybeMigrateJson(db);
global.__alertsDb = db;
return db;
}
// ── Schema ─────────────────────────────────────────────────────────────────────
function initSchema(db: Database.Database) {
db.exec(`
CREATE TABLE IF NOT EXISTS alerts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
key TEXT NOT NULL UNIQUE,
category TEXT NOT NULL,
severity TEXT NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
userId INTEGER,
userName TEXT,
mediaId INTEGER,
mediaType TEXT,
mediaTitle TEXT,
mediaUrl TEXT,
status TEXT NOT NULL DEFAULT 'open',
closeReason TEXT,
suppressedUntil TEXT,
firstSeen TEXT NOT NULL,
lastSeen TEXT NOT NULL,
closedAt TEXT,
requesterIds TEXT,
seerrMediaUrl TEXT
);
CREATE TABLE IF NOT EXISTS comments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
alertId INTEGER NOT NULL REFERENCES alerts(id) ON DELETE CASCADE,
body TEXT NOT NULL,
author TEXT NOT NULL DEFAULT 'user',
createdAt TEXT NOT NULL
);
`);
// Additive migrations for existing databases
try { db.exec("ALTER TABLE alerts ADD COLUMN requesterIds TEXT"); } catch {}
try { db.exec("ALTER TABLE alerts ADD COLUMN seerrMediaUrl TEXT"); } catch {}
}
// ── Legacy JSON migration ──────────────────────────────────────────────────────
function maybeMigrateJson(db: Database.Database) {
if (!existsSync(LEGACY_JSON_PATH)) return;
// Only migrate if the alerts table is empty
const count = (db.prepare("SELECT COUNT(*) as n FROM alerts").get() as { n: number }).n;
if (count > 0) {
// Table already has data — just remove the legacy file
renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH);
return;
}
try {
interface LegacyComment { id: number; body: string; author: "user" | "system"; createdAt: string; }
interface LegacyAlert {
id: number; key: string; category: string; severity: string;
title: string; description: string;
userId?: number; userName?: string;
mediaId?: number; mediaType?: string; mediaTitle?: string; mediaUrl?: string;
status: string; closeReason: string | null; suppressedUntil: string | null;
firstSeen: string; lastSeen: string; closedAt: string | null;
comments: LegacyComment[];
}
interface LegacyStore { alerts: Record<string, LegacyAlert>; }
const raw = readFileSync(LEGACY_JSON_PATH, "utf-8");
const store: LegacyStore = JSON.parse(raw);
const insertAlert = db.prepare(`
INSERT INTO alerts
(key, category, severity, title, description, userId, userName,
mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason,
suppressedUntil, firstSeen, lastSeen, closedAt)
VALUES
(@key, @category, @severity, @title, @description, @userId, @userName,
@mediaId, @mediaType, @mediaTitle, @mediaUrl, @status, @closeReason,
@suppressedUntil, @firstSeen, @lastSeen, @closedAt)
`);
const insertComment = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (@alertId, @body, @author, @createdAt)
`);
const migrate = db.transaction(() => {
for (const a of Object.values(store.alerts)) {
const info = insertAlert.run({
key: a.key, category: a.category, severity: a.severity,
title: a.title, description: a.description,
userId: a.userId ?? null, userName: a.userName ?? null,
mediaId: a.mediaId ?? null, mediaType: a.mediaType ?? null,
mediaTitle: a.mediaTitle ?? null, mediaUrl: a.mediaUrl ?? null,
status: a.status, closeReason: a.closeReason ?? null,
suppressedUntil: a.suppressedUntil ?? null,
firstSeen: a.firstSeen, lastSeen: a.lastSeen, closedAt: a.closedAt ?? null,
});
const alertId = info.lastInsertRowid as number;
for (const c of a.comments ?? []) {
insertComment.run({ alertId, body: c.body, author: c.author, createdAt: c.createdAt });
}
}
});
migrate();
renameSync(LEGACY_JSON_PATH, LEGACY_MIGRATED_PATH);
console.log("[db] Migrated alerts.json → SQLite");
} catch (err) {
console.error("[db] Migration failed:", err);
}
}
// ── Row → Alert ────────────────────────────────────────────────────────────────
interface AlertRow {
id: number; key: string; category: string; severity: string;
title: string; description: string;
userId: number | null; userName: string | null;
mediaId: number | null; mediaType: string | null;
mediaTitle: string | null; mediaUrl: string | null;
status: string; closeReason: string | null; suppressedUntil: string | null;
firstSeen: string; lastSeen: string; closedAt: string | null;
requesterIds: string | null; // JSON-encoded number[]
seerrMediaUrl: string | null;
}
interface CommentRow {
id: number; alertId: number; body: string; author: string; createdAt: string;
}
function rowToAlert(row: AlertRow, comments: CommentRow[]): Alert {
return {
id: row.id,
key: row.key,
category: row.category,
severity: row.severity as Alert["severity"],
title: row.title,
description: row.description,
userId: row.userId ?? undefined,
userName: row.userName ?? undefined,
mediaId: row.mediaId ?? undefined,
mediaType: row.mediaType as Alert["mediaType"],
mediaTitle: row.mediaTitle ?? undefined,
mediaUrl: row.mediaUrl ?? undefined,
requesterIds: row.requesterIds ? (JSON.parse(row.requesterIds) as number[]) : undefined,
seerrMediaUrl: row.seerrMediaUrl ?? undefined,
status: row.status as AlertStatus,
closeReason: row.closeReason as AlertCloseReason | null,
suppressedUntil: row.suppressedUntil,
firstSeen: row.firstSeen,
lastSeen: row.lastSeen,
closedAt: row.closedAt,
comments: comments.map((c) => ({
id: c.id,
body: c.body,
author: c.author as "user" | "system",
createdAt: c.createdAt,
})),
};
}
function getCommentsForAlert(db: Database.Database, alertId: number): CommentRow[] {
return db.prepare(
"SELECT * FROM comments WHERE alertId = ? ORDER BY createdAt ASC, id ASC"
).all(alertId) as CommentRow[];
}
// ── Exported API ───────────────────────────────────────────────────────────────
export interface UpsertResult {
openCount: number;
/** Candidates that were newly created or reopened this run — used for notifications. */
newAlerts: AlertCandidate[];
}
/**
* Merge generated candidates into the store, then auto-resolve any open alerts
* whose condition is no longer present (key not in this run's candidate set).
*
* Returns the count of open alerts after the merge and the list of newly
* created or reopened alert candidates (for Discord notifications etc.).
*/
export function upsertAlerts(candidates: AlertCandidate[]): UpsertResult {
const db = getDb();
const now = new Date();
const nowISO = now.toISOString();
const candidateKeys = new Set(candidates.map((c) => c.key));
const getByKey = db.prepare<[string], AlertRow>(
"SELECT * FROM alerts WHERE key = ?"
);
const updateAlert = db.prepare(`
UPDATE alerts SET
status = @status, closeReason = @closeReason, closedAt = @closedAt,
suppressedUntil = @suppressedUntil, lastSeen = @lastSeen,
title = @title, description = @description,
userName = COALESCE(@userName, userName),
mediaTitle = COALESCE(@mediaTitle, mediaTitle),
mediaUrl = COALESCE(@mediaUrl, mediaUrl),
requesterIds = COALESCE(@requesterIds, requesterIds),
seerrMediaUrl = COALESCE(@seerrMediaUrl, seerrMediaUrl)
WHERE key = @key
`);
const insertAlert = db.prepare(`
INSERT INTO alerts
(key, category, severity, title, description, userId, userName,
mediaId, mediaType, mediaTitle, mediaUrl, status, closeReason,
suppressedUntil, firstSeen, lastSeen, closedAt, requesterIds, seerrMediaUrl)
VALUES
(@key, @category, @severity, @title, @description, @userId, @userName,
@mediaId, @mediaType, @mediaTitle, @mediaUrl, 'open', NULL, NULL,
@firstSeen, @lastSeen, NULL, @requesterIds, @seerrMediaUrl)
`);
const insertComment = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (@alertId, @body, @author, @createdAt)
`);
const getOpenAlerts = db.prepare<[], AlertRow>(
"SELECT * FROM alerts WHERE status = 'open'"
);
const newAlerts: AlertCandidate[] = [];
db.transaction(() => {
// ── Step 1: upsert candidates ───────────────────────────────────────────
for (const c of candidates) {
const existing = getByKey.get(c.key);
if (existing) {
const isSuppressed =
existing.status === "closed" &&
existing.suppressedUntil !== null &&
new Date(existing.suppressedUntil) > now;
if (isSuppressed) continue;
const requesterIds = c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null;
const seerrMediaUrl = c.seerrMediaUrl ?? null;
if (existing.status === "closed") {
// Reopen — notify
updateAlert.run({
key: c.key,
status: "open",
closeReason: null,
closedAt: null,
suppressedUntil: null,
lastSeen: nowISO,
title: c.title,
description: c.description,
userName: c.userName ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
requesterIds,
seerrMediaUrl,
});
insertComment.run({
alertId: existing.id,
body: "Alert reopened — condition is still active.",
author: "system",
createdAt: nowISO,
});
newAlerts.push(c);
} else {
// Refresh content — already open, no notification
updateAlert.run({
key: c.key,
status: "open",
closeReason: null,
closedAt: null,
suppressedUntil: null,
lastSeen: nowISO,
title: c.title,
description: c.description,
userName: c.userName ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
requesterIds,
seerrMediaUrl,
});
}
} else {
// New alert — notify
insertAlert.run({
key: c.key,
category: c.category,
severity: c.severity,
title: c.title,
description: c.description,
userId: c.userId ?? null,
userName: c.userName ?? null,
mediaId: c.mediaId ?? null,
mediaType: c.mediaType ?? null,
mediaTitle: c.mediaTitle ?? null,
mediaUrl: c.mediaUrl ?? null,
firstSeen: nowISO,
lastSeen: nowISO,
requesterIds: c.requesterIds?.length ? JSON.stringify(c.requesterIds) : null,
seerrMediaUrl: c.seerrMediaUrl ?? null,
});
newAlerts.push(c);
}
}
// ── Step 2: auto-resolve alerts whose condition is gone ─────────────────
const openAlerts = getOpenAlerts.all();
for (const a of openAlerts) {
if (candidateKeys.has(a.key)) continue;
db.prepare(`
UPDATE alerts SET status = 'closed', closeReason = 'resolved',
closedAt = ?, suppressedUntil = NULL WHERE id = ?
`).run(nowISO, a.id);
insertComment.run({
alertId: a.id,
body: "Condition resolved — alert closed automatically.",
author: "system",
createdAt: nowISO,
});
}
})();
const { n } = db.prepare(
"SELECT COUNT(*) as n FROM alerts WHERE status = 'open'"
).get() as { n: number };
return { openCount: n, newAlerts };
}
export function getAllAlerts(): Alert[] {
const db = getDb();
const rows = db.prepare<[], AlertRow>(`
SELECT * FROM alerts
ORDER BY
CASE status WHEN 'open' THEN 0 ELSE 1 END ASC,
lastSeen DESC
`).all();
return rows.map((row) => rowToAlert(row, getCommentsForAlert(db, row.id)));
}
export function getAlertById(id: number): Alert | null {
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
return rowToAlert(row, getCommentsForAlert(db, id));
}
export function closeAlert(id: number): Alert | null {
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
const cooldownDays = COOLDOWN[row.category] ?? DEFAULT_COOLDOWN;
let suppressedUntil: string | null = null;
if (cooldownDays > 0) {
const d = new Date();
d.setDate(d.getDate() + cooldownDays);
suppressedUntil = d.toISOString();
}
const nowISO = new Date().toISOString();
db.transaction(() => {
db.prepare(`
UPDATE alerts SET status = 'closed', closeReason = 'manual',
closedAt = ?, suppressedUntil = ? WHERE id = ?
`).run(nowISO, suppressedUntil, id);
db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, 'system', ?)
`).run(id, "Manually closed.", nowISO);
})();
return getAlertById(id);
}
export function reopenAlert(id: number): Alert | null {
const db = getDb();
const row = db.prepare<[number], AlertRow>(
"SELECT * FROM alerts WHERE id = ?"
).get(id);
if (!row) return null;
const nowISO = new Date().toISOString();
db.transaction(() => {
db.prepare(`
UPDATE alerts SET status = 'open', closeReason = NULL,
closedAt = NULL, suppressedUntil = NULL WHERE id = ?
`).run(id);
db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, 'Manually reopened.', 'system', ?)
`).run(id, nowISO);
})();
return getAlertById(id);
}
export function addComment(
alertId: number,
body: string,
author: "user" | "system" = "user"
): AlertComment | null {
const db = getDb();
const exists = db.prepare<[number], { id: number }>(
"SELECT id FROM alerts WHERE id = ?"
).get(alertId);
if (!exists) return null;
const nowISO = new Date().toISOString();
const info = db.prepare(`
INSERT INTO comments (alertId, body, author, createdAt)
VALUES (?, ?, ?, ?)
`).run(alertId, body, author, nowISO);
return {
id: info.lastInsertRowid as number,
body,
author,
createdAt: nowISO,
};
}
-208
View File
@@ -1,208 +0,0 @@
/**
* Discord webhook notifications.
* Fired on newly opened or reopened alerts. Batches up to 10 embeds per
* message to stay within Discord's limits.
*/
import { AlertCandidate } from "@/lib/types";
import { getSettings } from "@/lib/settings";
// Discord embed colors per severity
const SEVERITY_COLOR: Record<string, number> = {
danger: 0xef4444, // red-500
warning: 0xeab308, // yellow-500
info: 0x3b82f6, // blue-500
};
const SEVERITY_LABEL: Record<string, string> = {
danger: "Critical",
warning: "Warning",
info: "Info",
};
type EmbedField = { name: string; value: string; inline: boolean };
interface DiscordEmbed {
title: string;
description?: string;
color: number;
url?: string;
fields: EmbedField[];
footer: { text: string };
timestamp: string;
}
// ── Description parsers (mirror AlertDetail.tsx regex patterns) ───────────────
function parseUnfulfilledComplete(desc: string) {
const m = desc.match(/^Approved (.+?) ago but (.+?)\. Requested by (.+?)\.?$/);
if (!m) return null;
return { age: m[1], detail: m[2], requesters: m[3] };
}
function parseUnfulfilledPartial(desc: string) {
// "Only X% of episodes downloaded (A/B). Approved N ago. Requested by Y."
const m = desc.match(/^Only .+?\. Approved (.+?) ago\. Requested by (.+?)\.?$/);
if (!m) return null;
const eps = desc.match(/\((\d+)\/(\d+)\)/);
return {
age: m[1],
requesters: m[2],
downloaded: eps ? parseInt(eps[1]) : null,
total: eps ? parseInt(eps[2]) : null,
};
}
function parsePending(desc: string) {
const m = desc.match(/^Awaiting approval for (.+?)\. Requested by (.+?)\.?$/);
if (!m) return null;
return { age: m[1], requesters: m[2] };
}
function parseWatchrate(desc: string) {
const pctM = desc.match(/~(\d+)%/);
const playsM = desc.match(/\((\d+) plays/);
const reqM = desc.match(/plays, (\d+) requests\)/);
if (!pctM || !playsM || !reqM) return null;
return { pct: pctM[1], plays: playsM[1], requests: reqM[1] };
}
// ── Embed builder ─────────────────────────────────────────────────────────────
function buildEmbed(alert: AlertCandidate): DiscordEmbed {
const color = SEVERITY_COLOR[alert.severity] ?? SEVERITY_COLOR.info;
const footer = { text: `OverSnitch · ${SEVERITY_LABEL[alert.severity] ?? alert.severity}` };
const timestamp = new Date().toISOString();
// Title links to Seerr media page if available, otherwise *arr
const url = alert.seerrMediaUrl ?? alert.mediaUrl ?? undefined;
const fields: EmbedField[] = [];
// ── unfulfilled ────────────────────────────────────────────────────────────
if (alert.category === "unfulfilled") {
const partial = parseUnfulfilledPartial(alert.description);
if (partial) {
fields.push({ name: "Requested by", value: partial.requesters, inline: true });
fields.push({ name: "Approved", value: `${partial.age} ago`, inline: true });
if (partial.downloaded !== null && partial.total !== null) {
const pct = Math.round((partial.downloaded / partial.total) * 100);
fields.push({
name: "Downloaded",
value: `${partial.downloaded} / ${partial.total} episodes (${pct}%)`,
inline: false,
});
}
return { title: alert.title, color, url, fields, footer, timestamp };
}
const complete = parseUnfulfilledComplete(alert.description);
if (complete) {
fields.push({ name: "Requested by", value: complete.requesters, inline: true });
fields.push({ name: "Approved", value: `${complete.age} ago`, inline: true });
// Capitalise "no file found in Radarr" → "No file found in Radarr"
const detail = complete.detail.charAt(0).toUpperCase() + complete.detail.slice(1);
fields.push({ name: "Status", value: detail, inline: false });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── pending ────────────────────────────────────────────────────────────────
if (alert.category === "pending") {
const p = parsePending(alert.description);
if (p) {
fields.push({ name: "Requested by", value: p.requesters, inline: true });
fields.push({ name: "Waiting", value: p.age, inline: true });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── ghost ──────────────────────────────────────────────────────────────────
if (alert.category === "ghost" && alert.userName) {
fields.push({ name: "User", value: alert.userName, inline: false });
// Trim the redundant name prefix from the description
// "Peri Wright has made 8 requests but hasn't watched…"
// → "Has made 8 requests but hasn't watched…"
const desc = alert.description.replace(
new RegExp(`^${alert.userName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+`),
""
);
const sentence = desc.charAt(0).toUpperCase() + desc.slice(1);
return { title: alert.title, description: sentence, color, url, fields, footer, timestamp };
}
// ── watchrate ──────────────────────────────────────────────────────────────
if (alert.category === "watchrate") {
const w = parseWatchrate(alert.description);
if (w && alert.userName) {
fields.push({ name: "User", value: alert.userName, inline: true });
fields.push({ name: "Watch rate", value: `~${w.pct}%`, inline: true });
fields.push({ name: "Plays", value: w.plays, inline: true });
fields.push({ name: "Requests", value: w.requests, inline: true });
return { title: alert.title, color, url, fields, footer, timestamp };
}
}
// ── fallback: plain description ────────────────────────────────────────────
return {
title: alert.title,
description: alert.description,
color,
url,
fields,
footer,
timestamp,
};
}
// ── Transport ─────────────────────────────────────────────────────────────────
async function postToWebhook(webhookUrl: string, embeds: DiscordEmbed[]): Promise<void> {
const res = await fetch(webhookUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "OverSnitch", embeds }),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`Discord webhook error ${res.status}: ${text}`);
}
}
/**
* Sends one Discord message per batch of up to 10 alerts.
* Silently no-ops if no webhook URL is configured.
* Errors are logged but never thrown.
*/
export async function sendDiscordNotifications(alerts: AlertCandidate[]): Promise<void> {
if (alerts.length === 0) return;
const { discord } = getSettings();
if (!discord.webhookUrl) return;
const embeds = alerts.map(buildEmbed);
// Discord allows up to 10 embeds per message
for (let i = 0; i < embeds.length; i += 10) {
try {
await postToWebhook(discord.webhookUrl, embeds.slice(i, i + 10));
} catch (err) {
console.error("[discord] Failed to send notification:", err);
}
}
}
/**
* Sends a single test embed. Used by the settings test endpoint.
*/
export async function sendDiscordTestNotification(webhookUrl: string): Promise<void> {
await postToWebhook(webhookUrl, [
{
title: "OverSnitch — Test Notification",
description: "Your Discord webhook is configured correctly. You'll receive alerts here when new issues are detected.",
color: SEVERITY_COLOR.info,
fields: [],
footer: { text: "OverSnitch" },
timestamp: new Date().toISOString(),
},
]);
}
-46
View File
@@ -1,46 +0,0 @@
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
@@ -1,27 +0,0 @@
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());
}
-54
View File
@@ -1,54 +0,0 @@
import { OverseerrUser, OverseerrRequest } from "@/lib/types";
import { getSettings } from "@/lib/settings";
const TAKE = 100;
export async function fetchAllUsers(): Promise<OverseerrUser[]> {
const { seerr } = getSettings();
const all: OverseerrUser[] = [];
let skip = 0;
while (true) {
const res = await fetch(
`${seerr.url}/api/v1/user?take=${TAKE}&skip=${skip}&sort=created`,
{ headers: { "X-Api-Key": seerr.apiKey } }
);
if (!res.ok) {
throw new Error(`Overseerr users API error: ${res.status} ${res.statusText}`);
}
const data: { results: OverseerrUser[] } = await res.json();
all.push(...data.results);
if (data.results.length < TAKE) break;
skip += TAKE;
}
return all;
}
export async function fetchUserRequests(userId: number): Promise<OverseerrRequest[]> {
const { seerr } = getSettings();
const all: OverseerrRequest[] = [];
let skip = 0;
while (true) {
const res = await fetch(
`${seerr.url}/api/v1/request?requestedBy=${userId}&take=${TAKE}&skip=${skip}`,
{ headers: { "X-Api-Key": seerr.apiKey } }
);
if (!res.ok) {
throw new Error(`Overseerr requests API error for user ${userId}: ${res.status} ${res.statusText}`);
}
const data: { results: OverseerrRequest[] } = await res.json();
all.push(...data.results);
if (data.results.length < TAKE) break;
skip += TAKE;
}
return all;
}
-21
View File
@@ -1,21 +0,0 @@
import { RadarrMovie, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildRadarrMap(): Promise<Map<number, MediaEntry>> {
const { radarr } = getSettings();
const res = await fetch(`${radarr.url}/api/v3/movie`, {
headers: { "X-Api-Key": radarr.apiKey },
});
if (!res.ok) {
throw new Error(`Radarr API error: ${res.status} ${res.statusText}`);
}
const movies: RadarrMovie[] = await res.json();
return new Map(
movies.map((m) => [
m.tmdbId,
{ title: m.title, titleSlug: m.titleSlug, sizeOnDisk: m.sizeOnDisk, available: m.isAvailable },
])
);
}
-95
View File
@@ -1,95 +0,0 @@
/**
* Persistent settings store.
*
* Settings are read from data/settings.json when present, with process.env
* values used as fallbacks. This means existing .env.local setups keep working
* with no changes; the UI just provides an alternative way to configure them.
*/
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
import { join } from "path";
const DATA_DIR = join(process.cwd(), "data");
const SETTINGS_PATH = join(DATA_DIR, "settings.json");
export interface ServiceConfig {
url: string;
apiKey: string;
}
export interface DiscordConfig {
webhookUrl: string;
}
export interface AppSettings {
radarr: ServiceConfig;
sonarr: ServiceConfig;
seerr: ServiceConfig;
tautulli: ServiceConfig;
discord: DiscordConfig;
}
interface StoredSettings {
radarr?: Partial<ServiceConfig>;
sonarr?: Partial<ServiceConfig>;
seerr?: Partial<ServiceConfig>;
tautulli?: Partial<ServiceConfig>;
discord?: Partial<DiscordConfig>;
}
function readFile(): StoredSettings {
try {
return JSON.parse(readFileSync(SETTINGS_PATH, "utf-8")) as StoredSettings;
} catch {
return {};
}
}
/** Returns the merged settings (file values override env vars). */
export function getSettings(): AppSettings {
const f = readFile();
return {
radarr: {
url: f.radarr?.url ?? process.env.RADARR_URL ?? "",
apiKey: f.radarr?.apiKey ?? process.env.RADARR_API ?? "",
},
sonarr: {
url: f.sonarr?.url ?? process.env.SONARR_URL ?? "",
apiKey: f.sonarr?.apiKey ?? process.env.SONARR_API ?? "",
},
seerr: {
url: f.seerr?.url ?? process.env.SEERR_URL ?? "",
apiKey: f.seerr?.apiKey ?? process.env.SEERR_API ?? "",
},
tautulli: {
url: f.tautulli?.url ?? process.env.TAUTULLI_URL ?? "",
apiKey: f.tautulli?.apiKey ?? process.env.TAUTULLI_API ?? "",
},
discord: {
webhookUrl: f.discord?.webhookUrl ?? process.env.DISCORD_WEBHOOK ?? "",
},
};
}
/** 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. */
export function saveSettings(settings: AppSettings): AppSettings {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
// Strip trailing slashes from URLs for consistency
const clean: StoredSettings = {
radarr: { url: settings.radarr.url.replace(/\/+$/, ""), apiKey: settings.radarr.apiKey },
sonarr: { url: settings.sonarr.url.replace(/\/+$/, ""), apiKey: settings.sonarr.apiKey },
seerr: { url: settings.seerr.url.replace(/\/+$/, ""), apiKey: settings.seerr.apiKey },
tautulli: { url: settings.tautulli.url.replace(/\/+$/, ""), apiKey: settings.tautulli.apiKey },
discord: { webhookUrl: settings.discord.webhookUrl.trim() },
};
writeFileSync(SETTINGS_PATH, JSON.stringify(clean, null, 2), "utf-8");
return getSettings();
}
-31
View File
@@ -1,31 +0,0 @@
import { SonarrSeries, MediaEntry } from "@/lib/types";
import { getSettings } from "@/lib/settings";
export async function buildSonarrMap(): Promise<Map<number, MediaEntry>> {
const { sonarr } = getSettings();
const res = await fetch(`${sonarr.url}/api/v3/series`, {
headers: { "X-Api-Key": sonarr.apiKey },
});
if (!res.ok) {
throw new Error(`Sonarr API error: ${res.status} ${res.statusText}`);
}
const series: SonarrSeries[] = await res.json();
return new Map(
series.map((s) => [
s.tvdbId,
{
title: s.title,
titleSlug: s.titleSlug,
sizeOnDisk: s.statistics.sizeOnDisk,
// "upcoming" = series hasn't started airing yet
available: s.status !== "upcoming",
episodeFileCount: s.statistics.episodeFileCount,
totalEpisodeCount: s.statistics.totalEpisodeCount,
percentOfEpisodes: s.statistics.percentOfEpisodes,
seriesStatus: s.status,
},
])
);
}
-135
View File
@@ -1,135 +0,0 @@
/**
* Shared stats-build logic and server-side SWR cache.
*
* Imported by both the /api/stats route handler (for on-demand fetches) and
* instrumentation.ts (for the background poller that runs independent of
* client activity).
*/
import { buildRadarrMap } from "@/lib/radarr";
import { buildSonarrMap } from "@/lib/sonarr";
import { fetchAllUsers, fetchUserRequests } from "@/lib/overseerr";
import { buildTautulliMap, lookupTautulliUser, fetchUserWatchHistory } from "@/lib/tautulli";
import { computeStats } from "@/lib/aggregate";
import { isConfigured } from "@/lib/settings";
import {
DashboardStats,
MediaEntry,
OverseerrRequest,
TautulliUser,
WatchDataPoint,
} from "@/lib/types";
const BATCH_SIZE = 5;
const STALE_MS = 5 * 60 * 1000;
const POLL_INTERVAL_MS = 5 * 60 * 1000;
// ── Encapsulated cache ────────────────────────────────────────────────────────
let cache: { stats: DashboardStats; at: number } | null = null;
let refreshing = false;
/** Raw data cached alongside stats for use by the user-page API route. */
export interface RawCache {
radarrMap: Map<number, MediaEntry>;
sonarrMap: Map<number, MediaEntry>;
allRequests: Map<number, OverseerrRequest[]>;
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;
export function getRawCache(): RawCache | null {
return rawCache;
}
// ── Core build function ───────────────────────────────────────────────────────
async function buildStats(): Promise<DashboardStats> {
const [radarrMap, sonarrMap, users, tautulliMap] = await Promise.all([
buildRadarrMap(),
buildSonarrMap(),
fetchAllUsers(),
buildTautulliMap(),
]);
const allRequests = new Map<number, OverseerrRequest[]>();
for (let i = 0; i < users.length; i += BATCH_SIZE) {
const chunk = users.slice(i, i + BATCH_SIZE);
const results = await Promise.all(chunk.map((u) => fetchUserRequests(u.id)));
chunk.forEach((u, idx) => allRequests.set(u.id, results[idx]));
}
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);
}
// ── Public API used by the route handler ─────────────────────────────────────
/**
* Returns stats, using the in-process cache.
* - force=true: always fetches fresh data and waits for it
* - force=false: returns cache immediately; if stale, kicks a background refresh
*/
export async function getStats(force = false): Promise<DashboardStats> {
if (force || !cache) {
const stats = await buildStats();
cache = { stats, at: Date.now() };
return stats;
}
const age = Date.now() - cache.at;
if (age > STALE_MS && !refreshing) {
refreshing = true;
buildStats()
.then((stats) => { cache = { stats, at: Date.now() }; })
.catch(() => {})
.finally(() => { refreshing = false; });
}
return cache.stats;
}
// ── Background poller ─────────────────────────────────────────────────────────
async function poll() {
if (refreshing) return;
if (!isConfigured()) return;
refreshing = true;
try {
const stats = await buildStats();
cache = { stats, at: Date.now() };
console.log("[poller] Stats refreshed at", new Date().toISOString());
} catch (err) {
console.error("[poller] Refresh failed:", err);
} finally {
refreshing = false;
}
}
/**
* Starts the background poller. Called once from instrumentation.ts on server
* startup. Runs an initial fetch immediately, then repeats every 5 minutes.
*/
export function startBackgroundPoller() {
console.log("[poller] Starting (interval: 5 min)");
poll(); // immediate first run — no waiting for a client request
setInterval(poll, POLL_INTERVAL_MS);
}
-150
View File
@@ -1,150 +0,0 @@
import { TautulliUser, WatchDataPoint } from "@/lib/types";
import { getSettings } from "@/lib/settings";
interface TautulliRow {
user_id: number;
friendly_name: string;
email: string;
plays: number;
duration: number;
last_seen: number | null;
}
interface TautulliResponse {
response: {
result: string;
data: {
data: TautulliRow[];
};
};
}
/**
* Returns a Map<lowercaseEmail, TautulliUser>.
* Returns null if TAUTULLI_URL/TAUTULLI_API are not set.
*/
export async function buildTautulliMap(): Promise<Map<string, TautulliUser> | null> {
const { tautulli } = getSettings();
const url = tautulli.url;
const key = tautulli.apiKey;
if (!url || !key) return null;
const res = await fetch(
`${url}/api/v2?apikey=${key}&cmd=get_users_table&length=1000&order_column=friendly_name&order_dir=asc`,
{ cache: "no-store" }
);
if (!res.ok) {
throw new Error(`Tautulli API error: ${res.status} ${res.statusText}`);
}
const json: TautulliResponse = await res.json();
if (json.response.result !== "success") {
throw new Error(`Tautulli API returned non-success result`);
}
const map = new Map<string, TautulliUser>();
for (const row of json.response.data.data) {
const user: TautulliUser = {
user_id: row.user_id ?? 0,
friendly_name: row.friendly_name,
email: row.email ?? "",
plays: row.plays ?? 0,
duration: row.duration ?? 0,
last_seen: row.last_seen ?? null,
};
if (user.email) {
map.set(user.email.toLowerCase(), user);
}
// Also index by friendly_name as fallback key
if (user.friendly_name) {
map.set(`name:${user.friendly_name.toLowerCase()}`, user);
}
}
return map;
}
export function lookupTautulliUser(
tautulliMap: Map<string, TautulliUser>,
email: string,
displayName: string
): TautulliUser | null {
return (
tautulliMap.get(email.toLowerCase()) ??
tautulliMap.get(`name:${displayName.toLowerCase()}`) ??
null
);
}
interface TautulliHistoryRow {
date: number; // unix timestamp (session start)
duration: number; // seconds watched
}
interface TautulliHistoryResponse {
response: {
result: string;
data: {
recordsFiltered: number;
recordsTotal: number;
data: TautulliHistoryRow[];
};
};
}
/**
* Fetches individual session history for a Tautulli user and aggregates by day.
* Returns an empty array if Tautulli is not configured or the call fails.
*/
export async function fetchUserWatchHistory(
tautulliUserId: number
): Promise<WatchDataPoint[]> {
const { tautulli } = getSettings();
const { url, apiKey } = tautulli;
if (!url || !apiKey || !tautulliUserId) return [];
let res: Response;
try {
res = await fetch(
`${url}/api/v2?apikey=${apiKey}&cmd=get_history&user_id=${tautulliUserId}&length=10000&order_column=date&order_dir=asc`,
{ cache: "no-store" }
);
} catch {
return [];
}
if (!res.ok) return [];
let json: TautulliHistoryResponse;
try {
json = await res.json() as TautulliHistoryResponse;
} catch {
return [];
}
if (json.response?.result !== "success") return [];
const byDate = new Map<string, { plays: number; durationSeconds: number }>();
for (const row of json.response.data.data ?? []) {
if (!row.date) continue;
const date = new Date(row.date * 1000).toISOString().slice(0, 10);
const existing = byDate.get(date) ?? { plays: 0, durationSeconds: 0 };
existing.plays += 1;
existing.durationSeconds += row.duration ?? 0;
byDate.set(date, existing);
}
return Array.from(byDate.entries())
.map(([date, { plays, durationSeconds }]) => ({
date,
plays,
durationHours: Math.round((durationSeconds / 3600) * 10) / 10,
}))
.sort((a, b) => a.date.localeCompare(b.date));
}
-190
View File
@@ -1,190 +0,0 @@
// ─── Raw API shapes ───────────────────────────────────────────────────────────
export interface OverseerrUser {
id: number;
displayName: string;
email: string;
requestCount: number;
userType: number;
}
export interface OverseerrRequest {
id: number;
type: "movie" | "tv";
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string; // ISO timestamp
media: {
tmdbId: number;
tvdbId?: number;
title?: string;
};
}
export interface RadarrMovie {
tmdbId: number;
title: string;
titleSlug: string;
sizeOnDisk: number; // bytes
isAvailable: boolean; // false = unreleased / below minimum availability
}
export interface SonarrSeries {
tvdbId: number;
title: string;
titleSlug: string;
status: string; // "continuing" | "ended" | "upcoming" | "deleted"
statistics: {
sizeOnDisk: number; // bytes
episodeFileCount: number;
totalEpisodeCount: number;
percentOfEpisodes: number; // 0100
};
}
export interface TautulliUser {
user_id: number;
friendly_name: string;
email: string;
plays: number;
duration: number; // seconds
last_seen: number | null; // unix timestamp
}
// ─── Richer map value types ───────────────────────────────────────────────────
export interface MediaEntry {
title: string;
titleSlug?: string;
sizeOnDisk: number; // bytes
available: boolean; // false = unreleased, skip unfulfilled alerts
// TV-specific (undefined for movies)
episodeFileCount?: number;
totalEpisodeCount?: number;
percentOfEpisodes?: number; // 0100
seriesStatus?: string; // "continuing" | "ended" | "upcoming" | "deleted"
}
// ─── Aggregated output ────────────────────────────────────────────────────────
export interface UserStat {
userId: number;
displayName: string;
email: string;
requestCount: number;
totalBytes: number;
totalGB: number;
avgGB: number;
// Tautulli (null when not configured)
plays: number | null;
watchHours: number | null;
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)
storageRank: number;
requestRank: number;
playsRank: number | null;
watchRank: number | null;
loadRank: number | null;
totalUsers: number;
}
export interface DashboardStats {
users: UserStat[];
summary: {
totalUsers: number;
totalRequests: number;
totalStorageGB: number;
totalWatchHours: number | null;
openAlertCount: number;
};
generatedAt: string;
}
// ─── Alerts ───────────────────────────────────────────────────────────────────
export type AlertSeverity = "danger" | "warning" | "info";
export type AlertStatus = "open" | "closed";
/** In-memory candidate produced by alert generation logic */
export interface AlertCandidate {
key: string; // deterministic dedup key, e.g. "unfulfilled:movie:12345"
category: string; // e.g. "unfulfilled", "ghost", "pending"
severity: AlertSeverity;
title: string;
description: string;
// At most one of userId or mediaId is the "subject"
userId?: number;
userName?: string;
mediaId?: number;
mediaType?: "movie" | "tv";
mediaTitle?: string;
mediaUrl?: string; // direct link to the item in Radarr/Sonarr
seerrMediaUrl?: string; // link to the item's page in Overseerr/Jellyseerr
// Ordered list of Overseerr user IDs who triggered this alert (content alerts)
requesterIds?: number[];
}
export interface AlertComment {
id: number;
body: string;
createdAt: string;
author: "user" | "system";
}
export type AlertCloseReason = "manual" | "resolved";
// ─── User page ────────────────────────────────────────────────────────────────
/** A single Overseerr request enriched with resolved media title and size */
export interface EnrichedRequest {
id: number;
type: "movie" | "tv";
status: number; // media status: 1=unknown, 2=pending, 3=processing, 4=partial, 5=available
createdAt: string;
mediaId: number; // tmdbId for movies, tvdbId for TV
title: string;
sizeOnDisk: number; // bytes
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 */
export interface WatchDataPoint {
date: string; // YYYY-MM-DD
plays: 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 {
stat: UserStat;
enrichedRequests: EnrichedRequest[];
watchHistory: WatchDataPoint[]; // daily, sorted ascending; empty if Tautulli not configured
openAlerts: Alert[];
serverAverages: ServerAverages;
}
/** Full persisted alert returned by the API */
export interface Alert extends AlertCandidate {
id: number; // auto-increment DB id (used in URLs)
status: AlertStatus;
closeReason: AlertCloseReason | null; // null = still open
suppressedUntil: string | null; // only set on manual close
firstSeen: string;
lastSeen: string;
closedAt: string | null;
comments: AlertComment[];
}
-97
View File
@@ -1,97 +0,0 @@
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;
}
-34
View File
@@ -1,34 +0,0 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./src/*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}