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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 18:30:22 -05:00
co-authored by Claude Opus 4.6
parent 0a0e7dfc47
commit 7a64ced5e0
8 changed files with 1274 additions and 501 deletions
@@ -52,7 +52,7 @@ export function AccountSection({
</p>
</div>
<Button
variant="ghost"
variant="destructive"
onClick={async () => {
await signOut();
router.push("/");
@@ -0,0 +1,124 @@
"use client";
import { IconCloudUpload } from "@tabler/icons-react";
import { useRef, useState } from "react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { CardContent, CardDescription, CardTitle } from "@/components/ui/card";
import { Spinner } from "@/components/ui/spinner";
export function BackupRestoreSection() {
const [restoring, setRestoring] = useState(false);
const [restoreDialogOpen, setRestoreDialogOpen] = useState(false);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
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 = "";
}
}
return (
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCloudUpload className="size-4 text-primary" />
</div>
<div>
<CardTitle>Restore</CardTitle>
<CardDescription>
Upload a .db file to replace the current database. A safety backup
is created first.
</CardDescription>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".db"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setRestoreDialogOpen(true);
}
}}
/>
<AlertDialog
open={restoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore database?</AlertDialogTitle>
<AlertDialogDescription>
This will replace your entire database with the uploaded file. A
safety backup of your current data will be created first. Active
sessions may need to refresh after restore.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel
onClick={() => {
setRestoreDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
>
Cancel
</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
if (selectedFile) handleRestore(selectedFile);
setRestoreDialogOpen(false);
setSelectedFile(null);
}}
>
Restore
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={restoring}
>
{restoring ? <Spinner /> : <IconCloudUpload />}
{restoring ? "Restoring..." : "Upload"}
</Button>
</div>
</CardContent>
);
}
@@ -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<BackupFrequency>(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 (
<>
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconCalendarRepeat className="size-4 text-primary" />
</div>
<div>
<CardTitle>Backup schedule</CardTitle>
<CardDescription>
{scheduledEnabled ? (
<span className="inline-flex flex-wrap items-baseline gap-1">
<span suppressHydrationWarning>
{formatNextBackup(frequency, time, dow)}.
</span>{" "}
Keeping{" "}
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-0.5 border-b border-dotted border-muted-foreground/50 transition-colors hover:text-foreground">
{maxRetention === 0
? "unlimited"
: `last ${maxRetention}`}
<IconChevronDown className="size-2.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(maxRetention)}
onValueChange={(v) =>
handleMaxRetentionChange(Number(v))
}
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<DropdownMenuRadioItem key={n} value={String(n)}>
{n === 0 ? "unlimited" : n}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>{" "}
backups.
</span>
) : (
"Automatically back up your database on a schedule"
)}
</CardDescription>
</div>
</div>
<Switch
checked={scheduledEnabled}
onCheckedChange={handleToggleScheduled}
disabled={togglingSchedule}
/>
</div>
</CardContent>
<AnimatePresence initial={false}>
{scheduledEnabled && (
<CardContent className="border-t border-border/30 pt-4">
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="space-y-3">
{/* Frequency selector */}
<div className="space-y-1.5">
<span className="inline-block text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Frequency
</span>
<div className="flex gap-1">
{FREQUENCY_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
disabled={savingSchedule}
onClick={() => handleScheduleChange(opt.value, time)}
className={
frequency === opt.value
? "border-primary/50 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}
>
{opt.label}
</Button>
))}
</div>
</div>
{/* Day of week — shown for 7d only */}
<AnimatePresence initial={false}>
{frequency === "7d" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Day:{" "}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50">
{DAYS_OF_WEEK[dow]}
<IconChevronDown className="size-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(dow)}
onValueChange={(v) =>
handleScheduleChange(frequency, time, Number(v))
}
>
{DAYS_OF_WEEK.map((day, i) => (
<DropdownMenuRadioItem
key={day}
value={String(i)}
>
{day}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
)}
</AnimatePresence>
{/* Time selector — shown for 12h, 1d, 7d */}
<AnimatePresence initial={false}>
{frequency !== "6h" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{frequency === "12h" ? "Starting at" : "Time:"}{" "}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50">
{format(
new Date(
2000,
0,
1,
...(time.split(":").map(Number) as [
number,
number,
]),
),
"h:mm a",
)}
<IconChevronDown className="size-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={time}
onValueChange={(v) =>
handleScheduleChange(frequency, v)
}
>
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
return (
<DropdownMenuRadioItem key={h} value={val}>
{format(new Date(2000, 0, 1, h, 0), "h:mm a")}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</CardContent>
)}
</AnimatePresence>
</>
);
}
@@ -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<BackupInfo[]>(initialBackups);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [restoring, setRestoring] = useState(false);
const [scheduledEnabled, setScheduledEnabled] = useState(
initialScheduledEnabled,
);
const [maxRetention, setMaxRetention] = useState(initialMaxRetention);
const [frequency, setFrequency] = useState<BackupFrequency>(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<File | null>(null);
const fileInputRef = useRef<HTMLInputElement>(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 */}
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
@@ -452,275 +249,6 @@ export function BackupSection({
</CardContent>
)}
</AnimatePresence>
{/* Scheduled backups */}
<CardContent className="border-t border-border/30 pt-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/50">
<IconCalendarRepeat className="size-4 text-muted-foreground" />
</div>
<div>
<CardTitle>Scheduled</CardTitle>
<CardDescription>
{scheduledEnabled ? (
<span className="inline-flex flex-wrap items-baseline gap-1">
<span suppressHydrationWarning>
{formatNextBackup(frequency, time, dow)}.
</span>{" "}
Keeping{" "}
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-0.5 border-b border-dotted border-muted-foreground/50 transition-colors hover:text-foreground">
{maxRetention === 0
? "unlimited"
: `last ${maxRetention}`}
<IconChevronDown className="size-2.5" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(maxRetention)}
onValueChange={(v) =>
handleMaxRetentionChange(Number(v))
}
>
{[3, 5, 7, 14, 30, 0].map((n) => (
<DropdownMenuRadioItem key={n} value={String(n)}>
{n === 0 ? "unlimited" : n}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>{" "}
backups.
</span>
) : (
"Automatically back up your database on a schedule"
)}
</CardDescription>
</div>
</div>
<Switch
checked={scheduledEnabled}
onCheckedChange={handleToggleScheduled}
disabled={togglingSchedule}
/>
</div>
{/* Schedule configurator */}
<AnimatePresence initial={false}>
{scheduledEnabled && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 ml-11 space-y-3">
{/* Frequency selector */}
<div className="space-y-1.5">
<span className="inline-block text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Frequency
</span>
<div className="flex gap-1">
{FREQUENCY_OPTIONS.map((opt) => (
<Button
key={opt.value}
variant="outline"
size="sm"
disabled={savingSchedule}
onClick={() => handleScheduleChange(opt.value, time)}
className={
frequency === opt.value
? "border-primary/50 bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:text-primary-foreground"
: "border-border/50 bg-muted/30 text-muted-foreground hover:bg-muted/50 hover:text-foreground"
}
>
{opt.label}
</Button>
))}
</div>
</div>
{/* Day of week — shown for 7d only */}
<AnimatePresence initial={false}>
{frequency === "7d" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
Day:{" "}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50">
{DAYS_OF_WEEK[dow]}
<IconChevronDown className="size-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={String(dow)}
onValueChange={(v) =>
handleScheduleChange(frequency, time, Number(v))
}
>
{DAYS_OF_WEEK.map((day, i) => (
<DropdownMenuRadioItem
key={day}
value={String(i)}
>
{day}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
)}
</AnimatePresence>
{/* Time selector — shown for 12h, 1d, 7d */}
<AnimatePresence initial={false}>
{frequency !== "6h" && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.15 }}
className="overflow-hidden"
>
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/70">
{frequency === "12h" ? "Starting at" : "Time:"}{" "}
</span>
<DropdownMenu>
<DropdownMenuTrigger className="inline-flex cursor-pointer items-center gap-1 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1 text-xs text-foreground transition-colors hover:bg-muted/50 disabled:opacity-50">
{format(
new Date(
2000,
0,
1,
...(time.split(":").map(Number) as [
number,
number,
]),
),
"h:mm a",
)}
<IconChevronDown className="size-3 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuRadioGroup
value={time}
onValueChange={(v) =>
handleScheduleChange(frequency, v)
}
>
{HOURS.map((h) => {
const val = `${String(h).padStart(2, "0")}:00`;
return (
<DropdownMenuRadioItem key={h} value={val}>
{format(new Date(2000, 0, 1, h, 0), "h:mm a")}
</DropdownMenuRadioItem>
);
})}
</DropdownMenuRadioGroup>
</DropdownMenuContent>
</DropdownMenu>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
)}
</AnimatePresence>
</CardContent>
{/* Restore */}
<CardContent className="border-t border-border/30 pt-4">
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted/50">
<IconCloudUpload className="size-4 text-muted-foreground" />
</div>
<div>
<CardTitle>Restore</CardTitle>
<CardDescription>
Upload a .db file to replace the current database. A
"just-in-case" backup is created first.
</CardDescription>
</div>
</div>
<input
ref={fileInputRef}
type="file"
accept=".db"
className="hidden"
onChange={(e) => {
const file = e.target.files?.[0];
if (file) {
setSelectedFile(file);
setRestoreDialogOpen(true);
}
}}
/>
<RestoreDialog
open={restoreDialogOpen}
onOpenChange={setRestoreDialogOpen}
onConfirm={() => {
if (selectedFile) handleRestore(selectedFile);
setRestoreDialogOpen(false);
setSelectedFile(null);
}}
onCancel={() => {
setRestoreDialogOpen(false);
setSelectedFile(null);
if (fileInputRef.current) fileInputRef.current.value = "";
}}
/>
<Button
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={restoring}
>
{restoring ? <Spinner /> : <IconCloudUpload />}
{restoring ? "Restoring..." : "Upload"}
</Button>
</div>
</CardContent>
</>
);
}
function RestoreDialog({
open,
onOpenChange,
onConfirm,
onCancel,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
onCancel: () => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Restore database?</AlertDialogTitle>
<AlertDialogDescription>
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.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={onCancel}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={onConfirm}>Restore</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -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<string, string> = {
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 (
<span
className={`inline-block h-1.5 w-1.5 shrink-0 rounded-full ${color}`}
/>
);
}
function SkeletonCards() {
return (
<div className="space-y-3">
{["status", "jobs", "storage"].map((s) => (
<Card key={s} className="border-l-2 border-l-primary/30">
<CardContent>
<div className="flex items-start gap-3">
<Skeleton className="mt-0.5 h-8 w-8 rounded-lg" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-28" />
<Skeleton className="h-3 w-48" />
</div>
</div>
</CardContent>
</Card>
))}
</div>
);
}
/** Renders 3 separate cards: System status, Background jobs, Storage */
export function SystemHealthCards() {
const [data, setData] = useState<SystemHealthData | null>(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 <SkeletonCards />;
if (!data) return null;
return (
<div className="space-y-3">
{/* ── Card 1: System Status ── */}
<Card className="border-l-2 border-l-primary/30">
<CardContent>
<div className="flex items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconActivity className="size-4 text-primary" />
</div>
<div>
<CardTitle>System status</CardTitle>
<CardDescription suppressHydrationWarning>
Checked{" "}
{formatDistanceToNow(new Date(data.checkedAt), {
addSuffix: true,
})}
</CardDescription>
</div>
</div>
<Button
variant="outline"
onClick={() => fetchHealth(true)}
disabled={refreshing}
>
{refreshing ? (
<Spinner className="size-3" />
) : (
<IconRefresh className="size-3.5" />
)}
Refresh
</Button>
</div>
</CardContent>
{/* Database */}
<CardContent className="border-t border-border/30 pt-4">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/40">
Database
</span>
<span className="font-mono text-[11px] text-muted-foreground">
{formatBytes(data.database.dbSizeBytes)}
{data.database.walSizeBytes > 0 &&
` + ${formatBytes(data.database.walSizeBytes)} WAL`}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{data.database.titleCount.toLocaleString()} titles
{" · "}
{data.database.episodeCount.toLocaleString()} episodes
{" · "}
{data.database.userCount.toLocaleString()} users
</p>
</CardContent>
{/* TMDB */}
<CardContent className="border-t border-border/30 pt-4">
<div className="flex items-center gap-2">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/40">
TMDB API
</span>
{!data.tmdb.tokenConfigured ? (
<>
<StatusDot status="inactive" />
<span className="text-xs text-muted-foreground/50">
Not configured
</span>
</>
) : data.tmdb.connected && data.tmdb.tokenValid ? (
<>
<StatusDot status="ok" />
<span className="text-xs text-muted-foreground">Connected</span>
<span className="font-mono text-[11px] text-muted-foreground/40">
{data.tmdb.responseTimeMs}ms
</span>
</>
) : data.tmdb.connected && !data.tmdb.tokenValid ? (
<>
<StatusDot status="error" />
<span className="text-xs text-destructive">Invalid token</span>
</>
) : (
<>
<StatusDot status="error" />
<span className="text-xs text-destructive">Unreachable</span>
{data.tmdb.error && (
<span className="text-[11px] text-muted-foreground/50">
{data.tmdb.error}
</span>
)}
</>
)}
</div>
</CardContent>
{/* Environment */}
<CardContent className="border-t border-border/30 pt-4">
<div className="space-y-2">
<span className="inline-block text-[11px] font-medium uppercase tracking-wider text-muted-foreground/40">
Environment
</span>
<div className="space-y-1">
{data.environment.envVars
.filter((env) => env.value !== null)
.map((env) => (
<div
key={env.name}
className="flex items-baseline gap-1 font-mono text-[11px] leading-relaxed"
>
<span className="text-muted-foreground/50">
{env.name}=
</span>
<span className="text-muted-foreground break-all">
{env.value}
</span>
</div>
))}
</div>
</div>
</CardContent>
</Card>
{/* ── Card 2: Background Jobs ── */}
<Card className="border-l-2 border-l-primary/30">
<CardContent>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconClock className="size-4 text-primary" />
</div>
<div>
<CardTitle>Background jobs</CardTitle>
<CardDescription>
{data.jobs.filter((j) => j.lastStatus === "success").length} of{" "}
{data.jobs.length} jobs healthy
</CardDescription>
</div>
</div>
</CardContent>
<CardContent className="border-t border-border/30 pt-4">
<div className="space-y-1.5">
{data.jobs.map((job) => (
<div
key={job.jobName}
className={`grid grid-cols-[auto_1fr_auto_auto] items-center gap-x-2.5 ${
job.isCurrentlyRunning ? "animate-pulse" : ""
}`}
>
{job.isCurrentlyRunning ? (
<Spinner className="size-2.5" />
) : job.lastStatus === null ? (
<StatusDot status="inactive" />
) : job.lastStatus === "success" ? (
<StatusDot status="ok" />
) : (
<StatusDot status="error" />
)}
<span className="text-xs text-muted-foreground">
{JOB_LABELS[job.jobName] ?? job.jobName}
</span>
{job.lastRunAt ? (
<span
className="text-right text-[11px] text-muted-foreground/50"
suppressHydrationWarning
>
{formatDistanceToNow(new Date(job.lastRunAt), {
addSuffix: true,
})}
</span>
) : (
<span className="text-[11px] text-muted-foreground/30">
</span>
)}
{job.lastDurationMs !== null ? (
<span className="w-12 text-right font-mono text-[11px] text-muted-foreground/40">
{formatDuration(job.lastDurationMs)}
</span>
) : (
<span className="w-12" />
)}
</div>
))}
</div>
</CardContent>
</Card>
{/* ── Card 3: Storage ── */}
<Card className="border-l-2 border-l-primary/30">
<CardContent>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-primary/10">
<IconDatabase className="size-4 text-primary" />
</div>
<div>
<CardTitle>Storage</CardTitle>
<CardDescription>
Image cache and backup disk usage
</CardDescription>
</div>
</div>
</CardContent>
{/* Image cache */}
<CardContent className="border-t border-border/30 pt-4">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/40">
Image cache
</span>
{data.imageCache.enabled ? (
<span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(data.imageCache.totalSizeBytes)}
</span>
) : null}
</div>
{data.imageCache.enabled ? (
<>
<p className="mt-1 text-xs text-muted-foreground">
{data.imageCache.imageCount.toLocaleString()} cached images
</p>
<p className="mt-0.5 text-[10px] leading-relaxed text-muted-foreground/30">
{Object.entries(data.imageCache.categories)
.map(([name, cat]) => `${name} ${cat.count}`)
.join(" · ")}
</p>
</>
) : (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground/50">
<StatusDot status="inactive" />
Disabled
</p>
)}
</CardContent>
{/* Backup summary */}
<CardContent className="border-t border-border/30 pt-4">
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground/40">
Backups
</span>
{data.backups.backupCount > 0 && (
<span className="font-mono text-[11px] text-muted-foreground/50">
{formatBytes(data.backups.totalSizeBytes)}
</span>
)}
</div>
{data.backups.backupCount > 0 ? (
<p
className="mt-1 text-xs text-muted-foreground"
suppressHydrationWarning
>
{data.backups.backupCount} backups · last{" "}
{data.backups.lastBackupAt
? formatDistanceToNow(new Date(data.backups.lastBackupAt), {
addSuffix: true,
})
: "unknown"}
</p>
) : (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground/50">
<StatusDot status="inactive" />
No backups yet
</p>
)}
</CardContent>
</Card>
</div>
);
}
+71 -25
View File
@@ -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() {
/>
<IntegrationsSection initialConnections={connections} />
{isAdmin && (
<div>
<div className="mb-3 flex items-center gap-2">
<IconServerCog className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Server
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
Admin only
</span>
<>
{/* Server health */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconServerCog className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Server
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
Admin only
</span>
</div>
<SystemHealthCards />
</div>
<div className="space-y-3">
<Card className="border-l-2 border-l-primary/30">
<ServerSection initialRegistrationOpen={registrationOpen} />
</Card>
<Card className="border-l-2 border-l-primary/30">
<BackupSection
initialBackups={backups}
initialScheduledEnabled={scheduledBackupsEnabled}
initialMaxRetention={maxBackupRetention}
initialFrequency={backupFrequency as "6h" | "12h" | "1d" | "7d"}
initialTime={backupTime}
initialDow={backupDow}
/>
</Card>
{/* Security */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconShieldLock className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Security
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
Admin only
</span>
</div>
<div className="space-y-3">
<Card className="border-l-2 border-l-primary/30">
<ServerSection initialRegistrationOpen={registrationOpen} />
</Card>
</div>
</div>
</div>
{/* Backups */}
<div>
<div className="mb-3 flex items-center gap-2">
<IconDatabaseExport className="size-4 text-muted-foreground" />
<h2 className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
Backups
</h2>
<span className="rounded-md bg-primary/10 px-1.5 py-0.5 text-[10px] font-medium text-primary">
Admin only
</span>
</div>
<div className="space-y-3">
<Card className="border-l-2 border-l-primary/30">
<BackupSection initialBackups={backups} />
</Card>
<Card className="border-l-2 border-l-primary/30">
<BackupScheduleSection
initialScheduledEnabled={scheduledBackupsEnabled}
initialMaxRetention={maxBackupRetention}
initialFrequency={
backupFrequency as "6h" | "12h" | "1d" | "7d"
}
initialTime={backupTime}
initialDow={backupDow}
/>
</Card>
<Card className="border-l-2 border-l-primary/30">
<BackupRestoreSection />
</Card>
</div>
</div>
</>
)}
</SettingsShell>
);