fix: rewrite repair-icons with proper error handling and progress

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