Compare commits
3 Commits
03dcd606f8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 2b9883d99f | |||
| 29e6933505 | |||
| ead2cdbc3c |
+8
-2
@@ -24,7 +24,8 @@ ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
RUN apk add --no-cache su-exec \
|
||||
&& addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
@@ -33,7 +34,12 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
RUN mkdir -p /app/data && chown nextjs:nodejs /app/data
|
||||
|
||||
USER nextjs
|
||||
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"]
|
||||
|
||||
@@ -10,7 +10,7 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
|
||||
|
||||
- **Leaderboard** — per-user request count, total storage, average GB per request, and optional Tautulli watch stats (plays, watch hours), each ranked against the full userbase
|
||||
- **User detail pages** — click any user in the leaderboard to see their full profile: stat cards, an activity chart with 1W/1M/3M/1Y timeframes, and a complete request history sorted newest-first
|
||||
- **Activity chart** — two modes: *Metrics* (requests, storage GB, watch hours as separate toggleable lines, with a Raw/Relative normalization toggle) and *Storage Load* (GB requested ÷ watch hours per bucket, with an all-time average reference line)
|
||||
- **Activity chart** — single metric picker (Requests · Storage · Watch Hours · Storage Load) × timeframe (1W/1M/3M/1Y), with both the user's own average and the server-wide average shown as reference lines. Storage Load is a running cumulative GB ÷ watch hours — a new request spikes the line up and subsequent watching drags it back down.
|
||||
- **Alerting** — automatic alerts for stalled downloads, neglected requesters, and abusive patterns, with open/close state, notes, and auto-resolve when conditions clear
|
||||
- **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
|
||||
@@ -20,29 +20,33 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
|
||||
|
||||
## Setup
|
||||
|
||||
### 1. Clone and install
|
||||
### Option A — Docker Compose (recommended)
|
||||
|
||||
Images are published to `gitea.thewrightserver.net/josh/oversnitch` on every push to `main` by the Gitea Actions workflow at [.gitea/workflows/build.yml](.gitea/workflows/build.yml).
|
||||
|
||||
On the deployment host:
|
||||
|
||||
```bash
|
||||
# Pull the latest image and start
|
||||
docker login gitea.thewrightserver.net # if the registry is private
|
||||
docker compose pull
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The bundled [docker-compose.yml](docker-compose.yml) mounts `./data` into `/app/data`, so `alerts.db` and `settings.json` persist across restarts. The dashboard listens on `http://localhost:3000` — after first boot, click the gear icon and enter your service URLs and API keys there.
|
||||
|
||||
To configure services via environment instead of the Settings UI, uncomment the relevant lines in `docker-compose.yml` (see the full list in *Option B*).
|
||||
|
||||
### Option B — Local Node
|
||||
|
||||
```bash
|
||||
git clone https://gitea.thewrightserver.net/josh/OverSnitch.git
|
||||
cd OverSnitch
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure
|
||||
|
||||
**Option A — Settings UI (recommended)**
|
||||
|
||||
Start the app and click the gear icon in the top-right corner. Enter your service URLs and API keys, hit **Test** to verify each connection, then **Save**.
|
||||
|
||||
```bash
|
||||
npm run dev # or: npm run build && npm start
|
||||
```
|
||||
|
||||
Settings are written to `data/settings.json` (gitignored).
|
||||
|
||||
**Option B — Environment variables**
|
||||
|
||||
Create `.env.local` in the project root. Values here are used as fallbacks when `data/settings.json` doesn't exist or doesn't contain an override.
|
||||
Configure through the Settings UI, or create `.env.local` with any of:
|
||||
|
||||
```env
|
||||
# Required
|
||||
@@ -66,6 +70,19 @@ DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
|
||||
# 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
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# /app/data is usually a bind mount from the host, so whatever permissions the
|
||||
# image set during build don't survive. Fix ownership on boot, then drop root.
|
||||
if [ -d /app/data ]; then
|
||||
chown -R nextjs:nodejs /app/data 2>/dev/null || true
|
||||
fi
|
||||
|
||||
exec su-exec nextjs:nodejs "$@"
|
||||
@@ -1,6 +1,10 @@
|
||||
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));
|
||||
|
||||
@@ -8,6 +8,7 @@ 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";
|
||||
@@ -19,6 +20,7 @@ export default function Page() {
|
||||
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) => {
|
||||
@@ -32,8 +34,15 @@ export default function Page() {
|
||||
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) {
|
||||
@@ -67,6 +76,17 @@ export default function Page() {
|
||||
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">
|
||||
|
||||
|
||||
@@ -2,21 +2,8 @@
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
|
||||
|
||||
// ── Icons ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
import ServiceSection from "@/components/settings/ServiceSection";
|
||||
import DiscordSection from "@/components/settings/DiscordSection";
|
||||
|
||||
function XIcon() {
|
||||
return (
|
||||
@@ -26,219 +13,6 @@ function XIcon() {
|
||||
);
|
||||
}
|
||||
|
||||
// ── Per-service section ───────────────────────────────────────────────────────
|
||||
|
||||
type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
|
||||
|
||||
interface SectionProps {
|
||||
id: ServiceKey;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
optional?: boolean;
|
||||
config: ServiceConfig;
|
||||
onChange: (patch: Partial<ServiceConfig>) => void;
|
||||
}
|
||||
|
||||
function ServiceSection({ id, label, placeholder, optional, config, onChange }: SectionProps) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Clear test result when inputs change
|
||||
function handleUrlChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ url: v });
|
||||
}
|
||||
function handleKeyChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ apiKey: v });
|
||||
}
|
||||
|
||||
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
|
||||
{optional && (
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{/* URL */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder="••••••••••••••••••••••••••••••••"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((s) => !s)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
<EyeIcon open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Test result */}
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Discord section (webhook URL only, no API key) ────────────────────────────
|
||||
|
||||
interface DiscordSectionProps {
|
||||
config: DiscordConfig;
|
||||
onChange: (patch: Partial<DiscordConfig>) => void;
|
||||
}
|
||||
|
||||
function DiscordSection({ config, onChange }: DiscordSectionProps) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ webhookUrl: v });
|
||||
}
|
||||
|
||||
const canTest = config.webhookUrl.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhookUrl}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Sending…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Modal ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
@@ -261,7 +35,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
const [saveResult, setSaveResult] = useState<"saved" | "error" | null>(null);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Load current settings when modal opens
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setSaveResult(null);
|
||||
@@ -273,7 +46,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
.finally(() => setLoading(false));
|
||||
}, [open]);
|
||||
|
||||
// Close on Escape
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function onKey(e: KeyboardEvent) {
|
||||
@@ -283,7 +55,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
return () => document.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
// Close on backdrop click
|
||||
function handleBackdrop(e: React.MouseEvent) {
|
||||
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
|
||||
onClose();
|
||||
@@ -336,7 +107,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
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]"
|
||||
>
|
||||
{/* Header */}
|
||||
<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
|
||||
@@ -348,7 +118,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5">
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -396,7 +165,6 @@ export default function SettingsModal({ open, onClose, onSaved }: Props) {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<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" && (
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
|
||||
import ServiceSection from "@/components/settings/ServiceSection";
|
||||
import DiscordSection from "@/components/settings/DiscordSection";
|
||||
|
||||
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
|
||||
const EMPTY_SETTINGS: AppSettings = {
|
||||
radarr: EMPTY_CONFIG,
|
||||
sonarr: EMPTY_CONFIG,
|
||||
seerr: EMPTY_CONFIG,
|
||||
tautulli: EMPTY_CONFIG,
|
||||
discord: { webhookUrl: "" },
|
||||
};
|
||||
|
||||
function filled(c: ServiceConfig) {
|
||||
return c.url.trim() !== "" && c.apiKey.trim() !== "";
|
||||
}
|
||||
|
||||
export default function SetupScreen({ onComplete }: { onComplete: () => void }) {
|
||||
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
|
||||
setError(null);
|
||||
setSettings((prev) => ({ ...prev, [service]: { ...prev[service], ...partial } }));
|
||||
}
|
||||
|
||||
function patchDiscord(partial: Partial<DiscordConfig>) {
|
||||
setError(null);
|
||||
setSettings((prev) => ({ ...prev, discord: { ...prev.discord, ...partial } }));
|
||||
}
|
||||
|
||||
const canComplete = filled(settings.radarr) && filled(settings.sonarr) && filled(settings.seerr);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(settings),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
onComplete();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]">
|
||||
<div className="px-6 py-6 border-b border-slate-800 shrink-0">
|
||||
<h1 className="text-2xl font-bold tracking-tight">
|
||||
Welcome to Over<span className="text-yellow-400">Snitch</span>
|
||||
</h1>
|
||||
<p className="text-slate-400 text-sm mt-2">
|
||||
Connect your services to get started. You can change any of these later in Settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
|
||||
<ServiceSection
|
||||
id="radarr"
|
||||
label="Radarr"
|
||||
placeholder="http://radarr:7878"
|
||||
config={settings.radarr}
|
||||
onChange={(p) => patch("radarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="sonarr"
|
||||
label="Sonarr"
|
||||
placeholder="http://sonarr:8989"
|
||||
config={settings.sonarr}
|
||||
onChange={(p) => patch("sonarr", p)}
|
||||
/>
|
||||
<ServiceSection
|
||||
id="seerr"
|
||||
label="Overseerr / Jellyseerr"
|
||||
placeholder="http://overseerr:5055"
|
||||
config={settings.seerr}
|
||||
onChange={(p) => patch("seerr", p)}
|
||||
/>
|
||||
|
||||
<div className="pt-1 text-xs uppercase tracking-wider text-slate-600 font-semibold">
|
||||
Optional
|
||||
</div>
|
||||
|
||||
<ServiceSection
|
||||
id="tautulli"
|
||||
label="Tautulli"
|
||||
placeholder="http://tautulli:8181"
|
||||
optional
|
||||
config={settings.tautulli}
|
||||
onChange={(p) => patch("tautulli", p)}
|
||||
/>
|
||||
<DiscordSection
|
||||
config={settings.discord}
|
||||
onChange={(p) => patchDiscord(p)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-4 border-t border-slate-800 shrink-0 space-y-3">
|
||||
{error && (
|
||||
<div className="text-xs text-red-400">Save failed: {error}</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={!canComplete || saving}
|
||||
className="w-full rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2.5 text-sm font-semibold text-black transition-colors"
|
||||
>
|
||||
{saving ? "Saving…" : "Complete Setup"}
|
||||
</button>
|
||||
{!canComplete && (
|
||||
<p className="text-xs text-slate-600 text-center">
|
||||
Fill in URL and API key for Radarr, Sonarr, and Overseerr to continue.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { DiscordConfig } from "@/lib/settings";
|
||||
|
||||
interface Props {
|
||||
config: DiscordConfig;
|
||||
onChange: (patch: Partial<DiscordConfig>) => void;
|
||||
}
|
||||
|
||||
export default function DiscordSection({ config, onChange }: Props) {
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ webhookUrl: v });
|
||||
}
|
||||
|
||||
const canTest = config.webhookUrl.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={config.webhookUrl}
|
||||
onChange={(e) => handleChange(e.target.value)}
|
||||
placeholder="https://discord.com/api/webhooks/…"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Sending…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ServiceConfig } from "@/lib/settings";
|
||||
|
||||
function EyeIcon({ open }: { open: boolean }) {
|
||||
return open ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
|
||||
|
||||
interface Props {
|
||||
id: ServiceKey;
|
||||
label: string;
|
||||
placeholder: string;
|
||||
optional?: boolean;
|
||||
config: ServiceConfig;
|
||||
onChange: (patch: Partial<ServiceConfig>) => void;
|
||||
}
|
||||
|
||||
export default function ServiceSection({ id, label, placeholder, optional, config, onChange }: Props) {
|
||||
const [showKey, setShowKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleTest() {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await fetch("/api/settings/test", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
|
||||
});
|
||||
const data = await res.json() as { ok: boolean; message: string };
|
||||
setTestResult(data);
|
||||
} catch {
|
||||
setTestResult({ ok: false, message: "Network error" });
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleUrlChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ url: v });
|
||||
}
|
||||
function handleKeyChange(v: string) {
|
||||
setTestResult(null);
|
||||
onChange({ apiKey: v });
|
||||
}
|
||||
|
||||
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
|
||||
{optional && (
|
||||
<span className="text-xs text-slate-600 font-normal">optional</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
|
||||
<input
|
||||
type="text"
|
||||
value={config.url}
|
||||
onChange={(e) => handleUrlChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
|
||||
<div className="flex flex-1 gap-2">
|
||||
<div className="relative flex-1">
|
||||
<input
|
||||
type={showKey ? "text" : "password"}
|
||||
value={config.apiKey}
|
||||
onChange={(e) => handleKeyChange(e.target.value)}
|
||||
placeholder="••••••••••••••••••••••••••••••••"
|
||||
spellCheck={false}
|
||||
autoComplete="off"
|
||||
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowKey((s) => !s)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
|
||||
tabIndex={-1}
|
||||
aria-label={showKey ? "Hide API key" : "Show API key"}
|
||||
>
|
||||
<EyeIcon open={showKey} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={!canTest || testing}
|
||||
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
|
||||
>
|
||||
{testing ? "Testing…" : "Test"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{testResult && (
|
||||
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
|
||||
{testResult.ok ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
|
||||
</svg>
|
||||
)}
|
||||
{testResult.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -71,6 +71,12 @@ export function getSettings(): AppSettings {
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns true iff the three services required for the dashboard to function (Radarr, Sonarr, Overseerr/Jellyseerr) have both a URL and API key. */
|
||||
export function isConfigured(s: AppSettings = getSettings()): boolean {
|
||||
const filled = (c: ServiceConfig) => c.url.trim() !== "" && c.apiKey.trim() !== "";
|
||||
return filled(s.radarr) && filled(s.sonarr) && filled(s.seerr);
|
||||
}
|
||||
|
||||
/** Saves the provided settings to disk and returns the merged result. */
|
||||
export function saveSettings(settings: AppSettings): AppSettings {
|
||||
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -110,6 +111,7 @@ export async function getStats(force = false): Promise<DashboardStats> {
|
||||
|
||||
async function poll() {
|
||||
if (refreshing) return;
|
||||
if (!isConfigured()) return;
|
||||
refreshing = true;
|
||||
try {
|
||||
const stats = await buildStats();
|
||||
|
||||
Reference in New Issue
Block a user