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(), + }; +}