From 7a64ced5e04d847bceee4ac49524c253b5b56e4d Mon Sep 17 00:00:00 2001 From: Jake Jarvis Date: Wed, 4 Mar 2026 18:30:22 -0500 Subject: [PATCH] Add system health dashboard and split settings into separate cards Add admin-only system health API and UI showing database stats, TMDB connection status, background job history, storage usage, and environment variables (with redacted secrets). Split monolithic backup card into three focused cards (backups, schedule, restore) with dedicated section headers for Server, Security, and Backups. Co-Authored-By: Claude Opus 4.6 --- .../settings/_components/account-section.tsx | 2 +- .../_components/backup-restore-section.tsx | 124 +++++ .../_components/backup-schedule-section.tsx | 369 ++++++++++++++ .../settings/_components/backup-section.tsx | 478 +----------------- .../_components/system-health-section.tsx | 384 ++++++++++++++ app/(pages)/settings/page.tsx | 96 +++- app/api/admin/system-health/route.ts | 15 + lib/services/system-health.ts | 307 +++++++++++ 8 files changed, 1274 insertions(+), 501 deletions(-) create mode 100644 app/(pages)/settings/_components/backup-restore-section.tsx create mode 100644 app/(pages)/settings/_components/backup-schedule-section.tsx create mode 100644 app/(pages)/settings/_components/system-health-section.tsx create mode 100644 app/api/admin/system-health/route.ts create mode 100644 lib/services/system-health.ts diff --git a/app/(pages)/settings/_components/account-section.tsx b/app/(pages)/settings/_components/account-section.tsx index f9dce0a..794888e 100644 --- a/app/(pages)/settings/_components/account-section.tsx +++ b/app/(pages)/settings/_components/account-section.tsx @@ -52,7 +52,7 @@ export function AccountSection({

+ + + ); +} diff --git a/app/(pages)/settings/_components/backup-schedule-section.tsx b/app/(pages)/settings/_components/backup-schedule-section.tsx new file mode 100644 index 0000000..80d48a4 --- /dev/null +++ b/app/(pages)/settings/_components/backup-schedule-section.tsx @@ -0,0 +1,369 @@ +"use client"; + +import { IconCalendarRepeat, IconChevronDown } from "@tabler/icons-react"; +import { format, formatDistanceToNow } from "date-fns"; +import { AnimatePresence, motion } from "motion/react"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Switch } from "@/components/ui/switch"; +import type { BackupFrequency } from "@/lib/cron"; +import { + setBackupScheduleAction, + setMaxBackupsAction, + setScheduledBackupAction, +} from "./actions"; + +const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [ + { value: "6h", label: "6h" }, + { value: "12h", label: "12h" }, + { value: "1d", label: "1d" }, + { value: "7d", label: "7d" }, +]; + +const HOURS = Array.from({ length: 24 }, (_, i) => i); + +const DAYS_OF_WEEK = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +] as const; + +function getNextBackupDate( + frequency: BackupFrequency, + time: string, + dayOfWeek: number, +): Date { + const now = new Date(); + const [h, m] = time.split(":").map(Number); + + if (frequency === "6h") { + const next = new Date(now); + const currentHour = next.getHours(); + const nextHour = Math.ceil((currentHour + 1) / 6) * 6; + next.setHours(nextHour, m, 0, 0); + if (next <= now) next.setHours(next.getHours() + 6); + return next; + } + + if (frequency === "12h") { + const next = new Date(now); + const h2 = (h + 12) % 24; + const candidates = [h, h2].sort((a, b) => a - b); + for (const candidate of candidates) { + next.setHours(candidate, m, 0, 0); + if (next > now) return next; + } + next.setDate(next.getDate() + 1); + next.setHours(candidates[0], m, 0, 0); + return next; + } + + if (frequency === "7d") { + const next = new Date(now); + const daysUntil = (dayOfWeek - next.getDay() + 7) % 7; + if (daysUntil === 0) { + next.setHours(h, m, 0, 0); + if (next > now) return next; + next.setDate(next.getDate() + 7); + next.setHours(h, m, 0, 0); + return next; + } + next.setDate(next.getDate() + daysUntil); + next.setHours(h, m, 0, 0); + return next; + } + + // 1d + const next = new Date(now); + next.setHours(h, m, 0, 0); + if (next <= now) next.setDate(next.getDate() + 1); + return next; +} + +function formatNextBackup( + frequency: BackupFrequency, + time: string, + dayOfWeek: number, +): string { + const next = getNextBackupDate(frequency, time, dayOfWeek); + return `Next backup ${formatDistanceToNow(next, { addSuffix: true })}`; +} + +export function BackupScheduleSection({ + initialScheduledEnabled, + initialMaxRetention, + initialFrequency, + initialTime, + initialDow, +}: { + initialScheduledEnabled: boolean; + initialMaxRetention: number; + initialFrequency: BackupFrequency; + initialTime: string; + initialDow: number; +}) { + const [scheduledEnabled, setScheduledEnabled] = useState( + initialScheduledEnabled, + ); + const [maxRetention, setMaxRetention] = useState(initialMaxRetention); + const [frequency, setFrequency] = useState(initialFrequency); + const [time, setTime] = useState(initialTime); + const [dow, setDow] = useState(initialDow); + const [savingSchedule, setSavingSchedule] = useState(false); + const [togglingSchedule, setTogglingSchedule] = useState(false); + + async function handleToggleScheduled(checked: boolean) { + const previous = scheduledEnabled; + setScheduledEnabled(checked); + setTogglingSchedule(true); + try { + await setScheduledBackupAction(checked); + toast.success( + checked ? "Scheduled backups enabled" : "Scheduled backups disabled", + ); + } catch { + setScheduledEnabled(previous); + toast.error("Failed to update scheduled backup setting"); + } finally { + setTogglingSchedule(false); + } + } + + async function handleMaxRetentionChange(value: number) { + const previous = maxRetention; + setMaxRetention(value); + try { + await setMaxBackupsAction(value); + } catch { + setMaxRetention(previous); + toast.error("Failed to update retention setting"); + } + } + + async function handleScheduleChange( + newFrequency: BackupFrequency, + newTime: string, + newDow = dow, + ) { + const prevFreq = frequency; + const prevTime = time; + const prevDow = dow; + setFrequency(newFrequency); + setTime(newTime); + setDow(newDow); + setSavingSchedule(true); + try { + await setBackupScheduleAction(newFrequency, newTime, newDow); + toast.success("Schedule updated"); + } catch { + setFrequency(prevFreq); + setTime(prevTime); + setDow(prevDow); + toast.error("Failed to update schedule"); + } finally { + setSavingSchedule(false); + } + } + + return ( + <> + +
+
+
+ +
+
+ Backup schedule + + {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" + )} + +
+
+ +
+
+ + + {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")} + + ); + })} + + + + + )} + +
+
+
+ )} +
+ + ); +} diff --git a/app/(pages)/settings/_components/backup-section.tsx b/app/(pages)/settings/_components/backup-section.tsx index cd769fb..d3f3e42 100644 --- a/app/(pages)/settings/_components/backup-section.tsx +++ b/app/(pages)/settings/_components/backup-section.tsx @@ -1,11 +1,8 @@ "use client"; import { - IconCalendarRepeat, - IconChevronDown, IconClock, IconCloudDownload, - IconCloudUpload, IconDatabaseExport, IconPlus, IconPointer, @@ -14,7 +11,7 @@ import { } from "@tabler/icons-react"; import { format, formatDistanceToNow } from "date-fns"; import { AnimatePresence, motion } from "motion/react"; -import { useRef, useState } from "react"; +import { useState } from "react"; import { toast } from "sonner"; import { AlertDialog, @@ -29,29 +26,14 @@ import { } from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { CardContent, CardDescription, CardTitle } from "@/components/ui/card"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; import { Spinner } from "@/components/ui/spinner"; -import { Switch } from "@/components/ui/switch"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { BackupFrequency } from "@/lib/cron"; import type { BackupInfo } from "@/lib/services/backup"; -import { - createBackupAction, - deleteBackupAction, - setBackupScheduleAction, - setMaxBackupsAction, - setScheduledBackupAction, -} from "./actions"; +import { createBackupAction, deleteBackupAction } from "./actions"; function formatBytes(bytes: number): string { if (bytes < 1024) return `${bytes} B`; @@ -63,117 +45,14 @@ function formatBackupDate(dateStr: string): string { return format(new Date(dateStr), "MMM d, h:mm a"); } -const FREQUENCY_OPTIONS: { value: BackupFrequency; label: string }[] = [ - { value: "6h", label: "6h" }, - { value: "12h", label: "12h" }, - { value: "1d", label: "1d" }, - { value: "7d", label: "7d" }, -]; - -const HOURS = Array.from({ length: 24 }, (_, i) => i); - -const DAYS_OF_WEEK = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", -] as const; - -function getNextBackupDate( - frequency: BackupFrequency, - time: string, - dayOfWeek: number, -): Date { - const now = new Date(); - const [h, m] = time.split(":").map(Number); - - if (frequency === "6h") { - const next = new Date(now); - const currentHour = next.getHours(); - const nextHour = Math.ceil((currentHour + 1) / 6) * 6; - next.setHours(nextHour, m, 0, 0); - if (next <= now) next.setHours(next.getHours() + 6); - return next; - } - - if (frequency === "12h") { - const next = new Date(now); - const h2 = (h + 12) % 24; - const candidates = [h, h2].sort((a, b) => a - b); - for (const candidate of candidates) { - next.setHours(candidate, m, 0, 0); - if (next > now) return next; - } - next.setDate(next.getDate() + 1); - next.setHours(candidates[0], m, 0, 0); - return next; - } - - if (frequency === "7d") { - const next = new Date(now); - const daysUntil = (dayOfWeek - next.getDay() + 7) % 7; - if (daysUntil === 0) { - next.setHours(h, m, 0, 0); - if (next > now) return next; - next.setDate(next.getDate() + 7); - next.setHours(h, m, 0, 0); - return next; - } - next.setDate(next.getDate() + daysUntil); - next.setHours(h, m, 0, 0); - return next; - } - - // 1d - const next = new Date(now); - next.setHours(h, m, 0, 0); - if (next <= now) next.setDate(next.getDate() + 1); - return next; -} - -function formatNextBackup( - frequency: BackupFrequency, - time: string, - dayOfWeek: number, -): string { - const next = getNextBackupDate(frequency, time, dayOfWeek); - return `Next backup ${formatDistanceToNow(next, { addSuffix: true })}`; -} - export function BackupSection({ initialBackups, - initialScheduledEnabled, - initialMaxRetention, - initialFrequency, - initialTime, - initialDow, }: { initialBackups: BackupInfo[]; - initialScheduledEnabled: boolean; - initialMaxRetention: number; - initialFrequency: BackupFrequency; - initialTime: string; - initialDow: number; }) { const [backups, setBackups] = useState(initialBackups); const [creating, setCreating] = useState(false); const [deleting, setDeleting] = useState(null); - const [restoring, setRestoring] = useState(false); - const [scheduledEnabled, setScheduledEnabled] = useState( - initialScheduledEnabled, - ); - const [maxRetention, setMaxRetention] = useState(initialMaxRetention); - const [frequency, setFrequency] = useState(initialFrequency); - const [time, setTime] = useState(initialTime); - const [dow, setDow] = useState(initialDow); - const [savingSchedule, setSavingSchedule] = useState(false); - const [togglingSchedule, setTogglingSchedule] = useState(false); - const [restoreDialogOpen, setRestoreDialogOpen] = useState(false); - const [selectedFile, setSelectedFile] = useState(null); - const fileInputRef = useRef(null); async function handleCreateBackup() { setCreating(true); @@ -213,91 +92,9 @@ export function BackupSection({ } } - async function handleRestore(file: File) { - setRestoring(true); - try { - const formData = new FormData(); - formData.append("file", file); - const res = await fetch("/api/backup/restore", { - method: "POST", - body: formData, - }); - if (!res.ok) { - const data = await res.json(); - throw new Error(data.error || "Restore failed"); - } - toast.success("Database restored. Reloading..."); - setTimeout(() => window.location.reload(), 1500); - } catch (err) { - const message = err instanceof Error ? err.message : "Restore failed"; - toast.error(message); - } finally { - setRestoring(false); - if (fileInputRef.current) fileInputRef.current.value = ""; - } - } - - async function handleToggleScheduled(checked: boolean) { - const previous = scheduledEnabled; - setScheduledEnabled(checked); - setTogglingSchedule(true); - try { - await setScheduledBackupAction(checked); - toast.success( - checked ? "Scheduled backups enabled" : "Scheduled backups disabled", - ); - } catch { - setScheduledEnabled(previous); - toast.error("Failed to update scheduled backup setting"); - } finally { - setTogglingSchedule(false); - } - } - - async function handleMaxRetentionChange(value: number) { - const previous = maxRetention; - setMaxRetention(value); - try { - await setMaxBackupsAction(value); - toast.success( - value === 0 - ? "Keeping unlimited backups" - : `Keeping last ${value} backups`, - ); - } catch { - setMaxRetention(previous); - toast.error("Failed to update retention setting"); - } - } - - async function handleScheduleChange( - newFrequency: BackupFrequency, - newTime: string, - newDow = dow, - ) { - const prevFreq = frequency; - const prevTime = time; - const prevDow = dow; - setFrequency(newFrequency); - setTime(newTime); - setDow(newDow); - setSavingSchedule(true); - try { - await setBackupScheduleAction(newFrequency, newTime, newDow); - toast.success("Schedule updated"); - } catch { - setFrequency(prevFreq); - setTime(prevTime); - setDow(prevDow); - toast.error("Failed to update schedule"); - } finally { - setSavingSchedule(false); - } - } - return ( <> - {/* Create backup header */} + {/* Header */}
@@ -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(), + }; +}