Compare commits

...

3 Commits

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

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

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:21:37 -04:00
josh ead2cdbc3c Document Docker deployment and refreshed chart UX in README
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-19 10:09:51 -04:00
11 changed files with 439 additions and 253 deletions
+8 -2
View File
@@ -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"]
+34 -17
View File
@@ -10,7 +10,7 @@ Built with Next.js 16, TypeScript, and Tailwind CSS.
- **Leaderboard** — per-user request count, total storage, average GB per request, and optional Tautulli watch stats (plays, watch hours), each ranked against the full userbase
- **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
npm run dev # or: npm run build && npm start
```
### 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
+10
View File
@@ -0,0 +1,10 @@
#!/bin/sh
set -e
# /app/data is usually a bind mount from the host, so whatever permissions the
# image set during build don't survive. Fix ownership on boot, then drop root.
if [ -d /app/data ]; then
chown -R nextjs:nodejs /app/data 2>/dev/null || true
fi
exec su-exec nextjs:nodejs "$@"
+4
View File
@@ -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));
+20
View File
@@ -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 -234
View File
@@ -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" && (
+129
View File
@@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { AppSettings, ServiceConfig, DiscordConfig } from "@/lib/settings";
import ServiceSection from "@/components/settings/ServiceSection";
import DiscordSection from "@/components/settings/DiscordSection";
const EMPTY_CONFIG: ServiceConfig = { url: "", apiKey: "" };
const EMPTY_SETTINGS: AppSettings = {
radarr: EMPTY_CONFIG,
sonarr: EMPTY_CONFIG,
seerr: EMPTY_CONFIG,
tautulli: EMPTY_CONFIG,
discord: { webhookUrl: "" },
};
function filled(c: ServiceConfig) {
return c.url.trim() !== "" && c.apiKey.trim() !== "";
}
export default function SetupScreen({ onComplete }: { onComplete: () => void }) {
const [settings, setSettings] = useState<AppSettings>(EMPTY_SETTINGS);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
function patch(service: "radarr" | "sonarr" | "seerr" | "tautulli", partial: Partial<ServiceConfig>) {
setError(null);
setSettings((prev) => ({ ...prev, [service]: { ...prev[service], ...partial } }));
}
function patchDiscord(partial: Partial<DiscordConfig>) {
setError(null);
setSettings((prev) => ({ ...prev, discord: { ...prev.discord, ...partial } }));
}
const canComplete = filled(settings.radarr) && filled(settings.sonarr) && filled(settings.seerr);
async function handleSave() {
setSaving(true);
setError(null);
try {
const res = await fetch("/api/settings", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(settings),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
onComplete();
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}
return (
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-lg rounded-2xl bg-slate-900 border border-slate-700/60 shadow-2xl flex flex-col max-h-[90vh]">
<div className="px-6 py-6 border-b border-slate-800 shrink-0">
<h1 className="text-2xl font-bold tracking-tight">
Welcome to Over<span className="text-yellow-400">Snitch</span>
</h1>
<p className="text-slate-400 text-sm mt-2">
Connect your services to get started. You can change any of these later in Settings.
</p>
</div>
<div className="overflow-y-auto flex-1 px-6 py-5 space-y-5">
<ServiceSection
id="radarr"
label="Radarr"
placeholder="http://radarr:7878"
config={settings.radarr}
onChange={(p) => patch("radarr", p)}
/>
<ServiceSection
id="sonarr"
label="Sonarr"
placeholder="http://sonarr:8989"
config={settings.sonarr}
onChange={(p) => patch("sonarr", p)}
/>
<ServiceSection
id="seerr"
label="Overseerr / Jellyseerr"
placeholder="http://overseerr:5055"
config={settings.seerr}
onChange={(p) => patch("seerr", p)}
/>
<div className="pt-1 text-xs uppercase tracking-wider text-slate-600 font-semibold">
Optional
</div>
<ServiceSection
id="tautulli"
label="Tautulli"
placeholder="http://tautulli:8181"
optional
config={settings.tautulli}
onChange={(p) => patch("tautulli", p)}
/>
<DiscordSection
config={settings.discord}
onChange={(p) => patchDiscord(p)}
/>
</div>
<div className="px-6 py-4 border-t border-slate-800 shrink-0 space-y-3">
{error && (
<div className="text-xs text-red-400">Save failed: {error}</div>
)}
<button
onClick={handleSave}
disabled={!canComplete || saving}
className="w-full rounded-lg bg-yellow-500 hover:bg-yellow-400 disabled:opacity-40 disabled:cursor-not-allowed px-4 py-2.5 text-sm font-semibold text-black transition-colors"
>
{saving ? "Saving…" : "Complete Setup"}
</button>
{!canComplete && (
<p className="text-xs text-slate-600 text-center">
Fill in URL and API key for Radarr, Sonarr, and Overseerr to continue.
</p>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,86 @@
"use client";
import { useState } from "react";
import { DiscordConfig } from "@/lib/settings";
interface Props {
config: DiscordConfig;
onChange: (patch: Partial<DiscordConfig>) => void;
}
export default function DiscordSection({ config, onChange }: Props) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: "discord", url: config.webhookUrl.trim(), apiKey: "" }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
function handleChange(v: string) {
setTestResult(null);
onChange({ webhookUrl: v });
}
const canTest = config.webhookUrl.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">Discord</h3>
<span className="text-xs text-slate-600 font-normal">optional</span>
</div>
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">Webhook</label>
<div className="flex flex-1 gap-2">
<input
type="text"
value={config.webhookUrl}
onChange={(e) => handleChange(e.target.value)}
placeholder="https://discord.com/api/webhooks/…"
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Sending…" : "Test"}
</button>
</div>
</div>
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
+138
View File
@@ -0,0 +1,138 @@
"use client";
import { useState } from "react";
import { ServiceConfig } from "@/lib/settings";
function EyeIcon({ open }: { open: boolean }) {
return open ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="h-4 w-4">
<path strokeLinecap="round" strokeLinejoin="round" d="M3.98 8.223A10.477 10.477 0 0 0 1.934 12C3.226 16.338 7.244 19.5 12 19.5c.993 0 1.953-.138 2.863-.395M6.228 6.228A10.451 10.451 0 0 1 12 4.5c4.756 0 8.773 3.162 10.065 7.498a10.522 10.522 0 0 1-4.293 5.774M6.228 6.228 3 3m3.228 3.228 3.65 3.65m7.894 7.894L21 21m-3.228-3.228-3.65-3.65m0 0a3 3 0 1 0-4.243-4.243m4.242 4.242L9.88 9.88" />
</svg>
);
}
export type ServiceKey = "radarr" | "sonarr" | "seerr" | "tautulli";
interface Props {
id: ServiceKey;
label: string;
placeholder: string;
optional?: boolean;
config: ServiceConfig;
onChange: (patch: Partial<ServiceConfig>) => void;
}
export default function ServiceSection({ id, label, placeholder, optional, config, onChange }: Props) {
const [showKey, setShowKey] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ ok: boolean; message: string } | null>(null);
async function handleTest() {
setTesting(true);
setTestResult(null);
try {
const res = await fetch("/api/settings/test", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ service: id, url: config.url.trim(), apiKey: config.apiKey.trim() }),
});
const data = await res.json() as { ok: boolean; message: string };
setTestResult(data);
} catch {
setTestResult({ ok: false, message: "Network error" });
} finally {
setTesting(false);
}
}
function handleUrlChange(v: string) {
setTestResult(null);
onChange({ url: v });
}
function handleKeyChange(v: string) {
setTestResult(null);
onChange({ apiKey: v });
}
const canTest = config.url.trim().length > 0 && config.apiKey.trim().length > 0;
return (
<div className="space-y-3 pb-5 border-b border-slate-800 last:border-0 last:pb-0">
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-slate-200">{label}</h3>
{optional && (
<span className="text-xs text-slate-600 font-normal">optional</span>
)}
</div>
<div className="space-y-2">
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">URL</label>
<input
type="text"
value={config.url}
onChange={(e) => handleUrlChange(e.target.value)}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
className="flex-1 rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-600 px-3 py-2 focus:outline-none transition-colors font-mono"
/>
</div>
<div className="flex items-center gap-3">
<label className="w-16 shrink-0 text-xs text-slate-500 text-right">API Key</label>
<div className="flex flex-1 gap-2">
<div className="relative flex-1">
<input
type={showKey ? "text" : "password"}
value={config.apiKey}
onChange={(e) => handleKeyChange(e.target.value)}
placeholder="••••••••••••••••••••••••••••••••"
spellCheck={false}
autoComplete="off"
className="w-full rounded-lg bg-slate-800 border border-slate-700/60 focus:border-slate-500 text-sm text-white placeholder-slate-700 px-3 py-2 pr-9 focus:outline-none transition-colors font-mono"
/>
<button
type="button"
onClick={() => setShowKey((s) => !s)}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-slate-600 hover:text-slate-400 transition-colors"
tabIndex={-1}
aria-label={showKey ? "Hide API key" : "Show API key"}
>
<EyeIcon open={showKey} />
</button>
</div>
<button
type="button"
onClick={handleTest}
disabled={!canTest || testing}
className="shrink-0 rounded-lg border border-slate-600 bg-slate-700/40 hover:bg-slate-700 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-2 text-xs font-medium text-slate-300 hover:text-white transition-colors whitespace-nowrap"
>
{testing ? "Testing…" : "Test"}
</button>
</div>
</div>
</div>
{testResult && (
<div className={`ml-[76px] flex items-center gap-1.5 text-xs ${testResult.ok ? "text-green-400" : "text-red-400"}`}>
{testResult.ok ? (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor" className="h-3.5 w-3.5 shrink-0">
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18 18 6M6 6l12 12" />
</svg>
)}
{testResult.message}
</div>
)}
</div>
);
}
+6
View File
@@ -71,6 +71,12 @@ export function getSettings(): AppSettings {
};
}
/** Returns true iff the three services required for the dashboard to function (Radarr, Sonarr, Overseerr/Jellyseerr) have both a URL and API key. */
export function isConfigured(s: AppSettings = getSettings()): boolean {
const filled = (c: ServiceConfig) => c.url.trim() !== "" && c.apiKey.trim() !== "";
return filled(s.radarr) && filled(s.sonarr) && filled(s.seerr);
}
/** Saves the provided settings to disk and returns the merged result. */
export function saveSettings(settings: AppSettings): AppSettings {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
+2
View File
@@ -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();