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
@@ -19,6 +19,7 @@ import {
|
||||
parseDfOutput,
|
||||
} from "../src/lib/services/disk-usage";
|
||||
import { publishDueArticles } from "../src/lib/services/news-scheduler";
|
||||
import { scheduledAutoCleanFakeNitros } from "../src/lib/services/nitro-cleanup";
|
||||
import { rcon } from "../src/lib/services/rcon";
|
||||
|
||||
function captureWorkerError(err: unknown, context: string): void {
|
||||
@@ -353,6 +354,27 @@ async function publishScheduledArticles(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Nightly self-cleaning of old fake .nitro leftovers (skips when a scan is
|
||||
* running in the studio). Requires the `items_base` truth list, so a DB outage
|
||||
* shows up here as a captured error instead of deleting anything. */
|
||||
async function autoCleanNitroFakes(): Promise<void> {
|
||||
try {
|
||||
const result = await scheduledAutoCleanFakeNitros(30);
|
||||
if (result.skipped) {
|
||||
logger.info("Skipped nightly nitro auto-clean: scan in progress", {
|
||||
module: "jobs",
|
||||
});
|
||||
} else {
|
||||
logger.info("Nightly nitro auto-clean finished", {
|
||||
module: "jobs",
|
||||
deleted: result.deleted,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
captureWorkerError(err, "Nitro auto-clean failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function reportWorkerHeartbeat(): Promise<void> {
|
||||
if (!redis) return;
|
||||
await redis
|
||||
@@ -433,6 +455,15 @@ async function main() {
|
||||
void reportWorkerHeartbeat();
|
||||
});
|
||||
await reportWorkerHeartbeat();
|
||||
|
||||
new Cron("0 2 * * *", () => {
|
||||
autoCleanNitroFakes().catch((e) =>
|
||||
captureWorkerError(e, "Nitro auto-clean error"),
|
||||
);
|
||||
});
|
||||
logger.info("Scheduled: nitro auto-clean (daily 02:00)", {
|
||||
module: "jobs",
|
||||
});
|
||||
await Promise.all([
|
||||
backupEmulatorJar(),
|
||||
cleanupOldLogs(),
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -88,10 +88,20 @@ vi.mock("node:fs", async (orig) => {
|
||||
import {
|
||||
autoCleanFakeNitros,
|
||||
deleteNitroCleanupFiles,
|
||||
getPersistedCleanupResult,
|
||||
type NitroRepairProgress,
|
||||
refreshScanCacheAfterMutation,
|
||||
repairBrokenNitros,
|
||||
scanFakeBrokenNitros,
|
||||
} from "@/lib/services/nitro-cleanup";
|
||||
import {
|
||||
cancelActiveScan,
|
||||
cancelScanSession,
|
||||
NitroScanConflictError,
|
||||
readScanSession,
|
||||
runScanInBackground,
|
||||
writeScanSession,
|
||||
} from "@/lib/services/nitro-scan-session";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -128,6 +138,49 @@ beforeEach(() => {
|
||||
statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 }));
|
||||
});
|
||||
|
||||
/**
|
||||
* File-backed state for the mocked `node:fs` promises: cache/session/history
|
||||
* writes land in the map and subsequent reads/exists checks consult it, while
|
||||
* binary reads (nitro bundles) fall back to placeholder bytes.
|
||||
*/
|
||||
function installFileState(
|
||||
initial: Record<string, string> = {},
|
||||
): Map<string, string> {
|
||||
const files = new Map<string, string>(Object.entries(initial));
|
||||
readFileFn.mockImplementation(async (filePath: string) => {
|
||||
const stored = files.get(filePath);
|
||||
if (stored !== undefined) return stored;
|
||||
return Buffer.from("nitro-data");
|
||||
});
|
||||
writeFileFn.mockImplementation(async (filePath: string, data: unknown) => {
|
||||
files.set(
|
||||
filePath,
|
||||
Buffer.isBuffer(data) ? data.toString("utf-8") : String(data),
|
||||
);
|
||||
});
|
||||
existsFn.mockImplementation((filePath: string) => files.has(filePath));
|
||||
return files;
|
||||
}
|
||||
|
||||
async function waitForSessionState(
|
||||
expected: string,
|
||||
timeoutMs = 2000,
|
||||
): Promise<Awaited<ReturnType<typeof readScanSession>>> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let session = await readScanSession();
|
||||
while ((session?.state ?? null) !== expected && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
session = await readScanSession();
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Let a detached scan from the previous test settle before the next one. */
|
||||
async function settleDetachedScans(): Promise<void> {
|
||||
cancelActiveScan();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
|
||||
function mockReaddir(byDir: Record<string, string[]>): void {
|
||||
readdirFn.mockImplementation(async (dir: string) => byDir[dir] ?? []);
|
||||
}
|
||||
@@ -429,6 +482,166 @@ describe("autoCleanFakeNitros", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshScanCacheAfterMutation", () => {
|
||||
it("drops removed files and recomputes digests so the cache stays valid", async () => {
|
||||
const dirs = {
|
||||
"nitro\u0000/assets/nitro": dirSignature(["chair.nitro", "ghost.nitro"]),
|
||||
"swf\u0000/assets/swf": dirSignature([]),
|
||||
"icon\u0000/assets/icons": dirSignature([]),
|
||||
};
|
||||
const files = installFileState({
|
||||
"/var/www/atom-nexst/storage/nitro-cleanup/scan-cache.json":
|
||||
JSON.stringify({
|
||||
dirs,
|
||||
result: {
|
||||
fake: [
|
||||
{
|
||||
fileName: "ghost.nitro",
|
||||
base: "ghost",
|
||||
size: 500,
|
||||
dirs: ["/assets/nitro"],
|
||||
lastModified: 0,
|
||||
},
|
||||
],
|
||||
broken: [],
|
||||
orphanedSwf: [],
|
||||
orphanedIcon: [],
|
||||
total: 2,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// The directory changed after the cached scan (ghost.nitro deleted).
|
||||
readdirFn.mockImplementation(async (dir: string) =>
|
||||
dir.endsWith("/nitro") ? ["chair.nitro"] : [],
|
||||
);
|
||||
|
||||
await refreshScanCacheAfterMutation("nitro", ["ghost.nitro"], 1);
|
||||
|
||||
const written = JSON.parse(
|
||||
files.get(
|
||||
"/var/www/atom-nexst/storage/nitro-cleanup/scan-cache.json",
|
||||
) as string,
|
||||
) as {
|
||||
dirs: Record<string, string>;
|
||||
result: {
|
||||
fake: Array<{ fileName: string }>;
|
||||
broken: unknown[];
|
||||
orphanedSwf: unknown[];
|
||||
orphanedIcon: unknown[];
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
expect(written.dirs["nitro\u0000/assets/nitro"]).toBe(
|
||||
dirSignature(["chair.nitro"]),
|
||||
);
|
||||
expect(written.result.fake).toEqual([]);
|
||||
expect(written.result.total).toBe(1);
|
||||
// The persisted result is now the refreshed one.
|
||||
await expect(getPersistedCleanupResult()).resolves.toMatchObject({
|
||||
fake: [],
|
||||
total: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("is a no-op when the file names list is empty or no cache exists", async () => {
|
||||
installFileState({});
|
||||
await refreshScanCacheAfterMutation("nitro", [], 0);
|
||||
installFileState({});
|
||||
await refreshScanCacheAfterMutation("nitro", ["ghost.nitro"], 1);
|
||||
expect(writeFileFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scan aborting", () => {
|
||||
it("throws an AbortError and skips the scan cache write when aborted", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
|
||||
await expect(
|
||||
scanFakeBrokenNitros({ signal: controller.signal }),
|
||||
).rejects.toMatchObject({ name: "AbortError" });
|
||||
expect(writeFileFn).not.toHaveBeenCalled();
|
||||
expect(readFileFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("nitro-scan-session", () => {
|
||||
it("runs a scan in the background and persists the done session", async () => {
|
||||
await settleDetachedScans();
|
||||
installFileState({});
|
||||
readdirFn.mockImplementation(async (dir: string) =>
|
||||
dir.endsWith("/nitro") ? ["chair.nitro"] : [],
|
||||
);
|
||||
parseNitroBundle.mockReturnValue({});
|
||||
statFn.mockImplementation(async () => ({ size: 512, mtimeMs: 0 }));
|
||||
|
||||
const session = await runScanInBackground({ force: true });
|
||||
|
||||
expect(session.id).toBeTruthy();
|
||||
const done = await waitForSessionState("done");
|
||||
expect(done?.id).toBe(session.id);
|
||||
expect(done?.total).toBe(1);
|
||||
expect(done?.cached).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a second scan while a fresh one is running (single-flight)", async () => {
|
||||
await settleDetachedScans();
|
||||
installFileState({});
|
||||
readdirFn.mockImplementation(async (dir: string) =>
|
||||
dir.endsWith("/nitro") ? ["chair.nitro"] : [],
|
||||
);
|
||||
parseNitroBundle.mockReturnValue({});
|
||||
|
||||
await runScanInBackground({ force: false });
|
||||
await expect(runScanInBackground({ force: false })).rejects.toBeInstanceOf(
|
||||
NitroScanConflictError,
|
||||
);
|
||||
|
||||
await settleDetachedScans();
|
||||
});
|
||||
|
||||
it("lets a stale running session be taken over (crashed process recovery)", async () => {
|
||||
await settleDetachedScans();
|
||||
installFileState({});
|
||||
readdirFn.mockImplementation(async (dir: string) =>
|
||||
dir.endsWith("/nitro") ? ["chair.nitro"] : [],
|
||||
);
|
||||
parseNitroBundle.mockReturnValue({});
|
||||
const staleHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
await writeScanSession({
|
||||
id: "stale-session",
|
||||
state: "running",
|
||||
startedAt: staleHourAgo,
|
||||
updatedAt: staleHourAgo,
|
||||
force: false,
|
||||
});
|
||||
|
||||
const session = await runScanInBackground({ force: false });
|
||||
|
||||
expect(session.id).not.toBe("stale-session");
|
||||
await expect(waitForSessionState("done")).resolves.toMatchObject({
|
||||
id: session.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks the session cancelled when the scan is aborted", async () => {
|
||||
await settleDetachedScans();
|
||||
installFileState({});
|
||||
readdirFn.mockImplementation(async (dir: string) =>
|
||||
dir.endsWith("/nitro") ? ["chair.nitro"] : [],
|
||||
);
|
||||
parseNitroBundle.mockReturnValue({});
|
||||
|
||||
const session = await runScanInBackground({ force: false });
|
||||
const cancelled = await cancelScanSession();
|
||||
|
||||
expect(cancelled?.state).toBe("cancelled");
|
||||
await expect(waitForSessionState("cancelled")).resolves.toMatchObject({
|
||||
id: session.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("repairBrokenNitros", () => {
|
||||
it("re-downloads valid bundles and writes them to every nitro dir", async () => {
|
||||
getTargetsFn.mockResolvedValue({
|
||||
|
||||
@@ -74,6 +74,12 @@ export interface CleanupScanOptions {
|
||||
* leaving the tab looking frozen while a large directory is processed.
|
||||
*/
|
||||
onProgress?: (progress: CleanupProgress) => void;
|
||||
/**
|
||||
* Aborts the scan. Every CPU-bound stage (readdir, stat pool, header
|
||||
* validation) checks the signal and throws an `AbortError`-named error, so a
|
||||
* cancelled scan never writes a partial result to the on-disk cache.
|
||||
*/
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
export interface NitroCleanupDeleteResult {
|
||||
@@ -130,6 +136,16 @@ const KIND_FILE_RE: Record<CleanupAssetKind, RegExp> = {
|
||||
|
||||
const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
function abortSignalReason(): Error {
|
||||
const error = new Error("Scan cancelled");
|
||||
error.name = "AbortError";
|
||||
return error;
|
||||
}
|
||||
|
||||
function throwIfAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) throw abortSignalReason();
|
||||
}
|
||||
|
||||
function uniqueDirs(dirs: string[]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
@@ -249,9 +265,11 @@ const DIR_STAT_CONCURRENCY = 32;
|
||||
async function buildDirEntries(
|
||||
dir: string,
|
||||
names: string[],
|
||||
signal?: AbortSignal,
|
||||
): Promise<DirectoryRead[]> {
|
||||
const entries: DirectoryRead[] = [];
|
||||
await runPool(names, DIR_STAT_CONCURRENCY, async (name) => {
|
||||
throwIfAborted(signal);
|
||||
let size = 0;
|
||||
let mtimeMs = 0;
|
||||
try {
|
||||
@@ -275,11 +293,13 @@ async function collectAssets(
|
||||
kind: CleanupAssetKind,
|
||||
dirs: string[],
|
||||
namesByKey: Map<string, string[]>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<Map<string, AssetCleanupEntry>> {
|
||||
const byName = new Map<string, AssetCleanupEntry>();
|
||||
for (const dir of dirs) {
|
||||
throwIfAborted(signal);
|
||||
const names = namesByKey.get(kindKey(kind, dir)) ?? [];
|
||||
for (const read of await buildDirEntries(dir, names)) {
|
||||
for (const read of await buildDirEntries(dir, names, signal)) {
|
||||
const entry = byName.get(read.name) ?? {
|
||||
fileName: read.name,
|
||||
base: read.base,
|
||||
@@ -355,7 +375,7 @@ function cacheDirsMatch(
|
||||
export async function scanFakeBrokenNitros(
|
||||
options: CleanupScanOptions = {},
|
||||
): Promise<NitroCleanupScan> {
|
||||
const { force = false, onProgress } = options;
|
||||
const { force = false, onProgress, signal } = options;
|
||||
const targets = await getFurniAssetWriteTargets();
|
||||
const nitroDirs = uniqueDirs([
|
||||
targets.nitroDir,
|
||||
@@ -386,6 +406,7 @@ export async function scanFakeBrokenNitros(
|
||||
await Promise.all(
|
||||
directoryPlan.flatMap(({ kind, dirs }) =>
|
||||
dirs.map(async (dir) => {
|
||||
throwIfAborted(signal);
|
||||
namesByKey.set(
|
||||
kindKey(kind, dir),
|
||||
await readDirNames(dir, KIND_FILE_RE[kind]),
|
||||
@@ -398,6 +419,7 @@ export async function scanFakeBrokenNitros(
|
||||
currentDigests[key] = dirNameSignature(namesByKey.get(key) ?? []);
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
onProgress?.({ phase: "readdir", scanned: 0 });
|
||||
if (!force) {
|
||||
const cache = await readScanCache();
|
||||
@@ -407,14 +429,15 @@ export async function scanFakeBrokenNitros(
|
||||
}
|
||||
}
|
||||
|
||||
throwIfAborted(signal);
|
||||
// Needed only for classification — skip the DB query on a cache hit.
|
||||
onProgress?.({ phase: "stems", scanned: 0 });
|
||||
const validStems = await getCleanupValidStems();
|
||||
|
||||
const [byNitro, bySwf, byIcon] = await Promise.all([
|
||||
collectAssets("nitro", nitroDirs, namesByKey),
|
||||
collectAssets("swf", swfDirs, namesByKey),
|
||||
collectAssets("icon", iconDirs, namesByKey),
|
||||
collectAssets("nitro", nitroDirs, namesByKey, signal),
|
||||
collectAssets("swf", swfDirs, namesByKey, signal),
|
||||
collectAssets("icon", iconDirs, namesByKey, signal),
|
||||
]);
|
||||
|
||||
const entries: NitroCleanupEntry[] = [...byNitro.values()];
|
||||
@@ -424,6 +447,7 @@ export async function scanFakeBrokenNitros(
|
||||
const totalToValidate = entries.length;
|
||||
|
||||
await runPool(entries, 16, async (entry) => {
|
||||
throwIfAborted(signal);
|
||||
// A .nitro that no DB item maps to is a leftover / fake bundle.
|
||||
if (!validStems.has(entry.base.toLowerCase())) {
|
||||
fake.push(entry);
|
||||
@@ -477,6 +501,7 @@ export async function scanFakeBrokenNitros(
|
||||
orphanedIcon: byName(orphanedIcon),
|
||||
total: byNitro.size + bySwf.size + byIcon.size,
|
||||
};
|
||||
throwIfAborted(signal);
|
||||
await writeScanCache(currentDigests, result);
|
||||
onProgress?.({ phase: "done", scanned: result.total });
|
||||
return result;
|
||||
@@ -567,6 +592,147 @@ export async function autoCleanFakeNitros(
|
||||
};
|
||||
}
|
||||
|
||||
export async function getPersistedCleanupResult(): Promise<NitroCleanupScan | null> {
|
||||
const cache = await readScanCache();
|
||||
return cache?.result ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep the on-disk scan cache consistent after a mutation without re-scanning:
|
||||
* recompute the directory signatures for the touched asset kind (cheap readdir
|
||||
* pass — no stats, no header validation) and drop the removed file names from
|
||||
* the cached result lists. Without this, the next non-forced scan would see a
|
||||
* digest mismatch (e.g. after a delete) and re-validate the whole directory.
|
||||
*/
|
||||
export async function refreshScanCacheAfterMutation(
|
||||
kind: CleanupAssetKind,
|
||||
fileNames: string[],
|
||||
removedCount = fileNames.length,
|
||||
): Promise<void> {
|
||||
const remove = new Set(fileNames);
|
||||
if (remove.size === 0) return;
|
||||
const cache = await readScanCache();
|
||||
if (!cache) return;
|
||||
|
||||
const targets = await getFurniAssetWriteTargets();
|
||||
for (const dir of dirsForKind(targets, kind)) {
|
||||
cache.dirs[kindKey(kind, dir)] = dirNameSignature(
|
||||
await readDirNames(dir, KIND_FILE_RE[kind]),
|
||||
);
|
||||
}
|
||||
|
||||
const result = cache.result;
|
||||
if (kind === "nitro") {
|
||||
result.fake = result.fake.filter((e) => !remove.has(e.fileName));
|
||||
result.broken = result.broken.filter((e) => !remove.has(e.fileName));
|
||||
} else if (kind === "swf") {
|
||||
result.orphanedSwf = result.orphanedSwf.filter(
|
||||
(e) => !remove.has(e.fileName),
|
||||
);
|
||||
} else {
|
||||
result.orphanedIcon = result.orphanedIcon.filter(
|
||||
(e) => !remove.has(e.fileName),
|
||||
);
|
||||
}
|
||||
result.total = Math.max(0, result.total - removedCount);
|
||||
await writeScanCache(cache.dirs, result);
|
||||
}
|
||||
|
||||
// ── Auto-clean history ──────────────────────────────────────────────────────
|
||||
//
|
||||
// The nightly scheduled auto-clean and the manual button both record a history
|
||||
// entry so staff can see what the jobs worker removed and when.
|
||||
|
||||
export interface NitroAutoCleanHistoryEntry {
|
||||
triggeredAt: string;
|
||||
triggeredBy: "manual" | "scheduled";
|
||||
maxAgeDays: number;
|
||||
deleted: number;
|
||||
copiesRemoved: number;
|
||||
skippedRecent: number;
|
||||
skippedBroken: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
const HISTORY_MAX_ENTRIES = 50;
|
||||
const historyPath = () => path.join(SCAN_CACHE_DIR, "history.json");
|
||||
|
||||
export async function readAutoCleanHistory(): Promise<
|
||||
NitroAutoCleanHistoryEntry[]
|
||||
> {
|
||||
try {
|
||||
if (!existsSync(historyPath())) return [];
|
||||
const parsed = JSON.parse(await fs.readFile(historyPath(), "utf8"));
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed as NitroAutoCleanHistoryEntry[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function appendAutoCleanHistory(
|
||||
entry: NitroAutoCleanHistoryEntry,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const history = (await readAutoCleanHistory()).slice(
|
||||
0,
|
||||
HISTORY_MAX_ENTRIES - 1,
|
||||
);
|
||||
await fs.mkdir(SCAN_CACHE_DIR, { recursive: true });
|
||||
await fs.writeFile(historyPath(), JSON.stringify([entry, ...history]));
|
||||
} catch {
|
||||
/* best effort — history is never fatal */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh the scan cache + append a history entry after an auto-clean run.
|
||||
* Shared by the manual route and the nightly scheduled job.
|
||||
*/
|
||||
export async function finishAutoClean(options: {
|
||||
result: NitroAutoCleanResult;
|
||||
maxAgeDays: number;
|
||||
triggeredBy: "manual" | "scheduled";
|
||||
}): Promise<void> {
|
||||
const removed = options.result.files
|
||||
.filter((file) => file.deleted)
|
||||
.map((file) => file.fileName);
|
||||
await refreshScanCacheAfterMutation("nitro", removed, options.result.deleted);
|
||||
await appendAutoCleanHistory({
|
||||
triggeredAt: new Date().toISOString(),
|
||||
triggeredBy: options.triggeredBy,
|
||||
maxAgeDays: options.maxAgeDays,
|
||||
deleted: options.result.deleted,
|
||||
copiesRemoved: options.result.copiesRemoved,
|
||||
skippedRecent: options.result.skippedRecent,
|
||||
skippedBroken: options.result.skippedBroken,
|
||||
errors: options.result.errors,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for the nightly jobs-worker cron. Never runs while an interactive
|
||||
* scan session is in flight, so scheduled deletion can't race the UI.
|
||||
*/
|
||||
export async function scheduledAutoCleanFakeNitros(
|
||||
maxAgeDays = 30,
|
||||
): Promise<{ skipped: boolean; deleted?: number }> {
|
||||
const { isSessionStale, readScanSession } = await import(
|
||||
"./nitro-scan-session"
|
||||
);
|
||||
const session = await readScanSession();
|
||||
if (session?.state === "running" && !isSessionStale(session)) {
|
||||
return { skipped: true };
|
||||
}
|
||||
const result = await autoCleanFakeNitros(maxAgeDays);
|
||||
await finishAutoClean({
|
||||
result,
|
||||
maxAgeDays,
|
||||
triggeredBy: "scheduled",
|
||||
});
|
||||
return { skipped: false, deleted: result.deleted };
|
||||
}
|
||||
|
||||
async function copyFileToDirs(
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { logger } from "@/lib/logger";
|
||||
import {
|
||||
type CleanupProgress,
|
||||
scanFakeBrokenNitros,
|
||||
} from "@/lib/services/nitro-cleanup";
|
||||
|
||||
/**
|
||||
* Durable nitro-cleanup scan session.
|
||||
*
|
||||
* A scan is expensive (hundreds of thousands of stat + header reads), so it is
|
||||
* better run as a detached in-process job than blocked inside one request:
|
||||
* closing the tab must not lose the scan and a second tab should re-attach
|
||||
* instead of starting a duplicate. The session is persisted on disk so the
|
||||
* Next.js process keeps working it after the POST that started it has already
|
||||
* returned, and a restart mid-scan is recoverable — a "running" session whose
|
||||
* heartbeat (`updatedAt`) has gone stale is treated as dead and taken over.
|
||||
*
|
||||
* The lightweight session record only stores metadata + progress; the actual
|
||||
* result lists live in the scan cache (`scan-cache.json`) written by the scan,
|
||||
* so a done session is read back without duplicating megabytes of JSON.
|
||||
*/
|
||||
|
||||
export const nitroScanRoot = () =>
|
||||
path.join(process.cwd(), "storage", "nitro-cleanup");
|
||||
const sessionPath = () => path.join(nitroScanRoot(), "session.json");
|
||||
|
||||
export type NitroScanState = "running" | "done" | "cancelled" | "error";
|
||||
|
||||
export interface NitroScanSession {
|
||||
id: string;
|
||||
state: NitroScanState;
|
||||
startedAt: string;
|
||||
updatedAt: string;
|
||||
force: boolean;
|
||||
progress?: { phase: string; scanned: number };
|
||||
cached?: boolean;
|
||||
total?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** A running session that has not been heard from for this long is stale. */
|
||||
export const SESSION_STALE_MS = 60_000;
|
||||
|
||||
/** Thrown when a fresh scan session already exists (single-flight). */
|
||||
export class NitroScanConflictError extends Error {
|
||||
readonly session: NitroScanSession;
|
||||
constructor(session: NitroScanSession) {
|
||||
super("A nitro scan is already running");
|
||||
this.name = "NitroScanConflictError";
|
||||
this.session = session;
|
||||
}
|
||||
}
|
||||
|
||||
export async function readScanSession(): Promise<NitroScanSession | null> {
|
||||
try {
|
||||
if (!existsSync(sessionPath())) return null;
|
||||
const parsed = JSON.parse(
|
||||
await fs.readFile(sessionPath(), "utf8"),
|
||||
) as NitroScanSession;
|
||||
return parsed && typeof parsed.id === "string" ? parsed : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function writeScanSession(
|
||||
session: NitroScanSession,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await fs.mkdir(nitroScanRoot(), { recursive: true });
|
||||
await fs.writeFile(sessionPath(), JSON.stringify(session));
|
||||
} catch {
|
||||
/* session file is best effort — never fatal */
|
||||
}
|
||||
}
|
||||
|
||||
export function isSessionStale(session: NitroScanSession): boolean {
|
||||
const age = Date.now() - new Date(session.updatedAt).getTime();
|
||||
return !Number.isFinite(age) || age > SESSION_STALE_MS;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new scan session, or reject with `NitroScanConflictError` when a
|
||||
* fresh session is already running (single-flight across the whole server).
|
||||
*/
|
||||
export async function startScanSession(options: {
|
||||
force: boolean;
|
||||
}): Promise<NitroScanSession> {
|
||||
const current = await readScanSession();
|
||||
if (current?.state === "running" && !isSessionStale(current)) {
|
||||
throw new NitroScanConflictError(current);
|
||||
}
|
||||
const now = new Date().toISOString();
|
||||
const session: NitroScanSession = {
|
||||
id: randomUUID(),
|
||||
state: "running",
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
force: options.force,
|
||||
};
|
||||
await writeScanSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
interface ActiveScan {
|
||||
sessionId: string;
|
||||
controller: AbortController;
|
||||
promise: Promise<void>;
|
||||
}
|
||||
|
||||
let activeScan: ActiveScan | null = null;
|
||||
|
||||
/**
|
||||
* Start (or re-attach to) a scan in the background. Resolves with the session
|
||||
* quickly; the scan itself keeps running detached in the process. Callers
|
||||
* poll `readScanSession()` to follow progress.
|
||||
*
|
||||
* @throws {NitroScanConflictError} when a fresh (non-stale) scan is already
|
||||
* running — the caller should attach to the returned session instead.
|
||||
*/
|
||||
export async function runScanInBackground(options: {
|
||||
force: boolean;
|
||||
}): Promise<NitroScanSession> {
|
||||
const session = await startScanSession(options);
|
||||
const controller = new AbortController();
|
||||
|
||||
const sessionEvent = async (patch?: {
|
||||
progress?: CleanupProgress;
|
||||
}): Promise<void> => {
|
||||
await writeScanSession({
|
||||
...session,
|
||||
state: "running",
|
||||
...(patch?.progress ? { progress: patch.progress } : {}),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
};
|
||||
|
||||
const run = (async () => {
|
||||
try {
|
||||
const scan = await scanFakeBrokenNitros({
|
||||
force: session.force,
|
||||
signal: controller.signal,
|
||||
onProgress: (progress) => void sessionEvent({ progress }),
|
||||
});
|
||||
await writeScanSession({
|
||||
...session,
|
||||
state: "done",
|
||||
cached: scan.cached ?? false,
|
||||
total: scan.total,
|
||||
progress: { phase: "done", scanned: scan.total },
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
if ((error as Error).name === "AbortError") {
|
||||
await writeScanSession({
|
||||
...session,
|
||||
state: "cancelled",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
logger.error("Nitro scan failed", {
|
||||
module: "nitro-cleanup-session",
|
||||
sessionId: session.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
await writeScanSession({
|
||||
...session,
|
||||
state: "error",
|
||||
error: error instanceof Error ? error.message : "Scan failed",
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (activeScan?.sessionId === session.id) activeScan = null;
|
||||
}
|
||||
})();
|
||||
|
||||
activeScan = { sessionId: session.id, controller, promise: run };
|
||||
return session;
|
||||
}
|
||||
|
||||
/** Abort the in-process scan (if any). Returns whether one was aborted. */
|
||||
export function cancelActiveScan(): boolean {
|
||||
if (!activeScan) return false;
|
||||
activeScan.controller.abort();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Cancel the scan session regardless of who is running it. */
|
||||
export async function cancelScanSession(): Promise<NitroScanSession | null> {
|
||||
if (cancelActiveScan()) {
|
||||
// The detached run observes the abort and marks the session cancelled —
|
||||
// wait for that so the caller sees the definitive end state.
|
||||
const deadline = Date.now() + 2000;
|
||||
let session = await readScanSession();
|
||||
while (session?.state === "running" && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
session = await readScanSession();
|
||||
}
|
||||
if (session?.state === "running" && session) {
|
||||
const cancelled: NitroScanSession = {
|
||||
...session,
|
||||
state: "cancelled",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeScanSession(cancelled);
|
||||
session = cancelled;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
const session = await readScanSession();
|
||||
if (session?.state === "running") {
|
||||
const cancelled: NitroScanSession = {
|
||||
...session,
|
||||
state: "cancelled",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await writeScanSession(cancelled);
|
||||
return cancelled;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -6497,6 +6497,78 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"nitroCleanup": {
|
||||
"alreadyRunning": "A scan is already running — attached to it.",
|
||||
"ageUnknown": "age unknown",
|
||||
"ageBelow": "< 1d",
|
||||
"scanButton": "Scan furniture assets",
|
||||
"scanningAssets": "Scanning assets…",
|
||||
"cancelButton": "Cancel",
|
||||
"deleteSelected": "Delete selected ({count})",
|
||||
"deleting": "Deleting…",
|
||||
"redownloadBroken": "Re-download broken",
|
||||
"redownloading": "Re-downloading…",
|
||||
"autoClean": "Auto-clean old fakes",
|
||||
"cleaning": "Cleaning…",
|
||||
"exportManifest": "Export manifest",
|
||||
"exporting": "Exporting…",
|
||||
"phaseStems": "Loading furniture stems…",
|
||||
"phaseReaddir": "Reading asset directories…",
|
||||
"phaseValidate": "Validating .nitro bundles…",
|
||||
"phaseScanning": "Scanning…",
|
||||
"scanProgressFiles": "{count} files",
|
||||
"repairProgress": "ok {ok}, failed {failed}",
|
||||
"attachedNotice": "Scan continues in the background — results will appear when it finishes.",
|
||||
"sessionCancelled": "Scan cancelled.",
|
||||
"onlyOldLabel": "Only show files older than",
|
||||
"onlyOldAgeLabel": "Minimum age in days to display",
|
||||
"onlyOldToggle": "Hide recent files",
|
||||
"daysLabel": "days",
|
||||
"autoAgeLabel": "Minimum age in days for auto-clean",
|
||||
"loadingResult": "Loading scan result…",
|
||||
"scannedStat": "asset files scanned",
|
||||
"cachedNotice": "(cached — directories unchanged)",
|
||||
"fakeStat": "Fake nitros (no matching item)",
|
||||
"brokenStat": "Broken nitros (unparseable)",
|
||||
"groupEmpty": "None found",
|
||||
"selectedCount": "{count} selected",
|
||||
"copiesBadge": "{count} copies",
|
||||
"groupFakeLabel": "Fake nitros",
|
||||
"groupFakeDescription": "These files match no item in items_base — leftovers from deleted or renamed furniture.",
|
||||
"groupBrokenLabel": "Broken nitros",
|
||||
"groupBrokenDescription": "These files belong to an item but cannot be parsed as a valid Nitro bundle.",
|
||||
"groupSwfLabel": "Orphaned SWFs",
|
||||
"groupSwfDescription": ".swf files that match no item in items_base.",
|
||||
"groupIconLabel": "Orphaned icons",
|
||||
"groupIconDescription": "Icon files that match no item in items_base.",
|
||||
"warningText": "Deleting a broken .nitro leaves its item in the database without a bundle — prefer re-downloading it, or let the catalog audit re-obtain it later. Database rows are never removed by this tool.",
|
||||
"onlyAssetsTouched": "Only asset files are touched — FurnitureData.json and database rows stay intact.",
|
||||
"emptyHint": "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.",
|
||||
"noResult": "No scan result yet — run a furniture asset scan first.",
|
||||
"scanFailed": "The scan failed.",
|
||||
"confirmDeleteTitle": "Delete {count} asset file(s)?",
|
||||
"confirmDeleteDescription": "The files will be removed from every directory (public + mirrored gamedata). Database rows are never touched.",
|
||||
"confirmDeleteLabel": "Delete",
|
||||
"confirmRepairTitle": "Re-download {count} broken .nitro bundle(s)?",
|
||||
"confirmRepairDescription": "The bundles will be fetched again from the configured nitro sources and written over every directory (public + mirrored gamedata).",
|
||||
"confirmRepairLabel": "Re-download",
|
||||
"confirmAutoCleanTitle": "Auto-clean fake nitros older than {days} day(s)?",
|
||||
"confirmAutoCleanDescription": "Only fake .nitro files (no matching item) older than the threshold are removed. Broken bundles and recent files are never touched.",
|
||||
"confirmAutoCleanLabel": "Auto-clean",
|
||||
"toastDeletePartial": "Deleted {copies} copy/copies, but some files could not be removed",
|
||||
"toastDeleteDone": "Deleted {copies} file copy/copies ({deleted} file(s))",
|
||||
"toastRepairDone": "Repaired {count} .nitro bundle(s)",
|
||||
"toastRepairFailed": "Could not re-obtain any bundle ({failed} failed)",
|
||||
"toastRepairCancelled": "Repair cancelled",
|
||||
"toastAutoCleanDone": "Auto-cleaned {deleted} old fake .nitro file(s) ({copies} copies). Skipped {recent} recent + {broken} broken.",
|
||||
"toastAutoCleanNothing": "Nothing old enough to remove (skipped {recent} recent, {broken} broken)",
|
||||
"toastManifestDownload": "Scan manifest downloaded",
|
||||
"historyTitle": "Auto-clean history",
|
||||
"historyScheduled": "scheduled",
|
||||
"historyManual": "manual",
|
||||
"historyEntry": "{deleted} removed ({copies} copies); {recent} recent and {broken} broken skipped",
|
||||
"historyErrors": "{count} errors"
|
||||
},
|
||||
"importDiagnostics": {
|
||||
"attempts": "Source file diagnostics",
|
||||
"preliminary": "Preliminary check: these addresses were checked without downloading the files. The import will validate the original files.",
|
||||
|
||||
@@ -6503,6 +6503,78 @@
|
||||
},
|
||||
"admin": {
|
||||
"studio": {
|
||||
"nitroCleanup": {
|
||||
"alreadyRunning": "Er draait al een scan — de resultaten volgen als die klaar is.",
|
||||
"ageUnknown": "leeftijd onbekend",
|
||||
"ageBelow": "< 1d",
|
||||
"scanButton": "Furniture assets scannen",
|
||||
"scanningAssets": "Assets scannen…",
|
||||
"cancelButton": "Annuleren",
|
||||
"deleteSelected": "{count} geselecteerd verwijderen",
|
||||
"deleting": "Verwijderen…",
|
||||
"redownloadBroken": "Opnieuw downloaden",
|
||||
"redownloading": "Opnieuw downloaden…",
|
||||
"autoClean": "Oude fakes opschonen",
|
||||
"cleaning": "Opschonen…",
|
||||
"exportManifest": "Manifest exporteren",
|
||||
"exporting": "Exporteren…",
|
||||
"phaseStems": "Furniture stems laden…",
|
||||
"phaseReaddir": "Asset-mappen lezen…",
|
||||
"phaseValidate": ".nitro-bundles valideren…",
|
||||
"phaseScanning": "Scannen…",
|
||||
"scanProgressFiles": "{count} bestanden",
|
||||
"repairProgress": "ok {ok}, mislukt {failed}",
|
||||
"attachedNotice": "De scan draait op de achtergrond verder — resultaten verschijnen als die klaar is.",
|
||||
"sessionCancelled": "Scan geannuleerd.",
|
||||
"onlyOldLabel": "Alleen bestanden ouder dan",
|
||||
"onlyOldAgeLabel": "Minimumleeftijd in dagen om te tonen",
|
||||
"onlyOldToggle": "Recente bestanden verbergen",
|
||||
"daysLabel": "dagen",
|
||||
"autoAgeLabel": "Minimumleeftijd in dagen voor opruimen",
|
||||
"loadingResult": "Scanresultaat laden…",
|
||||
"scannedStat": "asset-bestanden gescand",
|
||||
"cachedNotice": "(in cache — mappen ongewijzigd)",
|
||||
"fakeStat": "Fake-nitros (geen overeenkomend item)",
|
||||
"brokenStat": "Kapotte nitros (niet parseerbaar)",
|
||||
"groupEmpty": "Niets gevonden",
|
||||
"selectedCount": "{count} geselecteerd",
|
||||
"copiesBadge": "{count} kopieën",
|
||||
"groupFakeLabel": "Fake-nitros",
|
||||
"groupFakeDescription": "Deze bestanden matchen geen enkel item in items_base — overblijfselen van verwijderde of hernoemde furniture.",
|
||||
"groupBrokenLabel": "Kapotte nitros",
|
||||
"groupBrokenDescription": "Deze bestanden horen bij een item maar kunnen niet als geldige Nitro-bundle worden gelezen.",
|
||||
"groupSwfLabel": "Verweesde SWFs",
|
||||
"groupSwfDescription": ".swf-bestanden die geen enkel item in items_base matchen.",
|
||||
"groupIconLabel": "Verweesde iconen",
|
||||
"groupIconDescription": "Icoonbestanden die geen enkel item in items_base matchen.",
|
||||
"warningText": "Een kapotte .nitro verwijderen laat het item in de database achter zonder bundle — download het liever opnieuw, of laat de catalogaudit het later opnieuw ophalen. Database-rijen worden door deze tool nooit verwijderd.",
|
||||
"onlyAssetsTouched": "Alleen asset-bestanden worden aangeraakt — FurnitureData.json en database-rijen blijven intact.",
|
||||
"emptyHint": "Scan elke furni-assetmap om fake- en kapotte nitro-bundles plus verweesde SWFs en iconen te vinden. Verwijder ze, download de kapotte opnieuw of ruim oude overblijfselen automatisch op.",
|
||||
"noResult": "Nog geen scanresultaat — start eerst een furniture-assetscan.",
|
||||
"scanFailed": "De scan is mislukt.",
|
||||
"confirmDeleteTitle": "{count} assetbestand(en) verwijderen?",
|
||||
"confirmDeleteDescription": "De bestanden worden uit elke map verwijderd (publiek + gespiegelde gamedata). Database-rijen worden nooit aangeraakt.",
|
||||
"confirmDeleteLabel": "Verwijderen",
|
||||
"confirmRepairTitle": "{count} kapotte .nitro-bundles opnieuw downloaden?",
|
||||
"confirmRepairDescription": "De bundles worden opnieuw opgehaald van de geconfigureerde nitro-bronnen en over elke map geschreven (publiek + gespiegelde gamedata).",
|
||||
"confirmRepairLabel": "Opnieuw downloaden",
|
||||
"confirmAutoCleanTitle": "Fake-nitros ouder dan {days} dag(en) automatisch opruimen?",
|
||||
"confirmAutoCleanDescription": "Alleen fake .nitro-bestanden (geen overeenkomend item) ouder dan de drempel worden verwijderd. Kapotte bundles en recente bestanden worden nooit aangeraakt.",
|
||||
"confirmAutoCleanLabel": "Opschonen",
|
||||
"toastDeletePartial": "{copies} kopie(ën) verwijderd, maar sommige bestanden konden niet worden verwijderd",
|
||||
"toastDeleteDone": "{copies} bestandskopie(ën) verwijderd ({deleted} bestand(en))",
|
||||
"toastRepairDone": "{count} .nitro-bundle(s) gerepareerd",
|
||||
"toastRepairFailed": "Geen enkele bundle kon worden opgehaald ({failed} mislukt)",
|
||||
"toastRepairCancelled": "Herstel geannuleerd",
|
||||
"toastAutoCleanDone": "{deleted} oude fake .nitro-bestand(en) opgeruimd ({copies} kopieën). {recent} recente en {broken} kapotte overgeslagen.",
|
||||
"toastAutoCleanNothing": "Niets oud genoeg om te verwijderen ({recent} recente en {broken} kapotte overgeslagen)",
|
||||
"toastManifestDownload": "Scanmanifest gedownload",
|
||||
"historyTitle": "Opruimgeschiedenis",
|
||||
"historyScheduled": "gepland",
|
||||
"historyManual": "handmatig",
|
||||
"historyEntry": "{deleted} verwijderd ({copies} kopieën); {recent} recente en {broken} kapotte overgeslagen",
|
||||
"historyErrors": "{count} fouten"
|
||||
},
|
||||
"importDiagnostics": {
|
||||
"attempts": "Bestandsdiagnostiek",
|
||||
"preliminary": "Voorafgaande controle van adressen; inhoud wordt tijdens import gevalideerd.",
|
||||
|
||||
Reference in new issue
Block a user