feat(studio): run nitro scans in the background with cancel-re-attach and nightly auto-clean
This commit is contained in:
1 parent
9d571e0c29
commit
6c53f4680c
14 files changed
+1547
-303
No files matched your search
@@ -3,7 +3,10 @@ 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";
|
||||
import {
|
||||
autoCleanFakeNitros,
|
||||
finishAutoClean,
|
||||
} from "@/lib/services/nitro-cleanup";
|
||||
|
||||
/** Automatically remove fake .nitro leftovers older than `maxAgeDays`. */
|
||||
export const POST = withAdmin(
|
||||
@@ -21,6 +24,11 @@ export const POST = withAdmin(
|
||||
: 30;
|
||||
try {
|
||||
const result = await autoCleanFakeNitros(maxAgeDays);
|
||||
await finishAutoClean({
|
||||
result,
|
||||
maxAgeDays,
|
||||
triggeredBy: "manual",
|
||||
});
|
||||
clearFurniImportCache();
|
||||
if (result.deleted > 0) {
|
||||
logAudit({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { cancelScanSession } from "@/lib/services/nitro-scan-session";
|
||||
|
||||
/**
|
||||
* Abort the running background scan (in-process, or by marking the session
|
||||
* cancelled when the process that owns it is not the one serving the request).
|
||||
*/
|
||||
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const session = await cancelScanSession();
|
||||
return apiOk({ session });
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import { apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { readAutoCleanHistory } from "@/lib/services/nitro-cleanup";
|
||||
|
||||
/** Auto-clean history (manual + nightly scheduled runs), newest first. */
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const history = await readAutoCleanHistory();
|
||||
return apiOk({ history });
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { logAudit } from "@/lib/services/audit";
|
||||
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
|
||||
import {
|
||||
type NitroRepairProgress,
|
||||
refreshScanCacheAfterMutation,
|
||||
repairBrokenNitros,
|
||||
} from "@/lib/services/nitro-cleanup";
|
||||
|
||||
@@ -54,7 +55,15 @@ export const POST = withAdmin(
|
||||
repaired: number;
|
||||
failed: number;
|
||||
errors: string[];
|
||||
files: Array<{ fileName: string; ok: boolean }>;
|
||||
}) => {
|
||||
// Repaired bundles are no longer broken — drop them from the cached
|
||||
// scan (names/digests unchanged, so the cache otherwise stays valid).
|
||||
await refreshScanCacheAfterMutation(
|
||||
"nitro",
|
||||
result.files.filter((file) => file.ok).map((file) => file.fileName),
|
||||
0,
|
||||
);
|
||||
clearFurniImportCache();
|
||||
if (result.repaired > 0) {
|
||||
logAudit({
|
||||
|
||||
@@ -4,14 +4,18 @@ import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import { clearFurniImportCache } from "@/lib/services/furni-import-cache";
|
||||
import {
|
||||
type AssetCleanupEntry,
|
||||
ageDays,
|
||||
type CleanupAssetKind,
|
||||
type CleanupProgress,
|
||||
deleteNitroCleanupFiles,
|
||||
scanFakeBrokenNitros,
|
||||
getPersistedCleanupResult,
|
||||
refreshScanCacheAfterMutation,
|
||||
} from "@/lib/services/nitro-cleanup";
|
||||
|
||||
const VALID_KINDS: readonly CleanupAssetKind[] = ["nitro", "swf", "icon"];
|
||||
const GROUP_ORDER = ["fake", "broken", "orphanedSwf", "orphanedIcon"] as const;
|
||||
type GroupKey = (typeof GROUP_ORDER)[number];
|
||||
const CHUNK_SIZE = 400;
|
||||
|
||||
function intParam(value: string | null): number | null {
|
||||
if (value === null) return null;
|
||||
@@ -19,19 +23,26 @@ function intParam(value: string | null): number | null {
|
||||
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
||||
}
|
||||
|
||||
function ageFilter<T extends { lastModified: number }>(
|
||||
entries: T[],
|
||||
minAgeDays: number | null,
|
||||
): T[] {
|
||||
return minAgeDays !== null && minAgeDays > 0
|
||||
? entries.filter((entry) => ageDays(entry.lastModified) >= minAgeDays)
|
||||
: entries;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all asset directories for fake (orphaned) nitro / swf / icon files and
|
||||
* broken (unparseable) .nitro bundles.
|
||||
* Serve the persisted scan result (written by the background scan started
|
||||
* through POST `/run`).
|
||||
*
|
||||
* With `Accept: text/event-stream` the lists are streamed in bounded chunks
|
||||
* (`summary` + `chunk` events) so a result with hundreds of thousands of
|
||||
* entries is delivered without one giant JSON payload. With a plain Accept the
|
||||
* full result is returned as JSON, which is also how the manifest export works.
|
||||
*
|
||||
* Query params:
|
||||
* - `force=1` skips the on-disk scan cache (fresh re-validation);
|
||||
* - `minAgeDays=N` only returns entries older than N days;
|
||||
* - `limit=N` caps each returned list (use `full=1` to override);
|
||||
* - `full=1` never applies the limit (used by the manifest export).
|
||||
*
|
||||
* When the client asks for `text/event-stream` the scan is streamed as
|
||||
* `progress` / `result` / `error` events, keeping the connection alive while a
|
||||
* large nitro directory is walked so reverse proxies don't time the request out.
|
||||
* - `minAgeDays=N` (SSE + JSON) only returns entries older than N days.
|
||||
*/
|
||||
export const GET = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
@@ -39,77 +50,101 @@ export const GET = withAdmin(
|
||||
const wantsSse = request.headers
|
||||
.get("accept")
|
||||
?.includes("text/event-stream");
|
||||
const force = request.nextUrl.searchParams.get("force") === "1";
|
||||
const full = request.nextUrl.searchParams.get("full") === "1";
|
||||
const minAgeDays = intParam(request.nextUrl.searchParams.get("minAgeDays"));
|
||||
const limit = intParam(request.nextUrl.searchParams.get("limit"));
|
||||
|
||||
const run = async (onProgress?: (p: CleanupProgress) => void) => {
|
||||
const scan = await scanFakeBrokenNitros({ force, onProgress });
|
||||
const ageFilter = <T extends { lastModified: number }>(
|
||||
entries: T[],
|
||||
): T[] =>
|
||||
minAgeDays !== null && minAgeDays > 0
|
||||
? entries.filter((entry) => ageDays(entry.lastModified) >= minAgeDays)
|
||||
: entries;
|
||||
const cap = <T>(entries: T[]): T[] =>
|
||||
full || limit === null || limit <= 0
|
||||
? entries
|
||||
: entries.slice(0, limit);
|
||||
return {
|
||||
fake: cap(ageFilter(scan.fake)),
|
||||
broken: cap(ageFilter(scan.broken)),
|
||||
orphanedSwf: cap(ageFilter(scan.orphanedSwf)),
|
||||
orphanedIcon: cap(ageFilter(scan.orphanedIcon)),
|
||||
total: scan.total,
|
||||
cached: scan.cached ?? false,
|
||||
};
|
||||
};
|
||||
|
||||
try {
|
||||
if (!wantsSse) {
|
||||
return apiOk(await run());
|
||||
}
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
const persisted = await getPersistedCleanupResult();
|
||||
if (wantsSse) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const send = (data: unknown) => {
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
}
|
||||
};
|
||||
try {
|
||||
controller.enqueue(
|
||||
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
|
||||
);
|
||||
} catch {
|
||||
/* stream closed by client */
|
||||
if (!persisted) {
|
||||
send({
|
||||
type: "error",
|
||||
message:
|
||||
"No scan result yet — run a furniture asset scan first.",
|
||||
});
|
||||
} else {
|
||||
const groups: Partial<Record<GroupKey, AssetCleanupEntry[]>> = {
|
||||
fake: ageFilter(persisted.fake, minAgeDays),
|
||||
broken: ageFilter(persisted.broken, minAgeDays),
|
||||
orphanedSwf: ageFilter(persisted.orphanedSwf, minAgeDays),
|
||||
orphanedIcon: ageFilter(persisted.orphanedIcon, minAgeDays),
|
||||
};
|
||||
send({
|
||||
type: "summary",
|
||||
total: persisted.total,
|
||||
cached: persisted.cached ?? false,
|
||||
minAgeDays: minAgeDays ?? 0,
|
||||
groups: Object.fromEntries(
|
||||
Object.entries(groups).map(([key, value]) => [
|
||||
key,
|
||||
(value ?? []).length,
|
||||
]),
|
||||
),
|
||||
});
|
||||
for (const group of GROUP_ORDER) {
|
||||
const entries = groups[group] ?? [];
|
||||
for (
|
||||
let offset = 0;
|
||||
offset < entries.length;
|
||||
offset += CHUNK_SIZE
|
||||
) {
|
||||
send({
|
||||
type: "chunk",
|
||||
group,
|
||||
offset,
|
||||
entries: entries.slice(offset, offset + CHUNK_SIZE),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Failed to read scan result: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
};
|
||||
try {
|
||||
const result = await run((progress) =>
|
||||
send({ type: "progress", ...progress }),
|
||||
);
|
||||
send({ type: "result", ...result });
|
||||
} catch (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Asset scan failed: ${(err as Error).message}`,
|
||||
});
|
||||
}
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
try {
|
||||
controller.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (!persisted) {
|
||||
return apiError(
|
||||
"No scan result yet — run a furniture asset scan first.",
|
||||
404,
|
||||
);
|
||||
}
|
||||
return apiOk({
|
||||
...persisted,
|
||||
minAgeDays: minAgeDays ?? 0,
|
||||
cached: persisted.cached ?? false,
|
||||
});
|
||||
} catch (err) {
|
||||
return apiError(`Asset scan failed: ${(err as Error).message}`, 500);
|
||||
return apiError(
|
||||
`Failed to read scan result: ${(err as Error).message}`,
|
||||
500,
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -142,8 +177,16 @@ export const DELETE = withAdmin(
|
||||
}
|
||||
try {
|
||||
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.
|
||||
// Keep the persisted scan and the furniture list consistent without
|
||||
// re-scanning: drop the removed files from the scan cache and clear
|
||||
// the furniture presence cache so the Studio reflects it immediately.
|
||||
await refreshScanCacheAfterMutation(
|
||||
kind,
|
||||
result.files
|
||||
.filter((file) => file.deleted)
|
||||
.map((file) => file.fileName),
|
||||
result.deleted,
|
||||
);
|
||||
clearFurniImportCache();
|
||||
if (result.deleted > 0) {
|
||||
logAudit({
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { apiError, apiJson, apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { logAudit } from "@/lib/services/audit";
|
||||
import {
|
||||
NitroScanConflictError,
|
||||
runScanInBackground,
|
||||
} from "@/lib/services/nitro-scan-session";
|
||||
|
||||
/**
|
||||
* Start (or re-attach to) the furniture asset scan in the background. The scan
|
||||
* keeps running detached in the server process once this returns, so closing
|
||||
* the tab does not lose it. Clients poll GET `/session` for progress and fetch
|
||||
* the result through GET `/data`.
|
||||
*
|
||||
* @returns 200 with the started session, or 409 with `conflict: true` and the
|
||||
* in-flight session when a fresh scan is already running (<= the 60s stale
|
||||
* window) — the client should attach to that instead of starting a new one.
|
||||
*/
|
||||
export const POST = withAdmin(
|
||||
{ permission: PERMS.ASSETS_IMPORT },
|
||||
async (request, ctx) => {
|
||||
let body: { force?: unknown };
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
body = {};
|
||||
}
|
||||
const force = body?.force === true;
|
||||
try {
|
||||
const session = await runScanInBackground({ force });
|
||||
logAudit({
|
||||
userId: ctx.session.user.id,
|
||||
action: "nitro_cleanup_scan",
|
||||
target: "AssetFiles",
|
||||
after: { force, sessionId: session.id },
|
||||
});
|
||||
return apiOk({ session });
|
||||
} catch (err) {
|
||||
if (err instanceof NitroScanConflictError) {
|
||||
return apiJson(
|
||||
{ ok: true, session: err.session, conflict: true },
|
||||
{ status: 409 },
|
||||
);
|
||||
}
|
||||
return apiError(`Failed to start scan: ${(err as Error).message}`, 500);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,14 @@
|
||||
import { apiOk } from "@/lib/api";
|
||||
import { withAdmin } from "@/lib/api-handler";
|
||||
import { PERMS } from "@/lib/permissions";
|
||||
import { readScanSession } from "@/lib/services/nitro-scan-session";
|
||||
|
||||
/**
|
||||
* Current scan session (or null when no scan ran/stored yet). Polled by the
|
||||
* Studio panel while a background scan is running so a closed-tab scan can be
|
||||
* re-attached after a refresh.
|
||||
*/
|
||||
export const GET = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
|
||||
const session = await readScanSession();
|
||||
return apiOk({ session });
|
||||
});
|
||||
Reference in new issue
Block a user