mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-08-29 05:05:38 -04:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user