feat(studio): expand nitro cleanup with repair, auto-clean, and orphaned assets
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:
1 parent
ea4fd375b4
commit
438277a17a
13 files changed
+1118
-155
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 { clearFurniImportCache } from "@/lib/services/furni-import-cache";
|
||||
import {
|
||||
type CleanupAssetKind,
|
||||
deleteNitroCleanupFiles,
|
||||
scanFakeBrokenNitros,
|
||||
} 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 () => {
|
||||
try {
|
||||
const scan = await scanFakeBrokenNitros();
|
||||
return apiOk({
|
||||
fake: scan.fake,
|
||||
broken: scan.broken,
|
||||
orphanedSwf: scan.orphanedSwf,
|
||||
orphanedIcon: scan.orphanedIcon,
|
||||
total: scan.total,
|
||||
});
|
||||
} 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(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
let body: { fileNames?: unknown };
|
||||
let body: { fileNames?: unknown; kind?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
@@ -37,20 +48,26 @@ export const DELETE = withAdmin(
|
||||
(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) {
|
||||
return apiError("fileNames[] is required", 400);
|
||||
}
|
||||
try {
|
||||
const result = await deleteNitroCleanupFiles(fileNames);
|
||||
// The deleted .nitro presence is cached by the Studio — drop it so
|
||||
const result = await deleteNitroCleanupFiles(fileNames, kind);
|
||||
// The deleted asset presence is cached by the Studio — drop it so
|
||||
// the furniture list reflects the removal immediately.
|
||||
clearFurniImportCache();
|
||||
if (result.deleted > 0) {
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "nitro_cleanup_delete",
|
||||
target: "NitroFiles",
|
||||
target: "AssetFiles",
|
||||
after: {
|
||||
kind,
|
||||
deleted: result.deleted,
|
||||
copiesRemoved: result.copiesRemoved,
|
||||
fileNames,
|
||||
@@ -64,7 +81,7 @@ export const DELETE = withAdmin(
|
||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||
});
|
||||
} 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.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: [],
|
||||
inboxSources: [],
|
||||
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);
|
||||
|
||||
expect(registry.domains.map((domain) => domain.id)).toEqual([
|
||||
@@ -572,9 +572,20 @@ describe("housekeeping foundation completion contracts", () => {
|
||||
"hotel",
|
||||
"system",
|
||||
]);
|
||||
expect(registry.domains.every((domain) => domain.routes.length === 0)).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
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", () => {
|
||||
|
||||
@@ -77,6 +77,7 @@ const expectedManifests = [
|
||||
labelKey: "pages.housekeeping.domains.operations.title",
|
||||
descriptionKey: "pages.housekeeping.domains.operations.description",
|
||||
slugs: [PERMS.ADMIN_DASHBOARD, PERMS.ASSETS_IMPORT],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
id: "people",
|
||||
@@ -108,6 +109,7 @@ const expectedManifests = [
|
||||
PERMS.MOD_CFH_EDIT,
|
||||
PERMS.MOD_TICKETS_EDIT,
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
id: "content",
|
||||
@@ -131,6 +133,7 @@ const expectedManifests = [
|
||||
PERMS.SETTINGS_VIEW,
|
||||
PERMS.SETTINGS_EDIT,
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
id: "economy",
|
||||
@@ -144,6 +147,7 @@ const expectedManifests = [
|
||||
PERMS.CATALOG_EDIT,
|
||||
PERMS.SHOP_EDIT,
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
{
|
||||
id: "hotel",
|
||||
@@ -161,6 +165,14 @@ const expectedManifests = [
|
||||
PERMS.PAGES_VIEW,
|
||||
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",
|
||||
@@ -180,6 +192,7 @@ const expectedManifests = [
|
||||
PERMS.SETTINGS_EDIT,
|
||||
PERMS.NOTIFICATIONS_EDIT,
|
||||
],
|
||||
routes: [],
|
||||
},
|
||||
] as const;
|
||||
describe("housekeeping registry", () => {
|
||||
@@ -324,7 +337,7 @@ describe("housekeeping registry", () => {
|
||||
labelKey: expected.labelKey,
|
||||
descriptionKey: expected.descriptionKey,
|
||||
});
|
||||
expect(actual.routes).toEqual([]);
|
||||
expect(actual.routes).toEqual(expected.routes);
|
||||
expect(actual.searchProviders).toEqual([]);
|
||||
expect(actual.inboxSources).toEqual([]);
|
||||
expect(actual.widgets).toEqual([]);
|
||||
|
||||
@@ -586,15 +586,21 @@ export const hotelMigrationEntries: readonly MigrationEntry[] = [
|
||||
dependencies: {
|
||||
queries: ["GET /api/admin/studio/nitro-cleanup", "scanFakeBrokenNitros"],
|
||||
mutations: [
|
||||
"autoCleanFakeNitros",
|
||||
"DELETE /api/admin/studio/nitro-cleanup",
|
||||
"clearFurniImportCache",
|
||||
"deleteNitroCleanupFiles",
|
||||
"POST /api/admin/studio/nitro-cleanup/auto-clean",
|
||||
"POST /api/admin/studio/nitro-cleanup/rebuild",
|
||||
"repairBrokenNitros",
|
||||
],
|
||||
},
|
||||
auditRequirement: "PRIVILEGED_MUTATION",
|
||||
localization: "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({
|
||||
surface: "admin",
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -4771,7 +4771,13 @@
|
||||
},
|
||||
"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": {
|
||||
"title": "System",
|
||||
|
||||
@@ -4770,7 +4770,13 @@
|
||||
},
|
||||
"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": {
|
||||
"title": "Sistema",
|
||||
|
||||
Reference in new issue
Block a user