perf(studio): cache nitro scan, stream progress, virtualize cleanup list
This commit is contained in:
1 parent
c916e42572
commit
ba81d16f00
5 files changed
+727
-257
No files matched your search
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CheckCircle2,
|
||||
@@ -11,8 +12,9 @@ import {
|
||||
Sparkles,
|
||||
Trash2,
|
||||
Wrench,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useConfirmDialog } from "@/components/admin/confirm-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -20,6 +22,7 @@ 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;
|
||||
@@ -36,6 +39,7 @@ interface NitroCleanupScan {
|
||||
orphanedSwf: AssetEntry[];
|
||||
orphanedIcon: AssetEntry[];
|
||||
total: number;
|
||||
cached?: boolean;
|
||||
}
|
||||
|
||||
interface DeleteResponse {
|
||||
@@ -62,13 +66,7 @@ type GroupTone = "error" | "warning" | "neutral";
|
||||
type AssetKind = "nitro" | "swf" | "icon";
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* How many rows a group renders at once. Directories can contain tens of
|
||||
* thousands of leftover files; mounting every row as DOM nodes freezes the
|
||||
* tab, so rows are added in chunks via a "load more" button.
|
||||
*/
|
||||
const RENDER_CHUNK = 500;
|
||||
const SCAN_IDLE_TIMEOUT_MS = 120_000;
|
||||
|
||||
function ageLabel(lastModified: number): string {
|
||||
if (!lastModified) return "age unknown";
|
||||
@@ -91,6 +89,87 @@ function downloadJson(filename: string, data: unknown): void {
|
||||
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<string>;
|
||||
disabled?: boolean;
|
||||
onToggle: (fileName: string) => void;
|
||||
}) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
const virtualizer = useVirtualizer({
|
||||
count: entries.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 30,
|
||||
overscan: 10,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={parentRef}
|
||||
className="max-h-80 overflow-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-2"
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const entry = entries[virtualItem.index];
|
||||
return (
|
||||
<div
|
||||
key={entry.fileName}
|
||||
data-index={virtualItem.index}
|
||||
ref={virtualizer.measureElement}
|
||||
className="absolute left-0 top-0 flex w-full items-center gap-2 rounded-md px-2 text-sm hover:bg-[var(--admin-accent)]/5"
|
||||
style={{
|
||||
height: virtualItem.size,
|
||||
transform: `translateY(${virtualItem.start}px)`,
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
style={{ display: "inline-flex", width: 16, height: 16 }}
|
||||
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
|
||||
checked={selected.has(entry.fileName)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => onToggle(entry.fileName)}
|
||||
aria-label={`Select ${entry.fileName}`}
|
||||
/>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{entry.fileName}
|
||||
</code>
|
||||
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
|
||||
{formatBytes(entry.size)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
|
||||
{ageLabel(entry.lastModified)}
|
||||
</span>
|
||||
{entry.error && (
|
||||
<span
|
||||
title={entry.error}
|
||||
className="hidden max-w-[320px] truncate text-xs text-[var(--admin-error)] lg:block"
|
||||
>
|
||||
{entry.error}
|
||||
</span>
|
||||
)}
|
||||
{entry.dirs.length > 1 && (
|
||||
<span className="shrink-0 rounded bg-[var(--admin-accent)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--admin-text-muted)]">
|
||||
{entry.dirs.length} copies
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CleanupGroup({
|
||||
label,
|
||||
description,
|
||||
@@ -110,11 +189,8 @@ function CleanupGroup({
|
||||
onToggle: (fileName: string) => void;
|
||||
onToggleAll: (names: string[]) => void;
|
||||
}) {
|
||||
const [shown, setShown] = useState(RENDER_CHUNK);
|
||||
|
||||
const checked = entries.filter((e) => selected.has(e.fileName));
|
||||
const allChecked = entries.length > 0 && checked.length === entries.length;
|
||||
const visibleEntries = entries.slice(0, shown);
|
||||
const toneClass =
|
||||
tone === "error"
|
||||
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
|
||||
@@ -158,63 +234,12 @@ function CleanupGroup({
|
||||
No {label.toLowerCase()} found
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<ul className="max-h-80 space-y-1 overflow-auto rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-2">
|
||||
{visibleEntries.map((entry) => (
|
||||
<li
|
||||
key={entry.fileName}
|
||||
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm hover:bg-[var(--admin-accent)]/5"
|
||||
>
|
||||
<Checkbox
|
||||
style={{ display: "inline-flex", width: 16, height: 16 }}
|
||||
className="border-[var(--admin-border)] bg-[var(--admin-surface)] data-checked:border-[var(--admin-accent)] data-checked:bg-[var(--admin-accent)] data-checked:text-[var(--admin-accent-foreground)]"
|
||||
checked={selected.has(entry.fileName)}
|
||||
disabled={disabled}
|
||||
onCheckedChange={() => onToggle(entry.fileName)}
|
||||
aria-label={`Select ${entry.fileName}`}
|
||||
/>
|
||||
<code className="min-w-0 flex-1 truncate font-mono text-xs">
|
||||
{entry.fileName}
|
||||
</code>
|
||||
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
|
||||
{formatBytes(entry.size)}
|
||||
</span>
|
||||
<span className="shrink-0 text-xs text-[var(--admin-text-muted)]">
|
||||
{ageLabel(entry.lastModified)}
|
||||
</span>
|
||||
{entry.error && (
|
||||
<span
|
||||
title={entry.error}
|
||||
className="hidden max-w-[320px] truncate text-xs text-[var(--admin-error)] lg:block"
|
||||
>
|
||||
{entry.error}
|
||||
</span>
|
||||
)}
|
||||
{entry.dirs.length > 1 && (
|
||||
<span className="shrink-0 rounded bg-[var(--admin-accent)]/10 px-1.5 py-0.5 text-[10px] font-medium text-[var(--admin-text-muted)]">
|
||||
{entry.dirs.length} copies
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{entries.length > shown && (
|
||||
<div className="flex items-center justify-between px-2 text-xs text-[var(--admin-text-muted)]">
|
||||
<span>
|
||||
Showing {shown} of {entries.length}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={() => setShown((s) => s + RENDER_CHUNK)}
|
||||
>
|
||||
Load {Math.min(RENDER_CHUNK, entries.length - shown)} more
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<VirtualAssetList
|
||||
entries={entries}
|
||||
selected={selected}
|
||||
disabled={disabled}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
@@ -225,63 +250,113 @@ export function NitroCleanupPanel() {
|
||||
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<NitroCleanupScan | null>(null);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [selectedSwf, setSelectedSwf] = useState<Set<string>>(new Set());
|
||||
const [selectedIcon, setSelectedIcon] = useState<Set<string>>(new Set());
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const { confirm, dialog } = useConfirmDialog();
|
||||
|
||||
const scanNow = useCallback(async () => {
|
||||
setScanning(true);
|
||||
setSelected(new Set());
|
||||
setSelectedSwf(new Set());
|
||||
setSelectedIcon(new Set());
|
||||
try {
|
||||
const res = await adminFetch("/api/admin/studio/nitro-cleanup");
|
||||
const type = res.headers.get("content-type") ?? "";
|
||||
if (!res.ok) {
|
||||
const raw = await res.text();
|
||||
throw new Error(
|
||||
raw && !type.includes("application/json")
|
||||
? `Scan failed (HTTP ${res.status}) — the server or reverse proxy returned a non-JSON response. This usually means a proxy/worker timeout; try again or raise the upstream timeout.`
|
||||
: `Scan failed (HTTP ${res.status})`,
|
||||
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 (!type.includes("application/json")) {
|
||||
throw new Error(
|
||||
"Scan failed — the server returned a non-JSON response (likely an upstream timeout or proxy error page).",
|
||||
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;
|
||||
}
|
||||
const data = (await res.json()) as NitroCleanupScan & {
|
||||
error?: string;
|
||||
};
|
||||
if (data.error) throw new Error(data.error);
|
||||
setScan({
|
||||
fake: data.fake ?? [],
|
||||
broken: data.broken ?? [],
|
||||
orphanedSwf: data.orphanedSwf ?? [],
|
||||
orphanedIcon: data.orphanedIcon ?? [],
|
||||
total: data.total ?? 0,
|
||||
});
|
||||
const found =
|
||||
data.fake.length +
|
||||
data.broken.length +
|
||||
data.orphanedSwf.length +
|
||||
data.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) {
|
||||
toast.error(err instanceof Error ? err.message : "Scan failed");
|
||||
} finally {
|
||||
setScanning(false);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[onlyOld, minAgeDays],
|
||||
);
|
||||
|
||||
const toggle = (kind: AssetKind) => (fileName: string) => {
|
||||
const setter =
|
||||
@@ -318,6 +393,25 @@ export function NitroCleanupPanel() {
|
||||
|
||||
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 = <T extends { fileName: string }>(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 [
|
||||
@@ -358,6 +452,7 @@ export function NitroCleanupPanel() {
|
||||
deleted += data.deleted ?? 0;
|
||||
copiesRemoved += data.copiesRemoved ?? 0;
|
||||
if (data.errors?.length) hadErrors = true;
|
||||
dropFromScan(group.kind, group.names);
|
||||
}
|
||||
if (hadErrors) {
|
||||
toast.warning(
|
||||
@@ -371,7 +466,6 @@ export function NitroCleanupPanel() {
|
||||
setSelected(new Set());
|
||||
setSelectedSwf(new Set());
|
||||
setSelectedIcon(new Set());
|
||||
await scanNow();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Delete failed");
|
||||
} finally {
|
||||
@@ -408,8 +502,12 @@ export function NitroCleanupPanel() {
|
||||
} 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());
|
||||
await scanNow();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Repair failed");
|
||||
} finally {
|
||||
@@ -445,12 +543,18 @@ export function NitroCleanupPanel() {
|
||||
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)`,
|
||||
);
|
||||
}
|
||||
await scanNow();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : "Auto-clean failed");
|
||||
} finally {
|
||||
@@ -458,57 +562,72 @@ export function NitroCleanupPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const exportManifest = () => {
|
||||
const exportManifest = async () => {
|
||||
if (scan === null) return;
|
||||
const entries = {
|
||||
scannedAt: new Date().toISOString(),
|
||||
total: scan.total,
|
||||
fake: scan.fake,
|
||||
broken: scan.broken,
|
||||
orphanedSwf: scan.orphanedSwf,
|
||||
orphanedIcon: scan.orphanedIcon,
|
||||
selected: {
|
||||
nitro: [...selected].sort(),
|
||||
swf: [...selectedSwf].sort(),
|
||||
icon: [...selectedIcon].sort(),
|
||||
},
|
||||
};
|
||||
downloadJson(`nitro-cleanup-scan-${Date.now()}.json`, entries);
|
||||
toast.success("Scan manifest downloaded");
|
||||
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 visibleFake = useMemo(() => {
|
||||
if (scan === null) return [];
|
||||
if (!onlyOld || minAgeDays <= 0) return scan.fake;
|
||||
const threshold = minAgeDays * DAY_MS;
|
||||
return scan.fake.filter((e) => e.lastModified <= Date.now() - threshold);
|
||||
}, [scan, onlyOld, minAgeDays]);
|
||||
const toggleOnlyOld = (value: boolean) => {
|
||||
setOnlyOld(value);
|
||||
const threshold = value ? Math.max(0, minAgeDays) : undefined;
|
||||
void scanNow({ force: false, minAgeDays: threshold ?? 0 });
|
||||
};
|
||||
|
||||
const visibleSwf = useMemo(() => {
|
||||
if (scan === null) return [];
|
||||
if (!onlyOld || minAgeDays <= 0) return scan.orphanedSwf;
|
||||
const threshold = minAgeDays * DAY_MS;
|
||||
return scan.orphanedSwf.filter(
|
||||
(e) => e.lastModified <= Date.now() - threshold,
|
||||
);
|
||||
}, [scan, onlyOld, minAgeDays]);
|
||||
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 visibleIcon = useMemo(() => {
|
||||
if (scan === null) return [];
|
||||
if (!onlyOld || minAgeDays <= 0) return scan.orphanedIcon;
|
||||
const threshold = minAgeDays * DAY_MS;
|
||||
return scan.orphanedIcon.filter(
|
||||
(e) => e.lastModified <= Date.now() - threshold,
|
||||
);
|
||||
}, [scan, onlyOld, minAgeDays]);
|
||||
|
||||
const busy = scanning || deleting || repairing || autoCleaning;
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
{dialog}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button onClick={scanNow} disabled={busy} size="lg">
|
||||
<Button
|
||||
onClick={() => scanNow({ force: true })}
|
||||
disabled={busy}
|
||||
size="lg"
|
||||
>
|
||||
{scanning ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
@@ -516,6 +635,16 @@ export function NitroCleanupPanel() {
|
||||
)}
|
||||
{scanning ? "Scanning assets…" : "Scan furniture assets"}
|
||||
</Button>
|
||||
{scanning && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="lg"
|
||||
onClick={() => abortRef.current?.abort()}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
{scan && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
@@ -583,12 +712,28 @@ export function NitroCleanupPanel() {
|
||||
disabled={busy}
|
||||
onClick={exportManifest}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Export manifest
|
||||
{exporting ? (
|
||||
<Loader2 className="w-4 h-4 animate-spin mr-2" />
|
||||
) : (
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
{exporting ? "Exporting…" : "Export manifest"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{scanning && progress && (
|
||||
<div className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-sm text-[var(--admin-text-muted)]">
|
||||
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
||||
{phaseLabel(progress.phase)}
|
||||
{progress.scanned > 0 && (
|
||||
<span className="font-mono text-xs">
|
||||
{progress.scanned.toLocaleString()} files
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{scan && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||
@@ -602,7 +747,7 @@ export function NitroCleanupPanel() {
|
||||
className="w-20"
|
||||
value={minAgeDays}
|
||||
disabled={busy}
|
||||
onChange={(e) => setMinAgeDays(Number(e.target.value))}
|
||||
onChange={(e) => changeMinAgeDays(Number(e.target.value))}
|
||||
aria-label="Minimum age in days to display"
|
||||
/>
|
||||
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
|
||||
@@ -613,7 +758,7 @@ export function NitroCleanupPanel() {
|
||||
<Checkbox
|
||||
id="nitro-hide-recent"
|
||||
checked={onlyOld}
|
||||
onCheckedChange={(value) => setOnlyOld(value === true)}
|
||||
onCheckedChange={(value) => toggleOnlyOld(value === true)}
|
||||
/>
|
||||
<span>Hide recent files</span>
|
||||
</label>
|
||||
@@ -624,6 +769,12 @@ export function NitroCleanupPanel() {
|
||||
<div className="text-xl font-bold">{scan.total}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
asset files scanned
|
||||
{scan.cached && (
|
||||
<span className="text-[var(--admin-accent)]">
|
||||
{" "}
|
||||
(cached — directories unchanged)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl border bg-card p-3 space-y-1">
|
||||
@@ -648,7 +799,7 @@ export function NitroCleanupPanel() {
|
||||
label="Fake nitros"
|
||||
description="These files match no item in items_base — leftovers from deleted or renamed furniture."
|
||||
tone="error"
|
||||
entries={visibleFake}
|
||||
entries={scan.fake}
|
||||
selected={selected}
|
||||
onToggle={toggle("nitro")}
|
||||
onToggleAll={toggleAll("nitro")}
|
||||
@@ -657,7 +808,7 @@ export function NitroCleanupPanel() {
|
||||
label="Broken nitros"
|
||||
description="These files belong to an item but cannot be parsed as a valid Nitro bundle."
|
||||
tone="warning"
|
||||
entries={onlyOld ? [] : scan.broken}
|
||||
entries={scan.broken}
|
||||
selected={selected}
|
||||
onToggle={toggle("nitro")}
|
||||
onToggleAll={toggleAll("nitro")}
|
||||
@@ -667,7 +818,7 @@ export function NitroCleanupPanel() {
|
||||
label="Orphaned SWFs"
|
||||
description=".swf files that match no item in items_base."
|
||||
tone="warning"
|
||||
entries={visibleSwf}
|
||||
entries={scan.orphanedSwf}
|
||||
selected={selectedSwf}
|
||||
onToggle={toggle("swf")}
|
||||
onToggleAll={toggleAll("swf")}
|
||||
@@ -676,7 +827,7 @@ export function NitroCleanupPanel() {
|
||||
label="Orphaned icons"
|
||||
description="Icon files that match no item in items_base."
|
||||
tone="neutral"
|
||||
entries={visibleIcon}
|
||||
entries={scan.orphanedIcon}
|
||||
selected={selectedIcon}
|
||||
onToggle={toggle("icon")}
|
||||
onToggleAll={toggleAll("icon")}
|
||||
|
||||
Reference in new issue
Block a user