diff --git a/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts b/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts index e6e36fe0..702b5565 100644 --- a/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts +++ b/src/app/api/admin/studio/nitro-cleanup/rebuild/route.ts @@ -3,9 +3,25 @@ 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"; +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( { permission: PERMS.ASSETS_IMPORT }, async (request, ctx) => { @@ -23,8 +39,22 @@ export const POST = withAdmin( if (fileNames.length === 0) { 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(); if (result.repaired > 0) { logAudit({ @@ -39,10 +69,54 @@ export const POST = withAdmin( }, }); } - return apiOk({ - repaired: result.repaired, - failed: result.failed, - errors: result.errors.length > 0 ? result.errors : undefined, + return summarize(result); + }; + + 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) { return apiError(`Nitro repair failed: ${(err as Error).message}`, 500); diff --git a/src/components/admin/studio/nitro-cleanup-panel.tsx b/src/components/admin/studio/nitro-cleanup-panel.tsx index 1c8fff73..d41271cd 100644 --- a/src/components/admin/studio/nitro-cleanup-panel.tsx +++ b/src/components/admin/studio/nitro-cleanup-panel.tsx @@ -258,6 +258,12 @@ export function NitroCleanupPanel() { phase: string; scanned: number; } | null>(null); + const [repairProgress, setRepairProgress] = useState<{ + completed: number; + total: number; + ok: number; + failed: number; + } | null>(null); const [scan, setScan] = useState(null); const [selected, setSelected] = useState>(new Set()); const [selectedSwf, setSelectedSwf] = useState>(new Set()); @@ -486,32 +492,85 @@ export function NitroCleanupPanel() { variant: "default", }); if (!confirmed) return; + abortRef.current?.abort(); setRepairing(true); + setRepairProgress(null); + const abort = new AbortController(); + abortRef.current = abort; + + const holder: { result: RepairResponse | null } = { result: null }; + let lastError: string | null = null; try { const res = await adminFetch("/api/admin/studio/nitro-cleanup/rebuild", { method: "POST", - headers: { "Content-Type": "application/json" }, + headers: { + "Content-Type": "application/json", + Accept: "text/event-stream", + }, body: JSON.stringify({ fileNames }), + signal: abort.signal, }); - const data = (await res.json()) as RepairResponse & { - error?: string; - }; - if (!res.ok) throw new Error(data.error || "Repair failed"); - if (data.repaired > 0) { + if (!res.ok) { + const body = (await res.json().catch(() => null)) as { + error?: string; + } | null; + 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)`); - } else { + } else if (data) { toast.warning(`Could not re-obtain any bundle (${data.failed} failed)`); } // Only drop the attempted bundles when every one succeeded; partial // failures stay visible so they can be retried. - if (data.repaired >= fileNames.length) { + if (data && data.repaired >= fileNames.length) { dropFromScan("nitro", fileNames); } setSelected(new Set()); } 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 { 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 || repairing ? ( - )} + ) : null} {scan && (