"use client"; import { useVirtualizer } from "@tanstack/react-virtual"; import { AlertTriangle, CheckCircle2, Download, FileX2, Loader2, ScanSearch, ScrollText, Sparkles, Trash2, Wrench, X, } from "lucide-react"; import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { useConfirmDialog } from "@/components/admin/confirm-dialog"; import { Button } from "@/components/ui/button"; import { Checkbox } from "@/components/ui/checkbox"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; import { formatBytes } from "@/lib/services/disk-usage"; import { readSseStream } from "@/lib/sse-client"; interface AssetEntry { fileName: string; base: string; size: number; dirs: string[]; lastModified: number; error?: string; } interface NitroCleanupScan { fake: AssetEntry[]; broken: AssetEntry[]; orphanedSwf: AssetEntry[]; orphanedIcon: AssetEntry[]; total: number; cached?: boolean; } interface DeleteResponse { deleted: number; copiesRemoved: number; errors?: string[]; } interface RepairResponse { repaired: number; failed: number; errors?: string[]; } interface AutoCleanResponse { deleted: number; copiesRemoved: number; skippedRecent: number; skippedBroken: number; errors?: string[]; } type GroupTone = "error" | "warning" | "neutral"; type AssetKind = "nitro" | "swf" | "icon"; const DAY_MS = 24 * 60 * 60 * 1000; const SCAN_IDLE_TIMEOUT_MS = 120_000; function ageLabel(lastModified: number): string { if (!lastModified) return "age unknown"; const days = Math.floor((Date.now() - lastModified) / DAY_MS); if (days < 1) return "< 1d"; if (days < 30) return `${days}d`; if (days < 365) return `${Math.floor(days / 30)}mo`; return `${Math.floor(days / 365)}y`; } function downloadJson(filename: string, data: unknown): void { const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json", }); const url = URL.createObjectURL(blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = filename; anchor.click(); URL.revokeObjectURL(url); } /** * Virtualized list for one cleanup group. Directories can hold tens of * thousands of leftover files; only the visible rows are mounted as DOM nodes, * so the tab never freezes no matter how long the list is. */ function VirtualAssetList({ entries, selected, disabled = false, onToggle, }: { entries: AssetEntry[]; selected: Set; disabled?: boolean; onToggle: (fileName: string) => void; }) { const parentRef = useRef(null); const virtualizer = useVirtualizer({ count: entries.length, getScrollElement: () => parentRef.current, estimateSize: () => 30, overscan: 10, }); return (
{virtualizer.getVirtualItems().map((virtualItem) => { const entry = entries[virtualItem.index]; return (
onToggle(entry.fileName)} aria-label={`Select ${entry.fileName}`} /> {entry.fileName} {formatBytes(entry.size)} {ageLabel(entry.lastModified)} {entry.error && ( {entry.error} )} {entry.dirs.length > 1 && ( {entry.dirs.length} copies )}
); })}
); } function CleanupGroup({ label, description, tone, entries, selected, disabled = false, onToggle, onToggleAll, }: { label: string; description: string; tone: GroupTone; entries: AssetEntry[]; selected: Set; disabled?: boolean; onToggle: (fileName: string) => void; onToggleAll: (names: string[]) => void; }) { const checked = entries.filter((e) => selected.has(e.fileName)); const allChecked = entries.length > 0 && checked.length === entries.length; const toneClass = tone === "error" ? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]" : tone === "warning" ? "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]" : "border-[var(--admin-border)] bg-[var(--admin-surface)]"; return (
0 ? toneClass : "border-[var(--admin-border)] bg-[var(--admin-surface)]"}`} > onToggleAll(entries.map((e) => e.fileName))} aria-label={`Select all ${label}`} />

0 ? "text-[var(--admin-text)]" : "text-[var(--admin-text-muted)]"}`} > {label} ({entries.length})

{description}

