feat(studio): expand nitro cleanup with repair, auto-clean, and orphaned assets
CI / check (push) Successful in 4m15s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 1m59s

Scan distinguishes fake, broken, and orphaned SWF/icon assets with age
metadata, deletes per asset kind, re-downloads broken nitro bundles from
configured sources, auto-cleans old fake leftovers, and exports a JSON
manifest. Adds rebuild and auto-clean API endpoints with audit coverage
and a housekeeping preview route under the hotel domain.

Verified: full vitest suite (2213 tests), typecheck, and biome all pass.
This commit is contained in:
openhands committed 2026-09-15 21:59:21 +02:00
1 parent ea4fd375b4
commit 438277a17a
13 files changed
+1118 -155

No files matched your search

@@ -3,30 +3,38 @@
import {
AlertTriangle,
CheckCircle2,
Download,
FileX2,
Loader2,
ScanSearch,
ScrollText,
Sparkles,
Trash2,
Wrench,
} from "lucide-react";
import { useCallback, useState } from "react";
import { useCallback, useMemo, 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";
interface NitroCleanupEntry {
interface AssetEntry {
fileName: string;
base: string;
size: number;
dirs: string[];
lastModified: number;
error?: string;
}
interface NitroCleanupScan {
fake: NitroCleanupEntry[];
broken: NitroCleanupEntry[];
fake: AssetEntry[];
broken: AssetEntry[];
orphanedSwf: AssetEntry[];
orphanedIcon: AssetEntry[];
total: number;
}
@@ -36,45 +44,84 @@ interface DeleteResponse {
errors?: string[];
}
type GroupKey = "fake" | "broken";
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;
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);
}
function CleanupGroup({
group,
label,
description,
tone,
entries,
selected,
disabled = false,
onToggle,
onToggleAll,
}: {
group: GroupKey;
label: string;
description: string;
entries: NitroCleanupEntry[];
tone: GroupTone;
entries: AssetEntry[];
selected: Set<string>;
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 isError = group === "fake";
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 (
<section className="space-y-2">
<div
className={`flex flex-wrap items-center gap-2 rounded-lg border p-3 ${
entries.length > 0
? isError
? "border-[var(--admin-error-border)] bg-[var(--admin-error-subtle)]"
: "border-[var(--admin-warning-border)] bg-[var(--admin-warning-subtle)]"
: "border-[var(--admin-border)] bg-[var(--admin-surface)]"
}`}
className={`flex flex-wrap items-center gap-2 rounded-lg border p-3 ${entries.length > 0 ? toneClass : "border-[var(--admin-border)] bg-[var(--admin-surface)]"}`}
>
<Checkbox
style={{ display: "inline-flex", width: 18, height: 18 }}
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={allChecked}
disabled={entries.length === 0}
disabled={disabled || entries.length === 0}
onCheckedChange={() => onToggleAll(entries.map((e) => e.fileName))}
aria-label={`Select all ${label}`}
/>
@@ -111,6 +158,7 @@ function CleanupGroup({
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}`}
/>
@@ -120,6 +168,9 @@ function CleanupGroup({
<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}
@@ -144,31 +195,44 @@ function CleanupGroup({
export function NitroCleanupPanel() {
const [scanning, setScanning] = useState(false);
const [deleting, setDeleting] = useState(false);
const [repairing, setRepairing] = useState(false);
const [autoCleaning, setAutoCleaning] = useState(false);
const [autoAge, setAutoAge] = useState(30);
const [minAgeDays, setMinAgeDays] = useState(0);
const [onlyOld, setOnlyOld] = useState(false);
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 { 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 data = (await res.json()) as NitroCleanupScan & {
error?: string;
};
if (!res.ok) throw new Error(data.error || "Scan failed");
const fake = data.fake ?? [];
const broken = data.broken ?? [];
setScan({
fake,
broken,
fake: data.fake ?? [],
broken: data.broken ?? [],
orphanedSwf: data.orphanedSwf ?? [],
orphanedIcon: data.orphanedIcon ?? [],
total: data.total ?? 0,
});
const found = fake.length + broken.length;
const found =
data.fake.length +
data.broken.length +
data.orphanedSwf.length +
data.orphanedIcon.length;
if (found === 0) {
toast.success("No fake or broken .nitro files found");
toast.success("No orphaned or broken furniture assets found");
} else {
toast.info(`Found ${found} removable .nitro file(s)`);
toast.info(`Found ${found} removable asset file(s)`);
}
} catch (err) {
toast.error(err instanceof Error ? err.message : "Scan failed");
@@ -177,8 +241,14 @@ export function NitroCleanupPanel() {
}
}, []);
const toggle = (fileName: string) => {
setSelected((prev) => {
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);
@@ -186,8 +256,14 @@ export function NitroCleanupPanel() {
});
};
const toggleAll = (names: string[]) => {
setSelected((prev) => {
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) {
@@ -198,38 +274,61 @@ export function NitroCleanupPanel() {
});
};
const totalSelected = selected.size + selectedSwf.size + selectedIcon.size;
const removeSelected = async () => {
const fileNames = [...selected].sort();
if (fileNames.length === 0) return;
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 ${fileNames.length} .nitro file(s)?`,
title: `Delete ${totalSelected} asset file(s)?`,
description:
"The .nitro bundles will be removed from every directory (public + mirrored gamedata). Database rows are never touched.",
"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 {
const res = await adminFetch("/api/admin/studio/nitro-cleanup", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileNames }),
});
const data = (await res.json()) as DeleteResponse & {
error?: string;
};
if (!res.ok) throw new Error(data.error || "Delete failed");
if (data.errors?.length) {
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;
}
if (hadErrors) {
toast.warning(
`Deleted ${data.copiesRemoved} copy/copies, but some files could not be removed`,
`Deleted ${copiesRemoved} copy/copies, but some files could not be removed`,
);
} else {
toast.success(
`Deleted ${data.copiesRemoved} .nitro file copy/copies (${data.deleted} file(s))`,
`Deleted ${copiesRemoved} file copy/copies (${deleted} file(s))`,
);
}
setSelected(new Set());
setSelectedSwf(new Set());
setSelectedIcon(new Set());
await scanNow();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Delete failed");
@@ -238,23 +337,148 @@ export function NitroCleanupPanel() {
}
};
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)`);
}
setSelected(new Set());
await scanNow();
} 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.`,
);
} 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 {
setAutoCleaning(false);
}
};
const exportManifest = () => {
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");
};
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 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 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;
return (
<div className="space-y-4">
{dialog}
<div className="flex flex-wrap items-center gap-2">
<Button onClick={scanNow} disabled={scanning || deleting} size="lg">
<Button onClick={scanNow} disabled={busy} size="lg">
{scanning ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<ScanSearch className="w-4 h-4 mr-2" />
)}
{scanning ? "Scanning nitro files…" : "Scan for fake & broken nitros"}
{scanning ? "Scanning assets…" : "Scan furniture assets"}
</Button>
{scan && (
<Button
variant="destructive"
size="lg"
disabled={deleting || selected.size === 0}
disabled={busy || totalSelected === 0}
onClick={removeSelected}
>
{deleting ? (
@@ -262,18 +486,102 @@ export function NitroCleanupPanel() {
) : (
<Trash2 className="w-4 h-4 mr-2" />
)}
{deleting ? "Deleting…" : `Delete selected (${selected.size})`}
{deleting ? "Deleting…" : `Delete selected (${totalSelected})`}
</Button>
)}
{scan && scan.broken.length > 0 && (
<Button
size="lg"
disabled={
busy ||
![...selected].some((n) =>
scan.broken.some((e) => e.fileName === n),
)
}
onClick={repairSelected}
>
{repairing ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Wrench className="w-4 h-4 mr-2" />
)}
{repairing ? "Re-downloading…" : "Re-download broken"}
</Button>
)}
{scan && scan.fake.length > 0 && (
<>
<Button size="lg" disabled={busy} onClick={autoClean}>
{autoCleaning ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<Sparkles className="w-4 h-4 mr-2" />
)}
{autoCleaning ? "Cleaning…" : "Auto-clean old fakes"}
</Button>
<div className="flex items-center gap-1.5">
<Input
type="number"
min={0}
className="w-20"
value={autoAge}
disabled={busy}
onChange={(e) => setAutoAge(Number(e.target.value))}
aria-label="Minimum age in days for auto-clean"
/>
<span className="text-xs text-[var(--admin-text-muted)]">
days
</span>
</div>
</>
)}
{scan && (
<Button
variant="outline"
size="lg"
disabled={busy}
onClick={exportManifest}
>
<Download className="w-4 h-4 mr-2" />
Export manifest
</Button>
)}
</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">
<div className="flex items-center gap-2 text-sm text-[var(--admin-text-muted)]">
<ScrollText size={15} className="shrink-0" />
<span>Only show files older than</span>
</div>
<Input
type="number"
min={0}
className="w-20"
value={minAgeDays}
disabled={busy}
onChange={(e) => setMinAgeDays(Number(e.target.value))}
aria-label="Minimum age in days to display"
/>
<span className="text-xs text-[var(--admin-text-muted)]">days</span>
<label
htmlFor="nitro-hide-recent"
className="flex items-center gap-1.5 text-sm"
>
<Checkbox
id="nitro-hide-recent"
checked={onlyOld}
onCheckedChange={(value) => setOnlyOld(value === true)}
/>
<span>Hide recent files</span>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-xl border bg-card p-3 space-y-1">
<div className="text-xl font-bold">{scan.total}</div>
<div className="text-xs text-muted-foreground">
.nitro files scanned
asset files scanned
</div>
</div>
<div className="rounded-xl border bg-card p-3 space-y-1">
@@ -281,7 +589,7 @@ export function NitroCleanupPanel() {
{scan.fake.length}
</div>
<div className="text-xs text-[var(--admin-text-muted)]">
Fake (no matching item)
Fake nitros (no matching item)
</div>
</div>
<div className="rounded-xl border bg-card p-3 space-y-1">
@@ -289,28 +597,47 @@ export function NitroCleanupPanel() {
{scan.broken.length}
</div>
<div className="text-xs text-[var(--admin-text-muted)]">
Broken (unparseable)
Broken nitros (unparseable)
</div>
</div>
</div>
<CleanupGroup
group="fake"
label="Fake nitros"
description="These files match no item in items_base — leftovers from deleted or renamed furniture."
entries={scan.fake}
tone="error"
entries={visibleFake}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
onToggle={toggle("nitro")}
onToggleAll={toggleAll("nitro")}
/>
<CleanupGroup
group="broken"
label="Broken nitros"
description="These files belong to an item but cannot be parsed as a valid Nitro bundle."
entries={scan.broken}
tone="warning"
entries={onlyOld ? [] : scan.broken}
selected={selected}
onToggle={toggle}
onToggleAll={toggleAll}
onToggle={toggle("nitro")}
onToggleAll={toggleAll("nitro")}
disabled={onlyOld}
/>
<CleanupGroup
label="Orphaned SWFs"
description=".swf files that match no item in items_base."
tone="warning"
entries={visibleSwf}
selected={selectedSwf}
onToggle={toggle("swf")}
onToggleAll={toggleAll("swf")}
/>
<CleanupGroup
label="Orphaned icons"
description="Icon files that match no item in items_base."
tone="neutral"
entries={visibleIcon}
selected={selectedIcon}
onToggle={toggle("icon")}
onToggleAll={toggleAll("icon")}
/>
{(scan.fake.length > 0 || scan.broken.length > 0) && (
@@ -320,15 +647,15 @@ export function NitroCleanupPanel() {
className="mt-0.5 shrink-0 text-[var(--admin-warning)]"
/>
Deleting a broken .nitro leaves its item in the database without a
bundle — the catalog audit can re-download it later. Database rows
are never removed by this tool.
bundle — prefer re-downloading it, or let the catalog audit
re-obtain it later. Database rows are never removed by this tool.
</p>
)}
<div className="flex items-center gap-1.5 text-xs text-[var(--admin-text-muted)]">
<FileX2 size={14} className="shrink-0" />
Only .nitro files are touched — icons, SWFs, FurnitureData.json and
database rows stay intact.
Only asset files are touched — FurnitureData.json and database rows
stay intact.
</div>
</div>
)}
@@ -337,9 +664,9 @@ export function NitroCleanupPanel() {
<div className="rounded-xl border bg-card p-6 text-center text-muted-foreground">
<ScanSearch className="mx-auto mb-3 h-10 w-10 opacity-40" />
<p>
Scan every nitro directory to find fake bundles that belong to no
furniture and broken bundles that can no longer be parsed. Remove
them with one click.
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.
</p>
</div>
)}