"use client"; import { IconActivity, IconAlertTriangle, IconCalendarCheck, IconCheck, IconDatabase, IconPlayerPlay, IconRefresh, } from "@tabler/icons-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { StatusDot } from "@/components/status-dot"; 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "@/components/ui/table"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; import { useSystemHealth } from "@/hooks/use-system-health"; import { useTimeAgo } from "@/hooks/use-time-ago"; import type { SystemHealthData } from "@/lib/services/system-health"; const JOB_LABELS: Record = { nightlyRefreshLibrary: "Library refresh", refreshAvailability: "Availability", refreshRecommendations: "Recommendations", refreshTvChildren: "TV episodes", cacheImages: "Image cache", scheduledBackup: "Backup", updateCheck: "Update check", }; /** Convert a cron pattern to a short human-readable string */ function cronToHuman(pattern: string): string { const parts = pattern.split(" "); if (parts.length !== 5) return pattern; const [min, hour, _dom, _mon, dow] = parts; // Every N hours: "0 */6 * * *" if (hour.startsWith("*/")) { const n = Number.parseInt(hour.slice(2), 10); return `Every ${n}h`; } // Twice daily: "0 1,13 * * *" if (hour.includes(",") && !hour.includes("/") && !hour.includes("-")) { const hours = hour.split(","); if (hours.length === 2) { return `Daily at ${hours.map((h) => `${h.padStart(2, "0")}:${min.padStart(2, "0")}`).join(", ")}`; } } // Daily at specific time: "0 3 * * *" if (/^\d+$/.test(hour) && /^\d+$/.test(min) && dow === "*") { return `Daily at ${hour.padStart(2, "0")}:${min.padStart(2, "0")}`; } // Weekly if (/^\d+$/.test(dow)) { const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; return `Weekly on ${days[Number(dow)] ?? dow}`; } return pattern; } 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`; } function SkeletonCards() { return (
{["status", "jobs", "storage"].map((s) => (
))}
); } /** Inline component that live-updates a relative timestamp */ function LiveTimeAgo({ date, fallback = "", }: { date: string | Date | null | undefined; fallback?: string; }) { const text = useTimeAgo(date, { fallback }); return <>{text}; } /** Renders 3 separate cards: System status, Background jobs, Storage */ export function SystemHealthCards() { const { data, error, isLoading, isValidating, refresh } = useSystemHealth(); useEffect(() => { if (error && !isLoading) { toast.error("Failed to refresh system health"); } }, [error, isLoading]); if (isLoading) return ; if (!data) return null; return (
{/* ── Card 1: System Status ── */}
Health status Checked
refresh()} disabled={isValidating} /> } > {isValidating ? : } Refresh
{/* Database */}
Database {formatBytes(data.database.dbSizeBytes)} {data.database.walSizeBytes > 0 && ` + ${formatBytes(data.database.walSizeBytes)} WAL`}
{/* TMDB */}
TMDB API {!data.tmdb.tokenConfigured ? ( <> Not configured ) : data.tmdb.connected && data.tmdb.tokenValid ? ( <> Connected {data.tmdb.responseTimeMs}ms ) : data.tmdb.connected && !data.tmdb.tokenValid ? ( <> Invalid token ) : ( <> Unreachable {data.tmdb.error && ( {data.tmdb.error} )} )}
{/* Environment */}
Environment {data.environment.dataDirWritable ? ( ) : ( )}
{data.environment.envVars .filter((env) => env.value !== null) .map((env) => (
{env.name}= {env.value}
))}
{/* ── Card 2: Background Jobs ── */} {/* ── Card 3: Storage ── */}
Storage Image cache and backup disk usage
{/* Image cache */}
Image cache {data.imageCache.enabled ? ( {formatBytes(data.imageCache.totalSizeBytes)} ) : null}
{data.imageCache.enabled ? ( <>

{data.imageCache.imageCount.toLocaleString()} cached images

{Object.entries(data.imageCache.categories) .map(([name, cat]) => `${name} ${cat.count}`) .join(" · ")}

) : (

Disabled

)}
{/* Backup summary */}
Backups {data.backups.backupCount > 0 && ( {formatBytes(data.backups.totalSizeBytes)} )}
{data.backups.backupCount > 0 ? (

{data.backups.backupCount} backups · last{" "}

) : (

No backups yet

)}
); } /** Background Jobs card with table layout and manual trigger */ function BackgroundJobsCard({ jobs, onRefresh, }: { jobs: SystemHealthData["jobs"]; onRefresh: () => void; }) { const [triggeringJob, setTriggeringJob] = useState(null); const handleTrigger = async (jobName: string) => { setTriggeringJob(jobName); try { const res = await fetch("/api/admin/jobs/trigger", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jobName }), }); if (!res.ok) { const data = await res.json().catch(() => ({})); throw new Error(data.error || "Failed to trigger job"); } toast.success(`${JOB_LABELS[jobName] ?? jobName} triggered`); // Refresh after a brief delay so the run shows up setTimeout(onRefresh, 1500); } catch (err) { toast.error(err instanceof Error ? err.message : "Failed to trigger job"); } finally { setTriggeringJob(null); } }; const healthyCount = jobs.filter((j) => j.lastStatus === "success").length; return (
Background jobs {healthyCount} of {jobs.length} jobs healthy
Job Schedule Last run Next run Actions {jobs.map((job) => { const isTriggering = triggeringJob === job.jobName; const isRunning = job.isCurrentlyRunning || isTriggering; return ( {/* Job name + status */}
{isRunning ? ( ) : job.lastStatus === null ? ( ) : job.lastStatus === "success" ? ( ) : ( )} {JOB_LABELS[job.jobName] ?? job.jobName}
{/* Schedule */} {job.cronPattern ? ( {cronToHuman(job.cronPattern)} {job.cronPattern} ) : ( )} {/* Last run */} {job.lastRunAt ? (
{job.lastDurationMs !== null && job.lastDurationMs > 0 && ( {formatDuration(job.lastDurationMs)} )}
{new Date(job.lastRunAt).toLocaleString()} {job.lastError && (
{job.lastError}
)}
) : ( Never )}
{/* Next run */} {job.nextRunAt ? ( {new Date(job.nextRunAt).toLocaleString()} ) : ( )} {/* Trigger button */} handleTrigger(job.jobName)} /> } > {isRunning ? ( ) : ( )} Run now
); })}
); }