Compare commits
1 Commits
main
..
dc369b13a7
| Author | SHA1 | Date | |
|---|---|---|---|
| dc369b13a7 |
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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 -->
|
||||
-45
@@ -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"]
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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 "$@"
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: "standalone",
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
Generated
-2596
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 +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 +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 +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 +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 |
@@ -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: <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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -1,7 +0,0 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
background: #020617; /* slate-950 */
|
||||
color: #f8fafc;
|
||||
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
])
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; // 0–100
|
||||
};
|
||||
}
|
||||
|
||||
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; // 0–100
|
||||
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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
Reference in New Issue
Block a user