perf(studio): stream nitro repair progress over SSE and allow cancelling
This commit is contained in:
1 parent
ba81d16f00
commit
9d571e0c29
4 files changed
+249
-26
No files matched your search
@@ -3,9 +3,25 @@ import { withAdmin } from "@/lib/api-handler";
|
|||||||
import { PERMS } from "@/lib/permissions";
|
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 { repairBrokenNitros } from "@/lib/services/nitro-cleanup";
|
import {
|
||||||
|
type NitroRepairProgress,
|
||||||
|
repairBrokenNitros,
|
||||||
|
} from "@/lib/services/nitro-cleanup";
|
||||||
|
|
||||||
/** Re-download the given broken .nitro bundles from the configured sources. */
|
type RepairSummary = {
|
||||||
|
repaired: number;
|
||||||
|
failed: number;
|
||||||
|
errors?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-download the given broken .nitro bundles from the configured nitro
|
||||||
|
* sources. Bundles are pulled in parallel server-side; when the client asks
|
||||||
|
* for `text/event-stream` a `progress` event is streamed after every bundle
|
||||||
|
* settles plus a final `result` event, mirroring the scan endpoint so a large
|
||||||
|
* repair batch cannot be cut off by a reverse-proxy timeout and can be
|
||||||
|
* cancelled client-side.
|
||||||
|
*/
|
||||||
export const POST = withAdmin(
|
export const POST = withAdmin(
|
||||||
{ permission: PERMS.ASSETS_IMPORT },
|
{ permission: PERMS.ASSETS_IMPORT },
|
||||||
async (request, ctx) => {
|
async (request, ctx) => {
|
||||||
@@ -23,8 +39,22 @@ export const POST = withAdmin(
|
|||||||
if (fileNames.length === 0) {
|
if (fileNames.length === 0) {
|
||||||
return apiError("fileNames[] is required", 400);
|
return apiError("fileNames[] is required", 400);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const result = await repairBrokenNitros(fileNames);
|
const summarize = (result: {
|
||||||
|
repaired: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
}): RepairSummary => ({
|
||||||
|
repaired: result.repaired,
|
||||||
|
failed: result.failed,
|
||||||
|
errors: result.errors.length > 0 ? result.errors : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const finish = async (result: {
|
||||||
|
repaired: number;
|
||||||
|
failed: number;
|
||||||
|
errors: string[];
|
||||||
|
}) => {
|
||||||
clearFurniImportCache();
|
clearFurniImportCache();
|
||||||
if (result.repaired > 0) {
|
if (result.repaired > 0) {
|
||||||
logAudit({
|
logAudit({
|
||||||
@@ -39,10 +69,54 @@ export const POST = withAdmin(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return apiOk({
|
return summarize(result);
|
||||||
repaired: result.repaired,
|
};
|
||||||
failed: result.failed,
|
|
||||||
errors: result.errors.length > 0 ? result.errors : undefined,
|
const wantsSse = request.headers
|
||||||
|
.get("accept")
|
||||||
|
?.includes("text/event-stream");
|
||||||
|
try {
|
||||||
|
if (!wantsSse) {
|
||||||
|
return apiOk(await finish(await repairBrokenNitros(fileNames)));
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
const result = await repairBrokenNitros(fileNames, {
|
||||||
|
onProgress: (progress: NitroRepairProgress) =>
|
||||||
|
send({ type: "progress", ...progress }),
|
||||||
|
});
|
||||||
|
send({ type: "result", ...(await finish(result)) });
|
||||||
|
} catch (err) {
|
||||||
|
send({
|
||||||
|
type: "error",
|
||||||
|
message: `Nitro repair 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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return apiError(`Nitro repair failed: ${(err as Error).message}`, 500);
|
return apiError(`Nitro repair failed: ${(err as Error).message}`, 500);
|
||||||
|
|||||||
@@ -258,6 +258,12 @@ export function NitroCleanupPanel() {
|
|||||||
phase: string;
|
phase: string;
|
||||||
scanned: number;
|
scanned: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [repairProgress, setRepairProgress] = useState<{
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
failed: number;
|
||||||
|
} | null>(null);
|
||||||
const [scan, setScan] = useState<NitroCleanupScan | null>(null);
|
const [scan, setScan] = useState<NitroCleanupScan | null>(null);
|
||||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
const [selectedSwf, setSelectedSwf] = useState<Set<string>>(new Set());
|
const [selectedSwf, setSelectedSwf] = useState<Set<string>>(new Set());
|
||||||
@@ -486,32 +492,85 @@ export function NitroCleanupPanel() {
|
|||||||
variant: "default",
|
variant: "default",
|
||||||
});
|
});
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
abortRef.current?.abort();
|
||||||
setRepairing(true);
|
setRepairing(true);
|
||||||
|
setRepairProgress(null);
|
||||||
|
const abort = new AbortController();
|
||||||
|
abortRef.current = abort;
|
||||||
|
|
||||||
|
const holder: { result: RepairResponse | null } = { result: null };
|
||||||
|
let lastError: string | null = null;
|
||||||
try {
|
try {
|
||||||
const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", {
|
const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
},
|
||||||
body: JSON.stringify({ fileNames }),
|
body: JSON.stringify({ fileNames }),
|
||||||
|
signal: abort.signal,
|
||||||
});
|
});
|
||||||
const data = (await res.json()) as RepairResponse & {
|
if (!res.ok) {
|
||||||
error?: string;
|
const body = (await res.json().catch(() => null)) as {
|
||||||
};
|
error?: string;
|
||||||
if (!res.ok) throw new Error(data.error || "Repair failed");
|
} | null;
|
||||||
if (data.repaired > 0) {
|
throw new Error(
|
||||||
|
typeof body?.error === "string"
|
||||||
|
? body.error
|
||||||
|
: `Repair failed (HTTP ${res.status})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!res.body) throw new Error("Repair returned no response body");
|
||||||
|
|
||||||
|
await readSseStream(
|
||||||
|
res.body,
|
||||||
|
(evt) => {
|
||||||
|
if (evt.type === "progress") {
|
||||||
|
setRepairProgress({
|
||||||
|
completed: Number(evt.completed ?? 0),
|
||||||
|
total: Number(evt.total ?? fileNames.length),
|
||||||
|
ok: Number(evt.ok ?? 0),
|
||||||
|
failed: Number(evt.failed ?? 0),
|
||||||
|
});
|
||||||
|
} else if (evt.type === "result") {
|
||||||
|
holder.result = {
|
||||||
|
repaired: Number(evt.repaired ?? 0),
|
||||||
|
failed: Number(evt.failed ?? 0),
|
||||||
|
errors: Array.isArray(evt.errors)
|
||||||
|
? evt.errors.map(String)
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
} else if (evt.type === "error") {
|
||||||
|
lastError = String(evt.message ?? "Repair failed");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
abort.signal,
|
||||||
|
SCAN_IDLE_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (lastError) throw new Error(lastError);
|
||||||
|
const data = holder.result;
|
||||||
|
if (data && data.repaired > 0) {
|
||||||
toast.success(`Repaired ${data.repaired} .nitro bundle(s)`);
|
toast.success(`Repaired ${data.repaired} .nitro bundle(s)`);
|
||||||
} else {
|
} else if (data) {
|
||||||
toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`);
|
toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`);
|
||||||
}
|
}
|
||||||
// Only drop the attempted bundles when every one succeeded; partial
|
// Only drop the attempted bundles when every one succeeded; partial
|
||||||
// failures stay visible so they can be retried.
|
// failures stay visible so they can be retried.
|
||||||
if (data.repaired >= fileNames.length) {
|
if (data && data.repaired >= fileNames.length) {
|
||||||
dropFromScan("nitro", fileNames);
|
dropFromScan("nitro", fileNames);
|
||||||
}
|
}
|
||||||
setSelected(new Set());
|
setSelected(new Set());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err instanceof Error ? err.message : "Repair failed");
|
if ((err as Error).name === "AbortError") {
|
||||||
|
toast.info("Repair cancelled");
|
||||||
|
} else {
|
||||||
|
toast.error(err instanceof Error ? err.message : "Repair failed");
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setRepairing(false);
|
setRepairing(false);
|
||||||
|
setRepairProgress(null);
|
||||||
|
if (abortRef.current === abort) abortRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -635,7 +694,7 @@ export function NitroCleanupPanel() {
|
|||||||
)}
|
)}
|
||||||
{scanning ? "Scanning assets…" : "Scan furniture assets"}
|
{scanning ? "Scanning assets…" : "Scan furniture assets"}
|
||||||
</Button>
|
</Button>
|
||||||
{scanning && (
|
{scanning || repairing ? (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="lg"
|
size="lg"
|
||||||
@@ -644,7 +703,7 @@ export function NitroCleanupPanel() {
|
|||||||
<X className="w-4 h-4 mr-2" />
|
<X className="w-4 h-4 mr-2" />
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
) : null}
|
||||||
{scan && (
|
{scan && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -734,6 +793,17 @@ export function NitroCleanupPanel() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{repairing && repairProgress && (
|
||||||
|
<div className="flex items-center gap-2 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3 text-sm text-[var(--admin-text-muted)]">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin shrink-0" />
|
||||||
|
Re-downloading bundles…
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{repairProgress.completed}/{repairProgress.total} — ok{" "}
|
||||||
|
{repairProgress.ok}, failed {repairProgress.failed}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{scan && (
|
{scan && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
<div className="flex flex-wrap items-center gap-3 rounded-lg border border-[var(--admin-border)] bg-[var(--admin-surface)] p-3">
|
||||||
|
|||||||
@@ -88,6 +88,7 @@ vi.mock("node:fs", async (orig) => {
|
|||||||
import {
|
import {
|
||||||
autoCleanFakeNitros,
|
autoCleanFakeNitros,
|
||||||
deleteNitroCleanupFiles,
|
deleteNitroCleanupFiles,
|
||||||
|
type NitroRepairProgress,
|
||||||
repairBrokenNitros,
|
repairBrokenNitros,
|
||||||
scanFakeBrokenNitros,
|
scanFakeBrokenNitros,
|
||||||
} from "@/lib/services/nitro-cleanup";
|
} from "@/lib/services/nitro-cleanup";
|
||||||
@@ -471,4 +472,59 @@ describe("repairBrokenNitros", () => {
|
|||||||
expect(result.failed).toBe(1);
|
expect(result.failed).toBe(1);
|
||||||
expect(result.errors[0]).toMatch(/could not re-obtain/);
|
expect(result.errors[0]).toMatch(/could not re-obtain/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reports per-bundle progress while re-downloading", async () => {
|
||||||
|
getTargetsFn.mockResolvedValue({
|
||||||
|
nitroDir: "/a/nitro",
|
||||||
|
swfDir: "/a/swf",
|
||||||
|
iconDir: "/a/icons",
|
||||||
|
mirrorDirs: [],
|
||||||
|
});
|
||||||
|
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
|
||||||
|
parseNitroBundle.mockReturnValue({});
|
||||||
|
downloadFileFn.mockResolvedValue({ ok: true, size: 100 });
|
||||||
|
|
||||||
|
const events: NitroRepairProgress[] = [];
|
||||||
|
const result = await repairBrokenNitros(["a.nitro", "b.nitro"], {
|
||||||
|
onProgress: (progress) => events.push(progress),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.repaired).toBe(2);
|
||||||
|
expect(events).toHaveLength(2);
|
||||||
|
expect(events.map((e) => e.completed).sort()).toEqual([1, 2]);
|
||||||
|
expect(events[events.length - 1]).toMatchObject({
|
||||||
|
total: 2,
|
||||||
|
ok: 2,
|
||||||
|
failed: 0,
|
||||||
|
});
|
||||||
|
expect(events[0].total).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts successes and failures in repair progress", async () => {
|
||||||
|
getTargetsFn.mockResolvedValue({
|
||||||
|
nitroDir: "/a/nitro",
|
||||||
|
swfDir: "/a/swf",
|
||||||
|
iconDir: "/a/icons",
|
||||||
|
mirrorDirs: [],
|
||||||
|
});
|
||||||
|
readFileFn.mockResolvedValue(Buffer.from("nitro-data"));
|
||||||
|
parseNitroBundle.mockReturnValue({});
|
||||||
|
downloadFileFn.mockImplementation(async (url: string) => ({
|
||||||
|
ok: url.includes("a.nitro"),
|
||||||
|
size: 100,
|
||||||
|
error: url.includes("b.nitro") ? "HTTP 404" : undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const events: NitroRepairProgress[] = [];
|
||||||
|
await repairBrokenNitros(["a.nitro", "b.nitro"], {
|
||||||
|
onProgress: (progress) => events.push(progress),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(events.map((e) => e.completed).sort()).toEqual([1, 2]);
|
||||||
|
expect(events[events.length - 1]).toMatchObject({
|
||||||
|
total: 2,
|
||||||
|
ok: 1,
|
||||||
|
failed: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -584,15 +584,28 @@ async function copyFileToDirs(
|
|||||||
return errors;
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NitroRepairProgress {
|
||||||
|
completed: number;
|
||||||
|
total: number;
|
||||||
|
ok: number;
|
||||||
|
failed: number;
|
||||||
|
fileName?: string;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Re-download broken .nitro bundles from the configured nitro sources. The
|
* Re-download broken .nitro bundles from the configured nitro sources. The
|
||||||
* file stem matched an item in `items_base`, so the same classname candidates
|
* file stem matched an item in `items_base`, so the same classname candidates
|
||||||
* used by repair-nitros.ts are tried; every downloaded bundle is validated
|
* used by repair-nitros.ts are tried; every downloaded bundle is validated
|
||||||
* before it replaces the broken copies across all nitro directories.
|
* before it replaces the broken copies across all nitro directories. Bundles
|
||||||
|
* are repaired in parallel (pool of 6) and `onProgress` is called after each
|
||||||
|
* one settles, so a huge batch can be streamed to the client and cancelled.
|
||||||
*/
|
*/
|
||||||
export async function repairBrokenNitros(
|
export async function repairBrokenNitros(
|
||||||
fileNames: string[],
|
fileNames: string[],
|
||||||
|
options: { onProgress?: (progress: NitroRepairProgress) => void } = {},
|
||||||
): Promise<NitroRepairResult> {
|
): Promise<NitroRepairResult> {
|
||||||
|
const { onProgress } = options;
|
||||||
const targets = await getFurniAssetWriteTargets();
|
const targets = await getFurniAssetWriteTargets();
|
||||||
const nitroDirs = dirsForKind(targets, "nitro");
|
const nitroDirs = dirsForKind(targets, "nitro");
|
||||||
let sources = (await listSources().catch(() => [] as never)).filter(
|
let sources = (await listSources().catch(() => [] as never)).filter(
|
||||||
@@ -604,14 +617,23 @@ export async function repairBrokenNitros(
|
|||||||
let failed = 0;
|
let failed = 0;
|
||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const files: NitroRepairResult["files"] = [];
|
const files: NitroRepairResult["files"] = [];
|
||||||
|
const uniqueFileNames = [...new Set(fileNames)];
|
||||||
|
const total = uniqueFileNames.length;
|
||||||
|
let completed = 0;
|
||||||
|
const finish = (fileName: string, ok: boolean, error?: string) => {
|
||||||
|
completed++;
|
||||||
|
if (ok) repaired++;
|
||||||
|
else failed++;
|
||||||
|
onProgress?.({ completed, total, ok: repaired, failed, fileName, error });
|
||||||
|
};
|
||||||
|
|
||||||
await runPool([...new Set(fileNames)], 6, async (rawName) => {
|
await runPool(uniqueFileNames, 6, async (rawName) => {
|
||||||
const fileName = String(rawName ?? "");
|
const fileName = String(rawName ?? "");
|
||||||
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
|
if (!NITRO_FILE_RE.test(fileName) || !fileName.endsWith(".nitro")) {
|
||||||
failed++;
|
|
||||||
const message = `Invalid nitro file name: ${fileName}`;
|
const message = `Invalid nitro file name: ${fileName}`;
|
||||||
errors.push(message);
|
errors.push(message);
|
||||||
files.push({ fileName, ok: false, error: message });
|
files.push({ fileName, ok: false, error: message });
|
||||||
|
finish(fileName, false, message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const name = fileName.slice(0, -".nitro".length);
|
const name = fileName.slice(0, -".nitro".length);
|
||||||
@@ -648,16 +670,16 @@ export async function repairBrokenNitros(
|
|||||||
nitroDirs,
|
nitroDirs,
|
||||||
);
|
);
|
||||||
if (writeErrors.length === 0) {
|
if (writeErrors.length === 0) {
|
||||||
repaired++;
|
|
||||||
files.push({ fileName, ok: true });
|
files.push({ fileName, ok: true });
|
||||||
|
finish(fileName, true);
|
||||||
} else {
|
} else {
|
||||||
failed++;
|
|
||||||
errors.push(...writeErrors);
|
errors.push(...writeErrors);
|
||||||
files.push({
|
files.push({
|
||||||
fileName,
|
fileName,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: writeErrors[0],
|
error: writeErrors[0],
|
||||||
});
|
});
|
||||||
|
finish(fileName, false, writeErrors[0]);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -670,13 +692,14 @@ export async function repairBrokenNitros(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
failed++;
|
const message = "could not re-obtain bundle from any nitro source";
|
||||||
errors.push(`could not re-obtain ${fileName} from any nitro source`);
|
errors.push(`could not re-obtain ${fileName} from any nitro source`);
|
||||||
files.push({
|
files.push({
|
||||||
fileName,
|
fileName,
|
||||||
ok: false,
|
ok: false,
|
||||||
error: "could not re-obtain bundle from any nitro source",
|
error: message,
|
||||||
});
|
});
|
||||||
|
finish(fileName, false, message);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { repaired, failed, errors, files };
|
return { repaired, failed, errors, files };
|
||||||
|
|||||||
Reference in new issue
Block a user