{checked.length > 0 && ( {checked.length} selected )}
{entries.length === 0 ? (

No {label.toLowerCase()} found

) : ( )}
); } export function NitroCleanupPanel() { const [scanning, setScanning] = useState(false); const [deleting, setDeleting] = useState(false); const [repairing, setRepairing] = useState(false); const [autoCleaning, setAutoCleaning] = useState(false); const [exporting, setExporting] = useState(false); const [autoAge, setAutoAge] = useState(30); const [minAgeDays, setMinAgeDays] = useState(0); const [onlyOld, setOnlyOld] = useState(false); const [progress, setProgress] = useState<{ phase: string; scanned: number; } | null>(null); const [scan, setScan] = useState(null); const [selected, setSelected] = useState>(new Set()); const [selectedSwf, setSelectedSwf] = useState>(new Set()); const [selectedIcon, setSelectedIcon] = useState>(new Set()); const abortRef = useRef(null); const { confirm, dialog } = useConfirmDialog(); const scanNow = useCallback( async (opts: { force?: boolean; minAgeDays?: number } = {}) => { const force = opts.force ?? true; const ageFilter = opts.minAgeDays ?? (onlyOld ? minAgeDays : 0); abortRef.current?.abort(); setScanning(true); setProgress(null); setSelected(new Set()); setSelectedSwf(new Set()); setSelectedIcon(new Set()); const abort = new AbortController(); abortRef.current = abort; const params = new URLSearchParams(); if (force) params.set("force", "1"); if (ageFilter > 0) params.set("minAgeDays", String(ageFilter)); const holder: { result: NitroCleanupScan | null } = { result: null }; let lastError: string | null = null; try { const res = await adminFetch( `/api/admin/studio/nitro-cleanup${ params.size > 0 ? `?${params.toString()}` : "" }`, { headers: { Accept: "text/event-stream" }, signal: abort.signal }, ); if (!res.ok) { const data = (await res.json().catch(() => null)) as { error?: string; } | null; throw new Error( typeof data?.error === "string" ? data.error : `Scan failed (HTTP ${res.status})`, ); } if (!res.body) throw new Error("Scan returned no response body"); await readSseStream( res.body, (evt) => { if (evt.type === "progress") { setProgress({ phase: String(evt.phase ?? ""), scanned: Number(evt.scanned ?? 0), }); } else if (evt.type === "result") { holder.result = { fake: (evt.fake as AssetEntry[]) ?? [], broken: (evt.broken as AssetEntry[]) ?? [], orphanedSwf: (evt.orphanedSwf as AssetEntry[]) ?? [], orphanedIcon: (evt.orphanedIcon as AssetEntry[]) ?? [], total: Number(evt.total ?? 0), cached: evt.cached === true, }; setScan(holder.result); } else if (evt.type === "error") { lastError = String(evt.message ?? "Scan failed"); } }, abort.signal, SCAN_IDLE_TIMEOUT_MS, ); if (lastError) throw new Error(lastError); const lastResult = holder.result; if (lastResult) { const found = lastResult.fake.length + lastResult.broken.length + lastResult.orphanedSwf.length + lastResult.orphanedIcon.length; if (found === 0) { toast.success("No orphaned or broken furniture assets found"); } else { toast.info(`Found ${found} removable asset file(s)`); } } } catch (err) { if ((err as Error).name === "AbortError") { toast.info("Scan cancelled"); } else { toast.error(err instanceof Error ? err.message : "Scan failed"); } } finally { setScanning(false); setProgress(null); if (abortRef.current === abort) abortRef.current = null; } }, [onlyOld, minAgeDays], ); const toggle = (kind: AssetKind) => (fileName: string) => { const setter = kind === "swf" ? setSelectedSwf : kind === "icon" ? setSelectedIcon : setSelected; setter((prev) => { const next = new Set(prev); if (next.has(fileName)) next.delete(fileName); else next.add(fileName); return next; }); }; const toggleAll = (kind: AssetKind) => (names: string[]) => { const setter = kind === "swf" ? setSelectedSwf : kind === "icon" ? setSelectedIcon : setSelected; setter((prev) => { const next = new Set(prev); const allChecked = names.every((n) => next.has(n)); for (const name of names) { if (allChecked) next.delete(name); else next.add(name); } return next; }); }; const totalSelected = selected.size + selectedSwf.size + selectedIcon.size; /** Drop deleted/attempted files from the loaded scan without re-scanning. */ const dropFromScan = (kind: AssetKind, names: string[]) => { const remove = new Set(names); if (remove.size === 0) return; setScan((prev) => { if (!prev) return prev; const drop = (list: T[]): T[] => list.filter((entry) => !remove.has(entry.fileName)); return { ...prev, fake: kind === "nitro" ? drop(prev.fake) : prev.fake, broken: kind === "nitro" ? drop(prev.broken) : prev.broken, orphanedSwf: kind === "swf" ? drop(prev.orphanedSwf) : prev.orphanedSwf, orphanedIcon: kind === "icon" ? drop(prev.orphanedIcon) : prev.orphanedIcon, }; }); }; const removeSelected = async () => { const groups: Array<{ kind: AssetKind; names: string[] }> = []; for (const [kind, set] of [ ["nitro", selected], ["swf", selectedSwf], ["icon", selectedIcon], ] as const) { const names = [...set]; if (names.length > 0) groups.push({ kind, names }); } if (groups.length === 0) return; const confirmed = await confirm({ title: `Delete ${totalSelected} asset file(s)?`, description: "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.", confirmLabel: "Delete", variant: "danger", }); if (!confirmed) return; setDeleting(true); try { let copiesRemoved = 0; let deleted = 0; let hadErrors = false; for (const group of groups) { const res = await adminFetch("/api/admin/studio/nitro-cleanup", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileNames: group.names.sort(), kind: group.kind, }), }); const data = (await res.json()) as DeleteResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Delete failed"); deleted += data.deleted ?? 0; copiesRemoved += data.copiesRemoved ?? 0; if (data.errors?.length) hadErrors = true; dropFromScan(group.kind, group.names); } if (hadErrors) { toast.warning( `Deleted ${copiesRemoved} copy/copies, but some files could not be removed`, ); } else { toast.success( `Deleted ${copiesRemoved} file copy/copies (${deleted} file(s))`, ); } setSelected(new Set()); setSelectedSwf(new Set()); setSelectedIcon(new Set()); } catch (err) { toast.error(err instanceof Error ? err.message : "Delete failed"); } finally { setDeleting(false); } }; const repairSelected = async () => { const fileNames = [...selected] .filter((name) => scan?.broken.some((e) => e.fileName === name)) .sort(); if (fileNames.length === 0) return; const confirmed = await confirm({ title: `Re-download ${fileNames.length} broken .nitro bundle(s)?`, description: "The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).", confirmLabel: "Re-download", variant: "default", }); if (!confirmed) return; setRepairing(true); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileNames }), }); const data = (await res.json()) as RepairResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Repair failed"); if (data.repaired > 0) { toast.success(`Repaired ${data.repaired} .nitro bundle(s)`); } else { toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`); } // Only drop the attempted bundles when every one succeeded; partial // failures stay visible so they can be retried. if (data.repaired >= fileNames.length) { dropFromScan("nitro", fileNames); } setSelected(new Set()); } catch (err) { toast.error(err instanceof Error ? err.message : "Repair failed"); } finally { setRepairing(false); } }; const autoClean = async () => { if (scan === null) return; const confirmed = await confirm({ title: `Auto-clean fake nitros older than ${autoAge} day(s)?`, description: "Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.", confirmLabel: "Auto-clean", variant: "danger", }); if (!confirmed) return; setAutoCleaning(true); try { const res = await adminFetch( "/api/admin/studio/nitro-cleanup/auto-clean", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ maxAgeDays: autoAge }), }, ); const data = (await res.json()) as AutoCleanResponse & { error?: string; }; if (!res.ok) throw new Error(data.error || "Auto-clean failed"); if (data.deleted > 0) { toast.success( `Auto-cleaned ${data.deleted} old fake .nitro file(s) (${data.copiesRemoved} copies). Skipped ${data.skippedRecent} recent + ${data.skippedBroken} broken.`, ); const removable = (scan.fake ?? []) .filter( (entry) => Math.floor((Date.now() - entry.lastModified) / DAY_MS) >= autoAge, ) .map((entry) => entry.fileName); dropFromScan("nitro", removable); } else { toast.info( `Nothing old enough to remove (skipped ${data.skippedRecent} recent, ${data.skippedBroken} broken)`, ); } } catch (err) { toast.error(err instanceof Error ? err.message : "Auto-clean failed"); } finally { setAutoCleaning(false); } }; const exportManifest = async () => { if (scan === null) return; setExporting(true); try { const res = await adminFetch("/api/admin/studio/nitro-cleanup?full=1", { headers: { Accept: "application/json" }, }); const data = (await res.json()) as NitroCleanupScan & { error?: string; }; if (!res.ok || data.error) { throw new Error(data.error || `Export failed (HTTP ${res.status})`); } const entries = { scannedAt: new Date().toISOString(), total: data.total ?? 0, fake: data.fake ?? [], broken: data.broken ?? [], orphanedSwf: data.orphanedSwf ?? [], orphanedIcon: data.orphanedIcon ?? [], selected: { nitro: [...selected].sort(), swf: [...selectedSwf].sort(), icon: [...selectedIcon].sort(), }, }; downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries); toast.success("Scan manifest downloaded"); } catch (err) { toast.error(err instanceof Error ? err.message : "Export failed"); } finally { setExporting(false); } }; const toggleOnlyOld = (value: boolean) => { setOnlyOld(value); const threshold = value ? Math.max(0, minAgeDays) : undefined; void scanNow({ force: false, minAgeDays: threshold ?? 0 }); }; const changeMinAgeDays = (value: number) => { const days = Number.isFinite(value) && value >= 0 ? Math.floor(value) : 0; setMinAgeDays(days); if (onlyOld) void scanNow({ force: false, minAgeDays: days }); }; const busy = scanning || deleting || repairing || autoCleaning || exporting; const phaseLabel = (phase: string): string => phase === "stems" ? "Loading furniture stems…" : phase === "readdir" ? "Reading asset directories…" : phase === "validate" ? "Validating .nitro bundles…" : "Scanning…"; return (
{dialog}
{scanning && ( )} {scan && ( )} {scan && scan.broken.length > 0 && ( )} {scan && scan.fake.length > 0 && ( <>
setAutoAge(Number(e.target.value))} aria-label="Minimum age in days for auto-clean" /> days
)} {scan && ( )}
{scanning && progress && (
{phaseLabel(progress.phase)} {progress.scanned > 0 && ( {progress.scanned.toLocaleString()} files )}
)} {scan && (
Only show files older than
changeMinAgeDays(Number(e.target.value))} aria-label="Minimum age in days to display" /> days
{scan.total}
asset files scanned {scan.cached && ( {" "} (cached — directories unchanged) )}
{scan.fake.length}
Fake nitros (no matching item)
{scan.broken.length}
Broken nitros (unparseable)
{(scan.fake.length > 0 || scan.broken.length > 0) && (

Deleting a broken .nitro leaves its item in the database without a bundle — prefer re-downloading it, or let the catalog audit re-obtain it later. Database rows are never removed by this tool.

)}
Only asset files are touched — FurnitureData.json and database rows stay intact.
)} {!scan && !scanning && (

Scan every furni asset directory to find fake and broken nitro bundles plus orphaned SWFs and icons. Delete them, re-download the broken ones, or auto-clean old leftovers.

)}
); }