From 16191cef140ccaca991b8e1476a4e06e013564c4 Mon Sep 17 00:00:00 2001 From: simoleo89 Date: Sat, 1 Aug 2026 15:56:38 +0200 Subject: [PATCH] fix(admin): harden clothing/pets/effects/clone imports Align grids on data.items, only treat SSE done as success, hard-fail clothing sets when libs fail, and add Cancel via AbortController. Co-authored-by: Cursor --- .../import/clone/import-clone-client.tsx | 144 +++++++++++------ .../clothing/import-clothing-client.tsx | 151 ++++++++++++------ .../import/effects/import-effects-client.tsx | 84 +++++----- .../import/furni/import-furni-client.tsx | 2 +- .../admin/import/pets/import-pets-client.tsx | 81 ++++++---- src/lib/services/clothing-set-import.test.ts | 16 ++ src/lib/services/clothing-set-import.ts | 15 ++ src/lib/sse-client.ts | 24 ++- 8 files changed, 344 insertions(+), 173 deletions(-) diff --git a/src/app/admin/import/clone/import-clone-client.tsx b/src/app/admin/import/clone/import-clone-client.tsx index 778bbc59..f083e61a 100644 --- a/src/app/admin/import/clone/import-clone-client.tsx +++ b/src/app/admin/import/clone/import-clone-client.tsx @@ -13,7 +13,7 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -355,6 +355,7 @@ function FurniGrid({ source }: FurniGridProps) { } | null>(null); const [busyClassname, setBusyClassname] = useState(null); const [filter, setFilter] = useState<"all" | "missing" | "present">("all"); + const batchAbortRef = useRef(null); const fetchItems = useCallback( async ( @@ -378,7 +379,7 @@ function FurniGrid({ source }: FurniGridProps) { setError(data.error || "Failed to load"); return; } - setItems(data.item || []); + setItems(data.items || []); setMeta(data.meta || null); setStats(data.stats || null); setSelected(new Set()); @@ -442,30 +443,46 @@ function FurniGrid({ source }: FurniGridProps) { } async function cloneViaSse(toClone: FurniItem[]) { + batchAbortRef.current?.abort(); + const abort = new AbortController(); + batchAbortRef.current = abort; setBatchProgress({ done: 0, total: toClone.length }); let done = 0; - await runSseImport( - "/api/admin/import/clone/batch", - { - sourceId: source.id, - classnames: toClone.map((it) => it.classname), - concurrency: 2, - }, - (classname) => { - done++; - markDone(classname); - setBatchProgress({ done, total: toClone.length }); - }, - (succeeded, failed) => { - setBatchProgress(null); - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} furni cloned`); - if (failed > 0) toast.error(`${failed} failed`); - // Refresh stats after batch - fetchItems(activeSearch, page, filter); - }, - ); - setBatchProgress(null); + try { + await runSseImport( + "/api/admin/import/clone/batch", + { + sourceId: source.id, + classnames: toClone.map((it) => it.classname), + concurrency: 2, + }, + (classname) => { + done++; + markDone(classname); + setBatchProgress({ done, total: toClone.length }); + }, + (succeeded, failed) => { + setSelected(new Set()); + if (succeeded > 0) toast.success(`${succeeded} furni cloned`); + if (failed > 0) toast.error(`${failed} failed`); + fetchItems(activeSearch, page, filter); + }, + abort.signal, + () => { + done++; + setBatchProgress({ done, total: toClone.length }); + }, + ); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + toast.info("Clone cancelled"); + } else { + throw err; + } + } finally { + if (batchAbortRef.current === abort) batchAbortRef.current = null; + setBatchProgress(null); + } } async function cloneOne(it: FurniItem) { @@ -514,32 +531,55 @@ function FurniGrid({ source }: FurniGridProps) { ) { return; } + batchAbortRef.current?.abort(); + const abort = new AbortController(); + batchAbortRef.current = abort; const CHUNK = 400; setBatchProgress({ done: 0, total: names.length }); let done = 0; let okCount = 0; let failCount = 0; - for (let i = 0; i < names.length; i += CHUNK) { - const chunk = names.slice(i, i + CHUNK); - await runSseImport( - "/api/admin/import/clone/batch", - { sourceId: source.id, classnames: chunk, concurrency: 2 }, - (classname) => { - done++; - markDone(classname); - setBatchProgress({ done, total: names.length }); - }, - (succeeded, failed) => { - okCount += succeeded; - failCount += failed; - }, - ); + try { + for (let i = 0; i < names.length; i += CHUNK) { + if (abort.signal.aborted) break; + const chunk = names.slice(i, i + CHUNK); + await runSseImport( + "/api/admin/import/clone/batch", + { sourceId: source.id, classnames: chunk, concurrency: 2 }, + (classname) => { + done++; + markDone(classname); + setBatchProgress({ done, total: names.length }); + }, + (succeeded, failed) => { + okCount += succeeded; + failCount += failed; + }, + abort.signal, + () => { + done++; + setBatchProgress({ done, total: names.length }); + }, + ); + } + if (abort.signal.aborted) { + toast.info("Clone cancelled"); + } else { + if (okCount > 0) toast.success(`${okCount} furni cloned`); + if (failCount > 0) toast.error(`${failCount} failed`); + } + setSelected(new Set()); + fetchItems(activeSearch, page, filter); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + toast.info("Clone cancelled"); + } else { + throw err; + } + } finally { + if (batchAbortRef.current === abort) batchAbortRef.current = null; + setBatchProgress(null); } - setBatchProgress(null); - setSelected(new Set()); - if (okCount > 0) toast.success(`${okCount} furni cloned`); - if (failCount > 0) toast.error(`${failCount} failed`); - fetchItems(activeSearch, page, filter); } return ( @@ -692,9 +732,19 @@ function FurniGrid({ source }: FurniGridProps) { )} {batchProgress && ( - - {batchProgress.done}/{batchProgress.total} - + <> + + {batchProgress.done}/{batchProgress.total} + + + )} )} diff --git a/src/app/admin/import/clothing/import-clothing-client.tsx b/src/app/admin/import/clothing/import-clothing-client.tsx index 28559bc2..24b88bb3 100644 --- a/src/app/admin/import/clothing/import-clothing-client.tsx +++ b/src/app/admin/import/clothing/import-clothing-client.tsx @@ -11,7 +11,7 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; @@ -103,6 +103,7 @@ function LibsView() { total: number; } | null>(null); const [busyLib, setBusyLib] = useState(null); + const batchAbortRef = useRef(null); const fetchItems = useCallback(async (search: string, pageNum: number) => { setLoading(true); @@ -116,7 +117,7 @@ function LibsView() { setError(data.error || "Failed to load"); return; } - setItems(data.item || []); + setItems(data.items || []); setTotal(data.meta?.total || 0); setSelected(new Set()); } catch { @@ -168,24 +169,41 @@ function LibsView() { } async function importViaSse(toImport: FigureItem[]) { + batchAbortRef.current?.abort(); + const abort = new AbortController(); + batchAbortRef.current = abort; setBatchProgress({ done: 0, total: toImport.length }); let done = 0; - await runSseImport( - "/api/admin/import/clothing/batch", - { items: toImport, concurrency: 3 }, - (label) => { - done++; - markDone(label); - setBatchProgress({ done, total: toImport.length }); - }, - (succeeded, failed) => { - setBatchProgress(null); - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} libraries imported`); - if (failed > 0) toast.error(`${failed} failed`); - }, - ); - setBatchProgress(null); + try { + await runSseImport( + "/api/admin/import/clothing/batch", + { items: toImport, concurrency: 3 }, + (label) => { + done++; + markDone(label); + setBatchProgress({ done, total: toImport.length }); + }, + (succeeded, failed) => { + setSelected(new Set()); + if (succeeded > 0) toast.success(`${succeeded} libraries imported`); + if (failed > 0) toast.error(`${failed} failed`); + }, + abort.signal, + () => { + done++; + setBatchProgress({ done, total: toImport.length }); + }, + ); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + toast.info("Import cancelled"); + } else { + throw err; + } + } finally { + if (batchAbortRef.current === abort) batchAbortRef.current = null; + setBatchProgress(null); + } } async function importOne(p: FigureItem) { @@ -327,9 +345,19 @@ function LibsView() { )} {batchProgress && ( - - {batchProgress.done}/{batchProgress.total} - + <> + + {batchProgress.done}/{batchProgress.total} + + + )} )} @@ -476,6 +504,7 @@ function SetsView() { total: number; } | null>(null); const [busyKey, setBusyKey] = useState(null); + const batchAbortRef = useRef(null); function itemKey(it: ClothingSetItem): string { return `${it.setType}-${it.setId}`; @@ -493,7 +522,7 @@ function SetsView() { setError(data.error || "Failed to load"); return; } - setItems(data.item || []); + setItems(data.items || []); setTotal(data.meta?.total || 0); setSelected(new Set()); } catch { @@ -542,27 +571,47 @@ function SetsView() { } async function importViaSse(toImport: ClothingSetItem[]) { + batchAbortRef.current?.abort(); + const abort = new AbortController(); + batchAbortRef.current = abort; setBatchProgress({ done: 0, total: toImport.length }); let done = 0; - await runSseImport( - "/api/admin/import/clothing/sets/batch", - { - items: toImport.map((it) => ({ setType: it.setType, setId: it.setId })), - concurrency: 3, - }, - (label) => { - done++; - markDone(label); - setBatchProgress({ done, total: toImport.length }); - }, - (succeeded, failed) => { - setBatchProgress(null); - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} sets imported`); - if (failed > 0) toast.error(`${failed} failed`); - }, - ); - setBatchProgress(null); + try { + await runSseImport( + "/api/admin/import/clothing/sets/batch", + { + items: toImport.map((it) => ({ + setType: it.setType, + setId: it.setId, + })), + concurrency: 3, + }, + (label) => { + done++; + markDone(label); + setBatchProgress({ done, total: toImport.length }); + }, + (succeeded, failed) => { + setSelected(new Set()); + if (succeeded > 0) toast.success(`${succeeded} sets imported`); + if (failed > 0) toast.error(`${failed} failed`); + }, + abort.signal, + () => { + done++; + setBatchProgress({ done, total: toImport.length }); + }, + ); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + toast.info("Import cancelled"); + } else { + throw err; + } + } finally { + if (batchAbortRef.current === abort) batchAbortRef.current = null; + setBatchProgress(null); + } } async function importOne(it: ClothingSetItem) { @@ -684,9 +733,19 @@ function SetsView() { )} {batchProgress && ( - - {batchProgress.done}/{batchProgress.total} - + <> + + {batchProgress.done}/{batchProgress.total} + + + )} )} @@ -838,7 +897,7 @@ export function ImportClothingClient() { className="h-7 text-xs" onClick={() => setMode("libs")} > - Librerie + Libraries diff --git a/src/app/admin/import/effects/import-effects-client.tsx b/src/app/admin/import/effects/import-effects-client.tsx index 78d2ae1d..4451d902 100644 --- a/src/app/admin/import/effects/import-effects-client.tsx +++ b/src/app/admin/import/effects/import-effects-client.tsx @@ -13,12 +13,13 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; +import { runSseImport } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; interface EffectItem { @@ -44,6 +45,7 @@ export function ImportEffectsClient() { total: number; } | null>(null); const [busyLib, setBusyLib] = useState(null); + const batchAbortRef = useRef(null); const fetchEffects = useCallback(async (search: string) => { setLoading(true); @@ -101,51 +103,41 @@ export function ImportEffectsClient() { } async function importViaSse(items: EffectItem[]) { + batchAbortRef.current?.abort(); + const abort = new AbortController(); + batchAbortRef.current = abort; setBatchProgress({ done: 0, total: items.length }); - const res = await adminFetch("/api/admin/import/effects/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items, concurrency: 3 }), - }); - if (!res.body) { - toast.error("No response stream"); - setBatchProgress(null); - return; - } - const reader = res.body.getReader(); - const decoder = new TextDecoder(); - let buf = ""; let done = 0; - let succeeded = 0; - let failed = 0; - while (true) { - const { value, done: streamDone } = await reader.read(); - if (streamDone) break; - buf += decoder.decode(value, { stream: true }); - const parts = buf.split("\n\n"); - buf = parts.pop() ?? ""; - for (const part of parts) { - if (!part.startsWith("data: ")) continue; - const evt = JSON.parse(part.slice(6)); - if (evt.type === "item_progress" && evt.status === "done") { + try { + await runSseImport( + "/api/admin/import/effects/batch", + { items, concurrency: 3 }, + (lib) => { done++; - succeeded++; - markDone(evt.classname); + markDone(lib); setBatchProgress({ done, total: items.length }); - } else if (evt.type === "item_progress" && evt.status === "failed") { + }, + (succeeded, failed) => { + setSelected(new Set()); + if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`); + if (failed > 0) toast.error(`${failed} failed`); + }, + abort.signal, + () => { done++; - failed++; setBatchProgress({ done, total: items.length }); - } else if (evt.type === "batch_complete") { - succeeded = evt.succeeded; - failed = evt.failed; - } + }, + ); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + toast.info("Import cancelled"); + } else { + throw err; } + } finally { + if (batchAbortRef.current === abort) batchAbortRef.current = null; + setBatchProgress(null); } - setBatchProgress(null); - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} effect(s) imported`); - if (failed > 0) toast.error(`${failed} failed`); } async function importOne(e: EffectItem) { @@ -304,9 +296,19 @@ export function ImportEffectsClient() { )} {batchProgress && ( - - {batchProgress.done}/{batchProgress.total} - + <> + + {batchProgress.done}/{batchProgress.total} + + + )} )} diff --git a/src/app/admin/import/furni/import-furni-client.tsx b/src/app/admin/import/furni/import-furni-client.tsx index edc871f1..399c83c1 100644 --- a/src/app/admin/import/furni/import-furni-client.tsx +++ b/src/app/admin/import/furni/import-furni-client.tsx @@ -336,7 +336,7 @@ export function ImportFurniClient() { setError(data.error || "Failed to load"); return; } - const loadedItems = data.item || []; + const loadedItems = data.items || []; setItems(loadedItems); setMeta(data.meta || null); setSelected(new Set()); diff --git a/src/app/admin/import/pets/import-pets-client.tsx b/src/app/admin/import/pets/import-pets-client.tsx index 5dfbca10..f2120caf 100644 --- a/src/app/admin/import/pets/import-pets-client.tsx +++ b/src/app/admin/import/pets/import-pets-client.tsx @@ -11,13 +11,13 @@ import { Trash2, X, } from "lucide-react"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { adminFetch } from "@/lib/admin-fetch"; -import { readSseStream } from "@/lib/sse-client"; +import { runSseImport } from "@/lib/sse-client"; import { cn } from "@/lib/utils"; interface PetItem { @@ -51,6 +51,7 @@ export function ImportPetsClient() { total: number; } | null>(null); const [busyLib, setBusyLib] = useState(null); + const batchAbortRef = useRef(null); const fetchPets = useCallback(async (search: string) => { setLoading(true); @@ -99,37 +100,41 @@ export function ImportPetsClient() { } async function importViaSse(items: PetItem[]) { + batchAbortRef.current?.abort(); + const abort = new AbortController(); + batchAbortRef.current = abort; setBatchProgress({ done: 0, total: items.length }); - const res = await adminFetch("/api/admin/import/pets/batch", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items, concurrency: 3 }), - }); - if (!res.body) { - toast.error("No response stream"); - setBatchProgress(null); - return; - } let done = 0; - let succeeded = 0; - let failed = 0; - await readSseStream(res.body, (evt) => { - if (evt.type === "item_progress" && evt.status === "done") { - done++; - markDone(String(evt.classname ?? "")); - setBatchProgress({ done, total: items.length }); - } else if (evt.type === "item_progress" && evt.status === "failed") { - done++; - setBatchProgress({ done, total: items.length }); - } else if (evt.type === "batch_complete") { - succeeded = Number(evt.succeeded ?? 0); - failed = Number(evt.failed ?? 0); + try { + await runSseImport( + "/api/admin/import/pets/batch", + { items, concurrency: 3 }, + (lib) => { + done++; + markDone(lib); + setBatchProgress({ done, total: items.length }); + }, + (succeeded, failed) => { + setSelected(new Set()); + if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`); + if (failed > 0) toast.error(`${failed} failed`); + }, + abort.signal, + () => { + done++; + setBatchProgress({ done, total: items.length }); + }, + ); + } catch (err) { + if ((err as Error)?.name === "AbortError") { + toast.info("Import cancelled"); + } else { + throw err; } - }); - setBatchProgress(null); - setSelected(new Set()); - if (succeeded > 0) toast.success(`${succeeded} pet(s) imported`); - if (failed > 0) toast.error(`${failed} failed`); + } finally { + if (batchAbortRef.current === abort) batchAbortRef.current = null; + setBatchProgress(null); + } } async function importOne(p: PetItem) { @@ -271,9 +276,19 @@ export function ImportPetsClient() { )} {batchProgress && ( - - {batchProgress.done}/{batchProgress.total} - + <> + + {batchProgress.done}/{batchProgress.total} + + + )} )} diff --git a/src/lib/services/clothing-set-import.test.ts b/src/lib/services/clothing-set-import.test.ts index 59092fa5..dc38c8cf 100644 --- a/src/lib/services/clothing-set-import.test.ts +++ b/src/lib/services/clothing-set-import.test.ts @@ -87,4 +87,20 @@ describe("importClothingSet", () => { const r = await importClothingSet({ setType: "ch", setId: 999999 }); expect(r.ok).toBe(false); }); + + it("does not write set data when a required library import fails", async () => { + mergeSet.mockClear(); + mergePalette.mockClear(); + importSingleFigure.mockResolvedValueOnce({ + ok: false, + lib: "shirt_M_x", + warnings: [], + error: "nitro missing", + }); + const r = await importClothingSet({ setType: "ch", setId: 5000 }); + expect(r.ok).toBe(false); + expect(r.error).toMatch(/required figure libraries failed/i); + expect(mergeSet).not.toHaveBeenCalled(); + expect(mergePalette).not.toHaveBeenCalled(); + }); }); diff --git a/src/lib/services/clothing-set-import.ts b/src/lib/services/clothing-set-import.ts index b5cb50ad..b87c5b42 100644 --- a/src/lib/services/clothing-set-import.ts +++ b/src/lib/services/clothing-set-import.ts @@ -133,6 +133,8 @@ export async function importClothingSet(params: { await ensureFigureDirs(); + let hardFail = false; + for (const libId of requiredLibIds) { const officialLib = officialLibs.find((l) => l.id === libId); if (!officialLib) continue; @@ -141,18 +143,31 @@ export async function importClothingSet(params: { onProgress?.(`importing library ${libId}`); const result = await importSingleFigure({ lib: libId, onProgress }); if (!result.ok) { + hardFail = true; warnings.push( `Library import failed for ${libId}: ${result.error ?? "unknown error"}`, ); + continue; } if (result.warnings.length) { warnings.push(...result.warnings); } + localLibIds.add(libId); } await mergeLibrary(officialLib); } + if (hardFail) { + return { + ok: false, + setId, + warnings, + error: + "One or more required figure libraries failed to import — set was not written", + }; + } + const mappedSet = mapOfficialSet(set); await mergeSet(setType, st.paletteId, mappedSet as never); diff --git a/src/lib/sse-client.ts b/src/lib/sse-client.ts index fddc4793..eb05ff3e 100644 --- a/src/lib/sse-client.ts +++ b/src/lib/sse-client.ts @@ -50,6 +50,7 @@ export async function runSseImport( onDone: (classname: string) => void, onComplete: (succeeded: number, failed: number) => void, signal?: AbortSignal, + onFailed?: (classname: string, error?: string) => void, ): Promise { const res = await adminFetch(url, { method: "POST", @@ -57,8 +58,19 @@ export async function runSseImport( body: JSON.stringify(body), signal, }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + toast.error( + typeof data.error === "string" + ? data.error + : `Import failed (${res.status})`, + ); + onComplete(0, 0); + return; + } if (!res.body) { toast.error("No response stream"); + onComplete(0, 0); return; } @@ -68,11 +80,13 @@ export async function runSseImport( await readSseStream( res.body, (evt) => { - if ( - evt.type === "item_progress" && - (evt.status === "done" || evt.status === "failed") - ) { - onDone(String(evt.classname ?? "")); + if (evt.type === "item_progress") { + const classname = String(evt.classname ?? ""); + if (evt.status === "done") { + onDone(classname); + } else if (evt.status === "failed") { + onFailed?.(classname, String(evt.error ?? "")); + } } if (evt.type === "batch_complete") { succeeded = Number(evt.succeeded ?? 0);