From bebd65c056198c60bc1e3e1fee187950d864ec68 Mon Sep 17 00:00:00 2001 From: openhands Date: Tue, 21 Jul 2026 15:33:15 +0200 Subject: [PATCH] fix: refactor audit to SSE streaming, improve error handling - Changed from blocking JSON endpoint to SSE streaming (like sync-all/repair-icons) - Progress updates during each audit phase with item counts - Proper error handling with typed AuditEvent for every failure path - AbortController support for the client - Shows real-time progress for each check section --- src/app/admin/import/audit/audit-client.tsx | 307 +++++++++++++------- src/app/api/admin/import/audit/route.ts | 47 ++- src/lib/services/catalog-audit.ts | 293 +++++++++++++------ 3 files changed, 444 insertions(+), 203 deletions(-) diff --git a/src/app/admin/import/audit/audit-client.tsx b/src/app/admin/import/audit/audit-client.tsx index ced701d7..64c918f3 100644 --- a/src/app/admin/import/audit/audit-client.tsx +++ b/src/app/admin/import/audit/audit-client.tsx @@ -8,7 +8,7 @@ import { Loader2, SearchX, } from "lucide-react"; -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { adminFetch } from "@/lib/admin-fetch"; @@ -30,26 +30,27 @@ interface MissingFromSource { itemName: string; } -interface AuditResult { - issues: AuditIssue[]; - missingFromSources: MissingFromSource[]; - summary: { - totalItemsBase: number; - totalCatalogItems: number; - missingCatalog: number; - orphanedCatalog: number; - missingNitro: number; - missingIcon: number; - duplicates: number; - missingFromSourcesTotal: number; - }; +interface AuditSummary { + totalItemsBase: number; + totalCatalogItems: number; + missingCatalog: number; + orphanedCatalog: number; + missingNitro: number; + missingIcon: number; + duplicates: number; + missingFromSourcesTotal: number; } -const ICON_COLORS: Record = { - error: "text-[var(--admin-error)]", - warning: "text-[var(--admin-warning)]", - info: "text-[var(--admin-text-muted)]", -}; +interface AuditEvent { + type: string; + message?: string; + current?: number; + total?: number; + section?: string; + issues?: AuditIssue[]; + missingFromSources?: MissingFromSource[]; + summary?: AuditSummary; +} const TYPE_LABELS: Record = { missing_catalog: "No catalog entry", @@ -58,53 +59,137 @@ const TYPE_LABELS: Record = { missing_icon: "Missing icon file", duplicate_classname: "Duplicate classname", source_fetch_failed: "Source fetch failed", - sources_list_failed: "Sources list failed", }; export function AuditClient() { const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); const [progress, setProgress] = useState(""); const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues"); + const [issues, setIssues] = useState([]); + const [missingFromSources, setMissingFromSources] = useState< + MissingFromSource[] + >([]); + const [summary, setSummary] = useState(null); + + const [checkProgress, setCheckProgress] = useState<{ + section: string; + current: number; + total: number; + } | null>(null); + + const abortRef = useRef(null); + const runAudit = useCallback(async () => { setLoading(true); - setResult(null); setProgress("Starting audit…"); + setIssues([]); + setMissingFromSources([]); + setSummary(null); + setCheckProgress(null); + setActiveTab("issues"); + + const abort = new AbortController(); + abortRef.current = abort; try { const res = await adminFetch("/api/admin/import/audit", { method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + signal: abort.signal, }); - if (!res.ok) { - const data = await res.json().catch(() => ({})); - toast.error(data.error || "Audit failed"); - setProgress(""); + if (!res.body) { + toast.error("No response stream"); setLoading(false); return; } - const data = await res.json(); - setResult(data); - toast.success("Audit complete"); - } catch { - toast.error("Audit request failed"); + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let buf = ""; + + while (true) { + const { value, done } = await reader.read(); + if (done) 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; + try { + const evt = JSON.parse(part.slice(6)) as AuditEvent; + + if (evt.type === "progress") { + setProgress(evt.message ?? ""); + } + + if (evt.type === "items_loaded") { + setProgress(`Loaded ${evt.total} items_base entries`); + } + + if (evt.type === "catalog_loaded") { + setProgress(`Loaded ${evt.total} catalog_items entries`); + } + + if (evt.type === "checking") { + setProgress(`Checking ${evt.section}…`); + if (evt.total) { + setCheckProgress({ + section: evt.section ?? "", + current: evt.current ?? 0, + total: evt.total, + }); + } + } + + if (evt.type === "batch_complete") { + setIssues(evt.issues ?? []); + setMissingFromSources(evt.missingFromSources ?? []); + if (evt.summary) setSummary(evt.summary); + setCheckProgress(null); + setProgress("Done"); + if ( + evt.issues && + evt.issues.filter((i) => i.severity === "error").length > 0 + ) { + toast.error( + `${evt.issues.filter((i) => i.severity === "error").length} error(s) found`, + ); + } else if ( + evt.issues && + evt.issues.filter((i) => i.severity === "warning").length > 0 + ) { + toast.warning("Warnings found"); + } else { + toast.success("No issues found"); + } + } + + if (evt.type === "error") { + setProgress(`ERROR: ${evt.message ?? ""}`); + toast.error(evt.message ?? "Audit failed"); + } + } catch { + // skip parse errors + } + } + } + } catch (err) { + if ((err as Error).name !== "AbortError") { + toast.error("Audit request failed"); + } } finally { setLoading(false); - setProgress(""); + abortRef.current = null; } }, []); - const hasIssues = result && result.issues.length > 0; - const errors = result?.issues.filter((i) => i.severity === "error") ?? []; - const warnings = result?.issues.filter((i) => i.severity === "warning") ?? []; + const errors = issues.filter((i) => i.severity === "error"); + const warnings = issues.filter((i) => i.severity === "warning"); return (
- {!result && ( + {!summary && ( )} - {result && ( + {loading && checkProgress && ( +
+ {checkProgress.section}: {checkProgress.current} /{" "} + {checkProgress.total} +
+ )} + + {summary && ( <> {/* Summary cards */}
-
- {result.summary.totalItemsBase} -
+
{summary.totalItemsBase}
items_base entries
- {result.summary.totalCatalogItems} + {summary.totalCatalogItems}
catalog_items entries @@ -160,64 +250,60 @@ export function AuditClient() { {/* Quick stats */}
- {result.summary.missingCatalog > 0 ? ( + {summary.missingCatalog > 0 ? ( + + ) : ( + + )} + {summary.missingCatalog} items without catalog entry +
+
+ {summary.orphanedCatalog > 0 ? ( + + ) : ( + + )} + {summary.orphanedCatalog} orphaned catalog references +
+
+ {summary.missingNitro > 0 ? ( + + ) : ( + + )} + {summary.missingNitro} items without .nitro +
+
+ {summary.missingIcon > 0 ? ( + + ) : ( + + )} + {summary.missingIcon} items without icon +
+
+ {summary.duplicates > 0 ? ( + + ) : ( + + )} + {summary.duplicates} duplicate classnames +
+
+ {summary.missingFromSourcesTotal > 0 ? ( ) : ( )} - {result.summary.missingCatalog} items without catalog entry - -
-
- {result.summary.orphanedCatalog > 0 ? ( - - ) : ( - - )} - - {result.summary.orphanedCatalog} orphaned catalog references - -
-
- {result.summary.missingNitro > 0 ? ( - - ) : ( - - )} - {result.summary.missingNitro} items without .nitro -
-
- {result.summary.missingIcon > 0 ? ( - - ) : ( - - )} - {result.summary.missingIcon} items without icon -
-
- {result.summary.duplicates > 0 ? ( - - ) : ( - - )} - {result.summary.duplicates} duplicate classnames -
-
- {result.summary.missingFromSourcesTotal > 0 ? ( - - ) : ( - - )} - - {result.summary.missingFromSourcesTotal} items missing from - clone sources + {summary.missingFromSourcesTotal} items missing from clone + sources
{/* Tabs */} - {hasIssues || result.missingFromSources.length > 0 ? ( + {issues.length > 0 || missingFromSources.length > 0 ? (
) : null} {/* Issues list */} - {activeTab === "issues" && hasIssues && ( + {activeTab === "issues" && issues.length > 0 && (
- {result.issues.map((issue) => ( + {issues.map((issue) => (
{TYPE_LABELS[issue.type] || issue.type} @@ -291,9 +389,9 @@ export function AuditClient() { )} {/* Missing from sources list */} - {activeTab === "sources" && result.missingFromSources.length > 0 && ( + {activeTab === "sources" && missingFromSources.length > 0 && (
- {result.missingFromSources.map((item) => ( + {missingFromSources.map((item) => (

No issues found

@@ -324,8 +422,11 @@ export function AuditClient() {