fix: rewrite repair-icons with proper error handling and progress
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m38s

- Wrap all DB/file operations in try-catch, send error events via SSE
- Report skipped/progress events for items that already have icons
- Add 'started' event with total count so the UI shows real-time progress
- Catch route-level errors and stream them instead of returning JSON
- Use log line content as React key instead of array index
This commit is contained in:
openhands committed 2026-07-21 15:04:43 +02:00
1 parent 13cb5f8670
commit 90107c83d5
3 files changed
+160 -65

No files matched your search

@@ -1,6 +1,6 @@
"use client"; "use client";
import { ImageIcon, Loader2, Wrench } from "lucide-react"; import { Check, ImageIcon, Loader2, Wrench } from "lucide-react";
import { useCallback, useState } from "react"; import { useCallback, useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -10,8 +10,13 @@ interface RepairEvent {
type: string; type: string;
classname?: string; classname?: string;
status?: string; status?: string;
index?: number;
total?: number;
succeeded?: number; succeeded?: number;
failed?: number; failed?: number;
skipped?: number;
message?: string;
warnings?: string[];
} }
export function RepairIconsClient() { export function RepairIconsClient() {
@@ -24,6 +29,7 @@ export function RepairIconsClient() {
const [result, setResult] = useState<{ const [result, setResult] = useState<{
succeeded: number; succeeded: number;
failed: number; failed: number;
skipped: number;
} | null>(null); } | null>(null);
const startRepair = useCallback(async () => { const startRepair = useCallback(async () => {
@@ -46,9 +52,10 @@ export function RepairIconsClient() {
const reader = res.body.getReader(); const reader = res.body.getReader();
const decoder = new TextDecoder(); const decoder = new TextDecoder();
let buf = ""; let buf = "";
let total = 0;
let succeeded = 0; let succeeded = 0;
let failed = 0; let failed = 0;
let totalItems = 0; let skipped = 0;
while (true) { while (true) {
const { value, done: streamDone } = await reader.read(); const { value, done: streamDone } = await reader.read();
@@ -60,22 +67,47 @@ export function RepairIconsClient() {
if (!part.startsWith("data: ")) continue; if (!part.startsWith("data: ")) continue;
try { try {
const evt = JSON.parse(part.slice(6)) as RepairEvent; const evt = JSON.parse(part.slice(6)) as RepairEvent;
if (
evt.type === "item_progress" && if (evt.type === "started") {
(evt.status === "done" || evt.status === "failed") total = evt.total ?? 0;
) { setProgress({ done: 0, total });
totalItems++; setLog((prev) => [...prev, `Scanning ${total} furniture items…`]);
if (evt.status === "done") succeeded++; }
else failed++;
setProgress({ done: succeeded + failed, total: totalItems }); if (evt.type === "progress") {
const processed = (evt.index ?? 0) + 1;
setProgress({ done: processed, total });
if (evt.status === "skipped") {
skipped++;
} else if (evt.status === "done") {
succeeded++;
} else if (evt.status === "failed") {
failed++;
}
const icon =
evt.status === "done"
? "✓"
: evt.status === "failed"
? "✗"
: "–";
setLog((prev) => [ setLog((prev) => [
...prev, ...prev,
`${evt.status === "done" ? "✓" : "✗"} ${evt.classname ?? ""}`, `${icon} ${evt.classname ?? ""} (${evt.status})`,
]); ]);
} }
if (evt.type === "batch_complete") { if (evt.type === "batch_complete") {
succeeded = evt.succeeded ?? succeeded; succeeded = evt.succeeded ?? succeeded;
failed = evt.failed ?? failed; failed = evt.failed ?? failed;
skipped = evt.skipped ?? skipped;
}
if (evt.type === "error") {
setLog((prev) => [
...prev,
`ERROR: ${evt.message ?? "Unknown error"}`,
]);
toast.error(evt.message ?? "Repair failed");
} }
} catch { } catch {
// skip parse errors // skip parse errors
@@ -83,8 +115,8 @@ export function RepairIconsClient() {
} }
} }
setResult({ succeeded, failed }); setResult({ succeeded, failed, skipped });
if (succeeded > 0) toast.success(`${succeeded} icons repaired`); if (succeeded > 0) toast.success(`${succeeded} icons downloaded`);
if (failed > 0) toast.error(`${failed} icons could not be obtained`); if (failed > 0) toast.error(`${failed} icons could not be obtained`);
} catch { } catch {
toast.error("Repair request failed"); toast.error("Repair request failed");
@@ -110,8 +142,7 @@ export function RepairIconsClient() {
{progress && ( {progress && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Processed {progress.done} items Processed {progress.done} / {progress.total} items
{progress.total > 0 ? ` out of ${progress.total}` : ""}
</div> </div>
)} )}
@@ -128,19 +159,34 @@ export function RepairIconsClient() {
{result && ( {result && (
<div className="rounded-xl border bg-card p-5 space-y-3"> <div className="rounded-xl border bg-card p-5 space-y-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ImageIcon className="w-5 h-5 text-[var(--admin-success)]" /> <Check className="w-5 h-5 text-[var(--admin-success)]" />
<span className="text-lg font-semibold">Repair Complete</span> <span className="text-lg font-semibold">Repair Complete</span>
</div> </div>
<div className="text-sm space-y-1"> <div className="text-sm space-y-1">
<p> <p>
<span className="text-muted-foreground">Icons downloaded:</span>{" "} <span className="text-muted-foreground">Downloads:</span>{" "}
{result.succeeded} {result.succeeded}
</p> </p>
<p>
<span className="text-muted-foreground">
Skipped (already had icon):
</span>{" "}
{result.skipped}
</p>
<p> <p>
<span className="text-muted-foreground">Failed:</span>{" "} <span className="text-muted-foreground">Failed:</span>{" "}
{result.failed} {result.failed}
</p> </p>
</div> </div>
<Button
variant="outline"
onClick={() => {
setResult(null);
setLog([]);
}}
>
Run Again
</Button>
</div> </div>
)} )}
@@ -2,6 +2,10 @@ import { withAdmin } from "@/lib/api-handler";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { repairMissingIcons } from "@/lib/services/repair-icons"; import { repairMissingIcons } from "@/lib/services/repair-icons";
function sseEvent(data: unknown): string {
return `data: ${JSON.stringify(data)}\n\n`;
}
export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => { export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
@@ -9,17 +13,26 @@ export const POST = withAdmin({ permission: PERMS.ASSETS_IMPORT }, async () => {
async start(controller) { async start(controller) {
const send = (data: unknown) => { const send = (data: unknown) => {
try { try {
controller.enqueue( controller.enqueue(encoder.encode(sseEvent(data)));
encoder.encode(`data: ${JSON.stringify(data)}\n\n`),
);
} catch { } catch {
/* stream closed */ /* stream closed */
} }
}; };
await repairMissingIcons(send); try {
await repairMissingIcons(send);
} catch (err) {
send({
type: "error",
message: `repairMissingIcons threw: ${(err as Error).message}`,
});
}
controller.close(); try {
controller.close();
} catch {
/* ignore */
}
}, },
}); });
+79 -43
View File
@@ -8,52 +8,81 @@ import { downloadFile } from "@/lib/services/import/core/download";
import { ensureDirectories } from "./furni-import"; import { ensureDirectories } from "./furni-import";
export interface RepairEvent { export interface RepairEvent {
type: "item_progress" | "batch_complete" | "error"; type: "started" | "progress" | "done" | "batch_complete" | "error";
classname?: string; classname?: string;
status?: string; status?: string;
index?: number; index?: number;
total?: number;
message?: string; message?: string;
warnings?: string[]; warnings?: string[];
succeeded?: number; succeeded?: number;
failed?: number; failed?: number;
skipped?: number;
duration?: number; duration?: number;
} }
export async function repairMissingIcons( export async function repairMissingIcons(
onEvent?: (evt: RepairEvent) => void, onEvent?: (evt: RepairEvent) => void,
): Promise<{ succeeded: number; failed: number }> { ): Promise<{ succeeded: number; failed: number; skipped: number }> {
await ensureDirectories();
const { iconDir, nitroDir } = await getFurniAssetDirs();
const items = await prisma.$queryRaw<
Array<{ id: number; item_name: string }>
>`SELECT id, item_name FROM items_base ORDER BY id`;
if (items.length === 0) {
onEvent?.({
type: "batch_complete",
succeeded: 0,
failed: 0,
duration: 0,
});
return { succeeded: 0, failed: 0 };
}
let succeeded = 0; let succeeded = 0;
let failed = 0; let failed = 0;
let skipped = 0;
try {
await ensureDirectories();
} catch (err) {
onEvent?.({
type: "error",
message: `ensureDirectories failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
let iconDir: string;
let nitroDir: string;
try {
const dirs = await getFurniAssetDirs();
iconDir = dirs.iconDir;
nitroDir = dirs.nitroDir;
} catch (err) {
onEvent?.({
type: "error",
message: `getFurniAssetDirs failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
let items: Array<{ id: number; item_name: string }>;
try {
items = await prisma.$queryRaw<
Array<{ id: number; item_name: string }>
>`SELECT id, item_name FROM items_base ORDER BY id`;
} catch (err) {
onEvent?.({
type: "error",
message: `Database query failed: ${(err as Error).message}`,
});
return { succeeded: 0, failed: 0, skipped: 0 };
}
const total = items.length;
onEvent?.({ type: "started", total });
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
const { item_name: classname } = items[i]; const { item_name: classname } = items[i];
const iconPath = path.join(iconDir, `${classname}_icon.png`); const iconPath = path.join(iconDir, `${classname}_icon.png`);
if (existsSync(iconPath)) continue; if (existsSync(iconPath)) {
skipped++;
onEvent?.({ onEvent?.({
type: "item_progress", type: "progress",
classname, classname,
status: "started", status: "skipped",
index: i, index: i,
}); total,
});
continue;
}
let ok = false; let ok = false;
const warnings: string[] = []; const warnings: string[] = [];
@@ -77,40 +106,46 @@ export async function repairMissingIcons(
} }
if (!ok) { if (!ok) {
const sources = await listSources(); try {
for (const source of sources) { const sources = await listSources();
try { for (const source of sources) {
const dl = await downloadFile( try {
`${source.iconBaseUrl}/${classname}_icon.png`, const dl = await downloadFile(
iconPath, `${source.iconBaseUrl}/${classname}_icon.png`,
{ maxRetries: 1, validate: "png" }, iconPath,
); { maxRetries: 1, validate: "png" },
if (dl.ok) { );
ok = true; if (dl.ok) {
break; ok = true;
break;
}
} catch {
warnings.push(`download from ${source.name} failed`);
} }
} catch {
warnings.push(`download from ${source.name} failed`);
} }
} catch (err) {
warnings.push(`listSources failed: ${(err as Error).message}`);
} }
} }
if (ok) { if (ok) {
succeeded++; succeeded++;
onEvent?.({ onEvent?.({
type: "item_progress", type: "progress",
classname, classname,
status: "done", status: "done",
index: i, index: i,
total,
warnings: warnings.length ? warnings : undefined, warnings: warnings.length ? warnings : undefined,
}); });
} else { } else {
failed++; failed++;
onEvent?.({ onEvent?.({
type: "item_progress", type: "progress",
classname, classname,
status: "failed", status: "failed",
index: i, index: i,
total,
message: warnings[0] || "could not obtain icon", message: warnings[0] || "could not obtain icon",
}); });
} }
@@ -120,7 +155,8 @@ export async function repairMissingIcons(
type: "batch_complete", type: "batch_complete",
succeeded, succeeded,
failed, failed,
skipped,
}); });
return { succeeded, failed }; return { succeeded, failed, skipped };
} }