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
+1091 -128

No files matched your search

@@ -0,0 +1,5 @@
import { NitroCleanupPanel } from "@/components/admin/studio/nitro-cleanup-panel";
export default function HousekeepingHotelNitroCleanupPage() {
return <NitroCleanupPanel />;
}
@@ -0,0 +1,54 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import { autoCleanFakeNitros } from "@/lib/services/nitro-cleanup";
/** Automatically remove fake .nitro leftovers older than `maxAgeDays`. */
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
let body: { maxAgeDays?: unknown };
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const maxAgeDays =
typeof body?.maxAgeDays === "number" && Number.isFinite(body.maxAgeDays)
? Math.max(0, Math.floor(body.maxAgeDays))
: 30;
try {
const result = await autoCleanFakeNitros(maxAgeDays);
clearFurniImportCache();
if (result.deleted > 0) {
logAudit({
userId: ctx.session.user.id,
action: "nitro_auto_cleanup",
target: "NitroFiles",
after: {
maxAgeDays,
deleted: result.deleted,
copiesRemoved: result.copiesRemoved,
skippedRecent: result.skippedRecent,
skippedBroken: result.skippedBroken,
errors: result.errors.length > 0 ? result.errors : undefined,
},
});
}
return apiOk({
deleted: result.deleted,
copiesRemoved: result.copiesRemoved,
skippedRecent: result.skippedRecent,
skippedBroken: result.skippedBroken,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (err) {
return apiError(
`Nitro auto-cleanup failed: ${(err as Error).message}`,
500,
);
}
},
);
@@ -0,0 +1,51 @@
import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import { repairBrokenNitros } from "@/lib/services/nitro-cleanup";
/** Re-download the given broken .nitro bundles from the configured sources. */
export const POST = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => {
let body: { fileNames?: unknown };
try {
body = await request.json();
} catch {
return apiError("Invalid JSON body", 400);
}
const fileNames = Array.isArray(body?.fileNames)
? body.fileNames.filter(
(name): name is string => typeof name === "string",
)
: [];
if (fileNames.length === 0) {
return apiError("fileNames[] is required", 400);
}
try {
const result = await repairBrokenNitros(fileNames);
clearFurniImportCache();
if (result.repaired > 0) {
logAudit({
userId: ctx.session.user.id,
action: "nitro_cleanup_repair",
target: "NitroFiles",
after: {
repaired: result.repaired,
failed: result.failed,
fileNames,
errors: result.errors.length > 0 ? result.errors : undefined,
},
});
}
return apiOk({
repaired: result.repaired,
failed: result.failed,
errors: result.errors.length > 0 ? result.errors : undefined,
});
} catch (err) {
return apiError(`Nitro repair failed: ${(err as Error).message}`, 500);
}
},
);
@@ -4,29 +4,40 @@ import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { clearFurniImportCache } from "@/lib/services/furni-import-cache"; import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
import { import {
type CleanupAssetKind,
deleteNitroCleanupFiles, deleteNitroCleanupFiles,
scanFakeBrokenNitros, scanFakeBrokenNitros,
} from "@/lib/services/nitro-cleanup"; } from "@/lib/services/nitro-cleanup";
/** Scan all nitro directories for fake (orphaned) and broken (unparseable) .nitro files. */ const VALID_KINDS: readonly CleanupAssetKind[] = ["nitro", "swf", "icon"];
/**
* Scan all asset directories for fake (orphaned) nitro / swf / icon files and
* broken (unparseable) .nitro bundles.
*/
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
try { try {
const scan = await scanFakeBrokenNitros(); const scan = await scanFakeBrokenNitros();
return apiOk({ return apiOk({
fake: scan.fake, fake: scan.fake,
broken: scan.broken, broken: scan.broken,
orphanedSwf: scan.orphanedSwf,
orphanedIcon: scan.orphanedIcon,
total: scan.total, total: scan.total,
}); });
} catch (err) { } catch (err) {
return apiError(`Nitro scan failed: ${(err as Error).message}`, 500); return apiError(`Asset scan failed: ${(err as Error).message}`, 500);
} }
}); });
/** Delete the given .nitro files from every nitro directory (primary + mirrors). */ /**
* Delete the given asset files (default .nitro) from every matching directory
* (public + mirrored gamedata).
*/
export const DELETE = withAdmin( export const DELETE = withAdmin(
{ permission: PERMS.ASSETS_IMPORT }, { permission: PERMS.ASSETS_IMPORT },
async (request, ctx) => { async (request, ctx) => {
let body: { fileNames?: unknown }; let body: { fileNames?: unknown; kind?: unknown };
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
@@ -37,20 +48,26 @@ export const DELETE = withAdmin(
(name): name is string => typeof name === "string", (name): name is string => typeof name === "string",
) )
: []; : [];
const kind: CleanupAssetKind = VALID_KINDS.includes(
body?.kind as CleanupAssetKind,
)
? (body.kind as CleanupAssetKind)
: "nitro";
if (fileNames.length === 0) { if (fileNames.length === 0) {
return apiError("fileNames[] is required", 400); return apiError("fileNames[] is required", 400);
} }
try { try {
const result = await deleteNitroCleanupFiles(fileNames); const result = await deleteNitroCleanupFiles(fileNames, kind);
// The deleted .nitro presence is cached by the Studio — drop it so // The deleted asset presence is cached by the Studio — drop it so
// the furniture list reflects the removal immediately. // the furniture list reflects the removal immediately.
clearFurniImportCache(); clearFurniImportCache();
if (result.deleted > 0) { if (result.deleted > 0) {
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
action: "nitro_cleanup_delete", action: "nitro_cleanup_delete",
target: "NitroFiles", target: "AssetFiles",
after: { after: {
kind,
deleted: result.deleted, deleted: result.deleted,
copiesRemoved: result.copiesRemoved, copiesRemoved: result.copiesRemoved,
fileNames, fileNames,
@@ -64,7 +81,7 @@ export const DELETE = withAdmin(
errors: result.errors.length > 0 ? result.errors : undefined, errors: result.errors.length > 0 ? result.errors : undefined,
}); });
} catch (err) { } catch (err) {
return apiError(`Nitro cleanup failed: ${(err as Error).message}`, 500); return apiError(`Asset cleanup failed: ${(err as Error).message}`, 500);
} }
}, },
); );
File diff suppressed because it is too large. Load diff
@@ -20,7 +20,14 @@ export const hotelManifest = {
PERMS.PAGES_VIEW, PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT, PERMS.CATALOG_EDIT,
), ),
routes: [], routes: [
{
id: "nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
href: "/admin-next/hotel/nitro-cleanup",
capability: anyCapability(PERMS.ASSETS_IMPORT),
},
],
searchProviders: [], searchProviders: [],
inboxSources: [], inboxSources: [],
widgets: [], widgets: [],
@@ -561,7 +561,7 @@ describe("housekeeping foundation completion contracts", () => {
} }
}); });
it("creates the real six-domain registry in locked order without workflows", () => { it("creates the real six-domain registry in locked order without approved workflows", () => {
const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS); const registry = createHousekeepingRegistry(HOUSEKEEPING_MANIFESTS);
expect(registry.domains.map((domain) => domain.id)).toEqual([ expect(registry.domains.map((domain) => domain.id)).toEqual([
@@ -572,9 +572,20 @@ describe("housekeeping foundation completion contracts", () => {
"hotel", "hotel",
"system", "system",
]); ]);
expect(registry.domains.every((domain) => domain.routes.length === 0)).toBe( expect(
true, registry.domains
); .filter((domain) => domain.id !== "hotel")
.every((domain) => domain.routes.length === 0),
).toBe(true);
expect(
registry.domains.find((domain) => domain.id === "hotel")?.routes,
).toEqual([
expect.objectContaining({
id: "nitro-cleanup",
href: "/admin-next/hotel/nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
}),
]);
}); });
it("keeps production preview disabled even when the flag is true", () => { it("keeps production preview disabled even when the flag is true", () => {
@@ -77,6 +77,7 @@ const expectedManifests = [
labelKey: "pages.housekeeping.domains.operations.title", labelKey: "pages.housekeeping.domains.operations.title",
descriptionKey: "pages.housekeeping.domains.operations.description", descriptionKey: "pages.housekeeping.domains.operations.description",
slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT], slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
routes: [],
}, },
{ {
id: "people", id: "people",
@@ -108,6 +109,7 @@ const expectedManifests = [
PERMS.MOD_CFH_EDIT, PERMS.MOD_CFH_EDIT,
PERMS.MOD_TICKETS_EDIT, PERMS.MOD_TICKETS_EDIT,
], ],
routes: [],
}, },
{ {
id: "content", id: "content",
@@ -131,6 +133,7 @@ const expectedManifests = [
PERMS.SETTINGS_VIEW, PERMS.SETTINGS_VIEW,
PERMS.SETTINGS_EDIT, PERMS.SETTINGS_EDIT,
], ],
routes: [],
}, },
{ {
id: "economy", id: "economy",
@@ -144,6 +147,7 @@ const expectedManifests = [
PERMS.CATALOG_EDIT, PERMS.CATALOG_EDIT,
PERMS.SHOP_EDIT, PERMS.SHOP_EDIT,
], ],
routes: [],
}, },
{ {
id: "hotel", id: "hotel",
@@ -161,6 +165,14 @@ const expectedManifests = [
PERMS.PAGES_VIEW, PERMS.PAGES_VIEW,
PERMS.CATALOG_EDIT, PERMS.CATALOG_EDIT,
], ],
routes: [
{
id: "nitro-cleanup",
labelKey: "pages.housekeeping.domains.hotel.routes.nitroCleanup",
href: "/admin-next/hotel/nitro-cleanup",
capability: { mode: "any", slugs: [PERMS.ASSETS_IMPORT] },
},
],
}, },
{ {
id: "system", id: "system",
@@ -180,6 +192,7 @@ const expectedManifests = [
PERMS.SETTINGS_EDIT, PERMS.SETTINGS_EDIT,
PERMS.NOTIFICATIONS_EDIT, PERMS.NOTIFICATIONS_EDIT,
], ],
routes: [],
}, },
] as const; ] as const;
describe("housekeeping registry", () => { describe("housekeeping registry", () => {
@@ -324,7 +337,7 @@ describe("housekeeping registry", () => {
labelKey: expected.labelKey, labelKey: expected.labelKey,
descriptionKey: expected.descriptionKey, descriptionKey: expected.descriptionKey,
}); });
expect(actual.routes).toEqual([]); expect(actual.routes).toEqual(expected.routes);
expect(actual.searchProviders).toEqual([]); expect(actual.searchProviders).toEqual([]);
expect(actual.inboxSources).toEqual([]); expect(actual.inboxSources).toEqual([]);
expect(actual.widgets).toEqual([]); expect(actual.widgets).toEqual([]);
+7 -1
View File
@@ -586,15 +586,21 @@ export const hotelMigrationEntries: readonly MigrationEntry[] = [
dependencies: { dependencies: {
queries: ["GET /api/admin/studio/nitro-cleanup", "scanFakeBrokenNitros"], queries: ["GET /api/admin/studio/nitro-cleanup", "scanFakeBrokenNitros"],
mutations: [ mutations: [
"autoCleanFakeNitros",
"DELETE /api/admin/studio/nitro-cleanup", "DELETE /api/admin/studio/nitro-cleanup",
"clearFurniImportCache", "clearFurniImportCache",
"deleteNitroCleanupFiles", "deleteNitroCleanupFiles",
"POST /api/admin/studio/nitro-cleanup/auto-clean",
"POST /api/admin/studio/nitro-cleanup/rebuild",
"repairBrokenNitros",
], ],
}, },
auditRequirement: "PRIVILEGED_MUTATION", auditRequirement: "PRIVILEGED_MUTATION",
localization: "PARTIAL", localization: "PARTIAL",
accessibility: "PARTIAL", accessibility: "PARTIAL",
notes: ["Deletes orphaned or unparseable nitro bundles from asset files"], notes: [
"Deletes orphaned or unparseable asset files and re-downloads broken nitro bundles from configured sources",
],
}), }),
plannedHotelEntry({ plannedHotelEntry({
surface: "admin", surface: "admin",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+7 -1
View File
@@ -4771,7 +4771,13 @@
}, },
"hotel": { "hotel": {
"title": "Hotel", "title": "Hotel",
"description": "Rooms, radio, and asset tools" "description": "Rooms, radio, and asset tools",
"routes": {
"nitroCleanup": {
"title": "Nitro cleanup",
"description": "Scan and remove fake, broken, and orphaned furni asset files"
}
}
}, },
"system": { "system": {
"title": "System", "title": "System",
+7 -1
View File
@@ -4770,7 +4770,13 @@
}, },
"hotel": { "hotel": {
"title": "Hotel", "title": "Hotel",
"description": "Stanze, radio e strumenti per gli asset" "description": "Stanze, radio e strumenti per gli asset",
"routes": {
"nitroCleanup": {
"title": "Pulizia nitro",
"description": "Scansiona e rimuovi i file asset furni falsi, danneggiati o orfani"
}
}
}, },
"system": { "system": {
"title": "Sistema", "title": "Sistema",