@@ -452,275 +249,6 @@ export function BackupSection({
)}
-
- {/* Scheduled backups */}
-
-
-
-
-
-
-
- Scheduled
-
- {scheduledEnabled ? (
-
-
- {formatNextBackup(frequency, time, dow)}.
- {" "}
- Keeping{" "}
-
-
- {maxRetention === 0
- ? "unlimited"
- : `last ${maxRetention}`}
-
-
-
-
- handleMaxRetentionChange(Number(v))
- }
- >
- {[3, 5, 7, 14, 30, 0].map((n) => (
-
- {n === 0 ? "unlimited" : n}
-
- ))}
-
-
- {" "}
- backups.
-
- ) : (
- "Automatically back up your database on a schedule"
- )}
-
-
-
-
-
-
- {/* Schedule configurator */}
-
- {scheduledEnabled && (
-
-
- {/* Frequency selector */}
-
-
- Frequency
-
-
- {FREQUENCY_OPTIONS.map((opt) => (
-
- ))}
-
-
-
- {/* Day of week — shown for 7d only */}
-
- {frequency === "7d" && (
-
-
- Day:{" "}
-
-
-
- {DAYS_OF_WEEK[dow]}
-
-
-
-
- handleScheduleChange(frequency, time, Number(v))
- }
- >
- {DAYS_OF_WEEK.map((day, i) => (
-
- {day}
-
- ))}
-
-
-
-
- )}
-
-
- {/* Time selector — shown for 12h, 1d, 7d */}
-
- {frequency !== "6h" && (
-
-
- {frequency === "12h" ? "Starting at" : "Time:"}{" "}
-
-
-
- {format(
- new Date(
- 2000,
- 0,
- 1,
- ...(time.split(":").map(Number) as [
- number,
- number,
- ]),
- ),
- "h:mm a",
- )}
-
-
-
-
- handleScheduleChange(frequency, v)
- }
- >
- {HOURS.map((h) => {
- const val = `${String(h).padStart(2, "0")}:00`;
- return (
-
- {format(new Date(2000, 0, 1, h, 0), "h:mm a")}
-
- );
- })}
-
-
-
-
- )}
-
-
-
- )}
-
-
-
- {/* Restore */}
-
-
-
-
-
-
-
- Restore
-
- Upload a .db file to replace the current database. A
- "just-in-case" backup is created first.
-
-
-
-
{
- const file = e.target.files?.[0];
- if (file) {
- setSelectedFile(file);
- setRestoreDialogOpen(true);
- }
- }}
- />
-
{
- if (selectedFile) handleRestore(selectedFile);
- setRestoreDialogOpen(false);
- setSelectedFile(null);
- }}
- onCancel={() => {
- setRestoreDialogOpen(false);
- setSelectedFile(null);
- if (fileInputRef.current) fileInputRef.current.value = "";
- }}
- />
-
-
-
>
);
}
-
-function RestoreDialog({
- open,
- onOpenChange,
- onConfirm,
- onCancel,
-}: {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- onConfirm: () => void;
- onCancel: () => void;
-}) {
- return (
-
-
-
- Restore database?
-
- This will replace your entire database with the uploaded file. A
- "just-in-case" backup of your current data will be created first.
- Active sessions may need to refresh after restore.
-
-
-
- Cancel
- Restore
-
-
-
- );
-}
diff --git a/app/(pages)/settings/_components/system-health-section.tsx b/app/(pages)/settings/_components/system-health-section.tsx
new file mode 100644
index 0000000..30837ad
--- /dev/null
+++ b/app/(pages)/settings/_components/system-health-section.tsx
@@ -0,0 +1,384 @@
+"use client";
+
+import {
+ IconActivity,
+ IconClock,
+ IconDatabase,
+ IconRefresh,
+} from "@tabler/icons-react";
+import { formatDistanceToNow } from "date-fns";
+import { useCallback, useEffect, useState } from "react";
+import { toast } from "sonner";
+import { Button } from "@/components/ui/button";
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardTitle,
+} from "@/components/ui/card";
+import { Skeleton } from "@/components/ui/skeleton";
+import { Spinner } from "@/components/ui/spinner";
+import type { SystemHealthData } from "@/lib/services/system-health";
+
+const JOB_LABELS: Record
= {
+ nightlyRefreshLibrary: "Library refresh",
+ refreshAvailability: "Availability",
+ refreshRecommendations: "Recommendations",
+ refreshTvChildren: "TV episodes",
+ cacheImages: "Image cache",
+ scheduledBackup: "Backup",
+};
+
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
+ if (bytes < 1024 * 1024 * 1024)
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
+}
+
+function formatDuration(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
+ return `${Math.round(ms / 60000)}m`;
+}
+
+/** Small colored status dot */
+function StatusDot({
+ status,
+}: {
+ status: "ok" | "error" | "warn" | "inactive";
+}) {
+ const color = {
+ ok: "bg-green-500",
+ error: "bg-destructive",
+ warn: "bg-amber-500",
+ inactive: "bg-muted-foreground/30",
+ }[status];
+ return (
+
+ );
+}
+
+function SkeletonCards() {
+ return (
+
+ {["status", "jobs", "storage"].map((s) => (
+
+
+
+
+
+ ))}
+
+ );
+}
+
+/** Renders 3 separate cards: System status, Background jobs, Storage */
+export function SystemHealthCards() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [refreshing, setRefreshing] = useState(false);
+
+ const fetchHealth = useCallback(async (isRefresh = false) => {
+ if (isRefresh) setRefreshing(true);
+ try {
+ const res = await fetch("/api/admin/system-health");
+ if (!res.ok) throw new Error("Failed to fetch");
+ const health: SystemHealthData = await res.json();
+ setData(health);
+ } catch {
+ if (isRefresh) toast.error("Failed to refresh system health");
+ } finally {
+ setLoading(false);
+ setRefreshing(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchHealth();
+ }, [fetchHealth]);
+
+ if (loading) return ;
+ if (!data) return null;
+
+ return (
+
+ {/* ── Card 1: System Status ── */}
+
+
+
+
+
+
+
+
+ System status
+
+ Checked{" "}
+ {formatDistanceToNow(new Date(data.checkedAt), {
+ addSuffix: true,
+ })}
+
+
+
+
+
+
+
+ {/* Database */}
+
+
+
+ Database
+
+
+ {formatBytes(data.database.dbSizeBytes)}
+ {data.database.walSizeBytes > 0 &&
+ ` + ${formatBytes(data.database.walSizeBytes)} WAL`}
+
+
+
+ {data.database.titleCount.toLocaleString()} titles
+ {" · "}
+ {data.database.episodeCount.toLocaleString()} episodes
+ {" · "}
+ {data.database.userCount.toLocaleString()} users
+
+
+
+ {/* TMDB */}
+
+
+
+ TMDB API
+
+ {!data.tmdb.tokenConfigured ? (
+ <>
+
+
+ Not configured
+
+ >
+ ) : data.tmdb.connected && data.tmdb.tokenValid ? (
+ <>
+
+ Connected
+
+ {data.tmdb.responseTimeMs}ms
+
+ >
+ ) : data.tmdb.connected && !data.tmdb.tokenValid ? (
+ <>
+
+ Invalid token
+ >
+ ) : (
+ <>
+
+ Unreachable
+ {data.tmdb.error && (
+
+ {data.tmdb.error}
+
+ )}
+ >
+ )}
+
+
+
+ {/* Environment */}
+
+
+
+ Environment
+
+
+ {data.environment.envVars
+ .filter((env) => env.value !== null)
+ .map((env) => (
+
+
+ {env.name}=
+
+
+ {env.value}
+
+
+ ))}
+
+
+
+
+
+ {/* ── Card 2: Background Jobs ── */}
+
+
+
+
+
+
+
+ Background jobs
+
+ {data.jobs.filter((j) => j.lastStatus === "success").length} of{" "}
+ {data.jobs.length} jobs healthy
+
+
+
+
+
+
+ {data.jobs.map((job) => (
+
+ {job.isCurrentlyRunning ? (
+
+ ) : job.lastStatus === null ? (
+
+ ) : job.lastStatus === "success" ? (
+
+ ) : (
+
+ )}
+
+
+ {JOB_LABELS[job.jobName] ?? job.jobName}
+
+
+ {job.lastRunAt ? (
+
+ {formatDistanceToNow(new Date(job.lastRunAt), {
+ addSuffix: true,
+ })}
+
+ ) : (
+
+ —
+
+ )}
+
+ {job.lastDurationMs !== null ? (
+
+ {formatDuration(job.lastDurationMs)}
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+
+
+ {/* ── Card 3: Storage ── */}
+
+
+
+
+
+
+
+ Storage
+
+ Image cache and backup disk usage
+
+
+
+
+
+ {/* Image cache */}
+
+
+
+ Image cache
+
+ {data.imageCache.enabled ? (
+
+ {formatBytes(data.imageCache.totalSizeBytes)}
+
+ ) : null}
+
+ {data.imageCache.enabled ? (
+ <>
+
+ {data.imageCache.imageCount.toLocaleString()} cached images
+
+
+ {Object.entries(data.imageCache.categories)
+ .map(([name, cat]) => `${name} ${cat.count}`)
+ .join(" · ")}
+
+ >
+ ) : (
+
+
+ Disabled
+
+ )}
+
+
+ {/* Backup summary */}
+
+
+
+ Backups
+
+ {data.backups.backupCount > 0 && (
+
+ {formatBytes(data.backups.totalSizeBytes)}
+
+ )}
+
+ {data.backups.backupCount > 0 ? (
+
+ {data.backups.backupCount} backups · last{" "}
+ {data.backups.lastBackupAt
+ ? formatDistanceToNow(new Date(data.backups.lastBackupAt), {
+ addSuffix: true,
+ })
+ : "unknown"}
+
+ ) : (
+
+
+ No backups yet
+
+ )}
+
+
+
+ );
+}
diff --git a/app/(pages)/settings/page.tsx b/app/(pages)/settings/page.tsx
index 2849cdc..09d59db 100644
--- a/app/(pages)/settings/page.tsx
+++ b/app/(pages)/settings/page.tsx
@@ -1,4 +1,8 @@
-import { IconServerCog } from "@tabler/icons-react";
+import {
+ IconDatabaseExport,
+ IconServerCog,
+ IconShieldLock,
+} from "@tabler/icons-react";
import { desc, eq } from "drizzle-orm";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
@@ -10,10 +14,13 @@ import { listBackups } from "@/lib/services/backup";
import { getSetting } from "@/lib/services/settings";
import { APP_VERSION, GIT_COMMIT } from "@/lib/version";
import { AccountSection } from "./_components/account-section";
+import { BackupRestoreSection } from "./_components/backup-restore-section";
+import { BackupScheduleSection } from "./_components/backup-schedule-section";
import { BackupSection } from "./_components/backup-section";
import { IntegrationsSection } from "./_components/integrations-section";
import { ServerSection } from "./_components/server-section";
import { SettingsShell } from "./_components/settings-shell";
+import { SystemHealthCards } from "./_components/system-health-section";
export default async function SettingsPage() {
const session = await auth.api.getSession({ headers: await headers() });
@@ -119,32 +126,71 @@ export default async function SettingsPage() {
/>
{isAdmin && (
-
-
-
-
- Server
-
-
- Admin only
-
+ <>
+ {/* Server health */}
+
+
+
+
+ Server
+
+
+ Admin only
+
+
+
-
-
-
-
-
-
-
+
+ {/* Security */}
+
+
+
+
+ Security
+
+
+ Admin only
+
+
+
+
+
+
+
-
+
+ {/* Backups */}
+
+
+
+
+ Backups
+
+
+ Admin only
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
)}
);
diff --git a/app/api/admin/system-health/route.ts b/app/api/admin/system-health/route.ts
new file mode 100644
index 0000000..24153e4
--- /dev/null
+++ b/app/api/admin/system-health/route.ts
@@ -0,0 +1,15 @@
+import { headers } from "next/headers";
+import { NextResponse } from "next/server";
+import { auth } from "@/lib/auth/server";
+import { getSystemHealth } from "@/lib/services/system-health";
+
+export async function GET() {
+ const session = await auth.api.getSession({ headers: await headers() });
+ if (!session)
+ return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
+ if (session.user.role !== "admin")
+ return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+
+ const health = await getSystemHealth();
+ return NextResponse.json(health);
+}
diff --git a/lib/services/system-health.ts b/lib/services/system-health.ts
new file mode 100644
index 0000000..ebeb893
--- /dev/null
+++ b/lib/services/system-health.ts
@@ -0,0 +1,307 @@
+import { access, constants, readdir, stat } from "node:fs/promises";
+import path from "node:path";
+import { count, desc, eq } from "drizzle-orm";
+import { db } from "@/lib/db/client";
+import { cronRuns, episodes, titles, user } from "@/lib/db/schema";
+import { listBackups } from "@/lib/services/backup";
+import { imageCacheEnabled } from "@/lib/services/image-cache";
+
+const DATA_DIR = process.env.DATA_DIR || "./data";
+const DATABASE_URL =
+ process.env.DATABASE_URL || path.join(DATA_DIR, "sqlite.db");
+const CACHE_DIR = process.env.CACHE_DIR
+ ? path.join(process.env.CACHE_DIR, "images")
+ : path.join(DATA_DIR, "images");
+const TMDB_API_BASE_URL =
+ process.env.TMDB_API_BASE_URL || "https://api.themoviedb.org/3";
+
+export interface SystemHealthData {
+ database: {
+ dbSizeBytes: number;
+ walSizeBytes: number;
+ titleCount: number;
+ episodeCount: number;
+ userCount: number;
+ };
+ tmdb: {
+ connected: boolean;
+ tokenValid: boolean;
+ tokenConfigured: boolean;
+ responseTimeMs: number | null;
+ error: string | null;
+ };
+ jobs: {
+ jobName: string;
+ lastRunAt: string | null;
+ lastDurationMs: number | null;
+ lastStatus: "running" | "success" | "error" | null;
+ lastError: string | null;
+ isCurrentlyRunning: boolean;
+ }[];
+ imageCache: {
+ enabled: boolean;
+ totalSizeBytes: number;
+ imageCount: number;
+ categories: Record
;
+ };
+ backups: {
+ lastBackupAt: string | null;
+ lastBackupAgeHours: number | null;
+ backupCount: number;
+ totalSizeBytes: number;
+ };
+ environment: {
+ dataDir: string;
+ dataDirWritable: boolean;
+ envVars: { name: string; value: string | null }[];
+ };
+ checkedAt: string;
+}
+
+const JOB_NAMES = [
+ "nightlyRefreshLibrary",
+ "refreshAvailability",
+ "refreshRecommendations",
+ "refreshTvChildren",
+ "cacheImages",
+ "scheduledBackup",
+];
+
+function getDatabaseHealth(): SystemHealthData["database"] {
+ let dbSizeBytes = 0;
+ let walSizeBytes = 0;
+ try {
+ dbSizeBytes = Bun.file(DATABASE_URL).size;
+ } catch {}
+ try {
+ walSizeBytes = Bun.file(`${DATABASE_URL}-wal`).size;
+ } catch {}
+
+ const [titleCount] = db.select({ count: count() }).from(titles).all();
+ const [episodeCount] = db.select({ count: count() }).from(episodes).all();
+ const [userCount] = db.select({ count: count() }).from(user).all();
+
+ return {
+ dbSizeBytes,
+ walSizeBytes,
+ titleCount: titleCount.count,
+ episodeCount: episodeCount.count,
+ userCount: userCount.count,
+ };
+}
+
+async function getTmdbHealth(): Promise {
+ const token = process.env.TMDB_API_READ_ACCESS_TOKEN;
+ if (!token) {
+ return {
+ connected: false,
+ tokenValid: false,
+ tokenConfigured: false,
+ responseTimeMs: null,
+ error: null,
+ };
+ }
+
+ try {
+ const start = performance.now();
+ const res = await fetch(`${TMDB_API_BASE_URL}/configuration`, {
+ headers: {
+ Authorization: `Bearer ${token}`,
+ Accept: "application/json",
+ },
+ signal: AbortSignal.timeout(5000),
+ });
+ const responseTimeMs = Math.round(performance.now() - start);
+
+ if (res.ok) {
+ return {
+ connected: true,
+ tokenValid: true,
+ tokenConfigured: true,
+ responseTimeMs,
+ error: null,
+ };
+ }
+
+ return {
+ connected: true,
+ tokenValid: false,
+ tokenConfigured: true,
+ responseTimeMs,
+ error: `HTTP ${res.status}`,
+ };
+ } catch (err) {
+ return {
+ connected: false,
+ tokenValid: false,
+ tokenConfigured: true,
+ responseTimeMs: null,
+ error: err instanceof Error ? err.message : String(err),
+ };
+ }
+}
+
+function getJobsHealth(): SystemHealthData["jobs"] {
+ return JOB_NAMES.map((jobName) => {
+ const latest = db
+ .select()
+ .from(cronRuns)
+ .where(eq(cronRuns.jobName, jobName))
+ .orderBy(desc(cronRuns.startedAt))
+ .limit(1)
+ .get();
+
+ const isCurrentlyRunning = latest?.status === "running";
+
+ let lastDurationMs: number | null = null;
+ if (latest?.finishedAt && latest.startedAt) {
+ lastDurationMs = latest.finishedAt.getTime() - latest.startedAt.getTime();
+ }
+
+ return {
+ jobName,
+ lastRunAt: latest?.startedAt?.toISOString() ?? null,
+ lastDurationMs,
+ lastStatus: (latest?.status as "running" | "success" | "error") ?? null,
+ lastError: latest?.errorMessage ?? null,
+ isCurrentlyRunning,
+ };
+ });
+}
+
+async function getImageCacheHealth(): Promise {
+ const enabled = imageCacheEnabled();
+ if (!enabled) {
+ return { enabled: false, totalSizeBytes: 0, imageCount: 0, categories: {} };
+ }
+
+ const categoryNames = ["posters", "backdrops", "stills", "logos"];
+ const categories: Record = {};
+ let totalSizeBytes = 0;
+ let imageCount = 0;
+
+ for (const category of categoryNames) {
+ const dir = path.join(CACHE_DIR, category);
+ try {
+ const files = await readdir(dir);
+ let sizeBytes = 0;
+ for (const file of files) {
+ try {
+ const s = await stat(path.join(dir, file));
+ if (s.isFile()) sizeBytes += s.size;
+ } catch {}
+ }
+ categories[category] = { count: files.length, sizeBytes };
+ totalSizeBytes += sizeBytes;
+ imageCount += files.length;
+ } catch {
+ categories[category] = { count: 0, sizeBytes: 0 };
+ }
+ }
+
+ return { enabled: true, totalSizeBytes, imageCount, categories };
+}
+
+async function getBackupsHealth(): Promise {
+ try {
+ const backups = await listBackups();
+ const totalSizeBytes = backups.reduce((sum, b) => sum + b.sizeBytes, 0);
+ const lastBackup = backups[0] ?? null;
+ const lastBackupAt = lastBackup?.createdAt ?? null;
+ const lastBackupAgeHours = lastBackupAt
+ ? Math.round(
+ (Date.now() - new Date(lastBackupAt).getTime()) / (1000 * 60 * 60),
+ )
+ : null;
+
+ return {
+ lastBackupAt,
+ lastBackupAgeHours,
+ backupCount: backups.length,
+ totalSizeBytes,
+ };
+ } catch {
+ return {
+ lastBackupAt: null,
+ lastBackupAgeHours: null,
+ backupCount: 0,
+ totalSizeBytes: 0,
+ };
+ }
+}
+
+async function getEnvironmentHealth(): Promise<
+ SystemHealthData["environment"]
+> {
+ const resolvedDataDir = path.resolve(DATA_DIR);
+ let dataDirWritable = false;
+ try {
+ await access(resolvedDataDir, constants.W_OK);
+ dataDirWritable = true;
+ } catch {}
+
+ const redact = (val: string | undefined): string | null => {
+ if (!val) return null;
+ if (val.length <= 8) return "••••••••";
+ return `${val.slice(0, 4)}${"•".repeat(Math.min(val.length - 4, 24))}`;
+ };
+
+ const env = (name: string) => process.env[name] ?? null;
+
+ return {
+ dataDir: resolvedDataDir,
+ dataDirWritable,
+ envVars: [
+ // Core
+ { name: "DATA_DIR", value: resolvedDataDir },
+ { name: "LOG_LEVEL", value: env("LOG_LEVEL") ?? "info" },
+ // Auth
+ { name: "BETTER_AUTH_URL", value: env("BETTER_AUTH_URL") },
+ {
+ name: "BETTER_AUTH_SECRET",
+ value: redact(env("BETTER_AUTH_SECRET") ?? undefined),
+ },
+ // TMDB
+ {
+ name: "TMDB_API_READ_ACCESS_TOKEN",
+ value: redact(env("TMDB_API_READ_ACCESS_TOKEN") ?? undefined),
+ },
+ { name: "TMDB_API_BASE_URL", value: env("TMDB_API_BASE_URL") },
+ { name: "TMDB_IMAGE_BASE_URL", value: env("TMDB_IMAGE_BASE_URL") },
+ // Image cache
+ {
+ name: "IMAGE_CACHE_ENABLED",
+ value: env("IMAGE_CACHE_ENABLED") ?? "true",
+ },
+ // OIDC
+ { name: "OIDC_ISSUER_URL", value: env("OIDC_ISSUER_URL") },
+ { name: "OIDC_CLIENT_ID", value: env("OIDC_CLIENT_ID") },
+ {
+ name: "OIDC_CLIENT_SECRET",
+ value: redact(env("OIDC_CLIENT_SECRET") ?? undefined),
+ },
+ { name: "OIDC_PROVIDER_NAME", value: env("OIDC_PROVIDER_NAME") },
+ { name: "OIDC_AUTO_REGISTER", value: env("OIDC_AUTO_REGISTER") },
+ { name: "DISABLE_PASSWORD_LOGIN", value: env("DISABLE_PASSWORD_LOGIN") },
+ ],
+ };
+}
+
+export async function getSystemHealth(): Promise {
+ const [tmdb, imageCache, backups, environment] = await Promise.all([
+ getTmdbHealth(),
+ getImageCacheHealth(),
+ getBackupsHealth(),
+ getEnvironmentHealth(),
+ ]);
+
+ return {
+ database: getDatabaseHealth(),
+ tmdb,
+ jobs: getJobsHealth(),
+ imageCache,
+ backups,
+ environment,
+ checkedAt: new Date().toISOString(),
+ };
+}