feat: parallelize audit repair and add nitro/SQL repair options
CI / check (push) Successful in 48s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m55s

- Parallelize icon and nitro downloads in the audit repair with a
  sliding-window worker pool (6 concurrent) to speed up large catalogs
- Add repairMissingNitros: fetch missing .nitro bundles from configured
  nitro sources (Wibbo default), validating each bundle before writing
- Add catalog-repair service: generate/apply catalog_items SQL for furni
  missing a catalog entry and repair FurnitureData.json (add missing +
  dedupe classnames)
- Wire all options through the audit API and client UI with live progress
  and result stats (icons, nitros, SQL, furnidata)
- Add Wibbo as default nitro source alongside existing icon sources
- Ignore runtime furni assets downloaded into public/ during repair
This commit is contained in:
openhands committed 2026-08-02 19:12:28 +02:00
1 parent 0c8e62357f
commit 5308ce6a12
8 files changed
+1166 -58

No files matched your search

+279 -1
View File
@@ -2,6 +2,7 @@
import {
AlertCircle,
Check,
CheckCircle2,
ClipboardList,
ExternalLink,
@@ -39,6 +40,21 @@ interface AuditSummary {
missingIcon: number;
duplicates: number;
missingFromSourcesTotal: number;
iconsRepaired: number;
iconsStillMissing: number;
nitrosRepaired: number;
nitrosStillMissing: number;
catalogSqlCount: number;
catalogSqlApplied: number;
furniDataAdded: number;
furniDataDuplicatesRemoved: number;
}
interface RepairStats {
succeeded: number;
failed: number;
skipped: number;
stillMissing: number;
}
interface AuditEvent {
@@ -50,6 +66,13 @@ interface AuditEvent {
issues?: AuditIssue[];
missingFromSources?: MissingFromSource[];
summary?: AuditSummary;
repair?: RepairStats;
repairNitro?: RepairStats;
sql?: string;
sqlCount?: number;
applied?: number;
sqlFailed?: number;
furniData?: { added: number; removedDuplicates: number };
}
const TYPE_LABELS: Record<string, string> = {
@@ -61,16 +84,72 @@ const TYPE_LABELS: Record<string, string> = {
source_fetch_failed: "Source fetch failed",
};
function AuditCheckbox({
checked,
disabled,
onChange,
children,
}: {
checked: boolean;
disabled?: boolean;
onChange: (value: boolean) => void;
children: React.ReactNode;
}) {
return (
// biome-ignore lint/a11y/useSemanticElements: custom checkbox so the checked state shows a visible checkmark on every admin theme
<button
type="button"
role="checkbox"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className={`flex items-center gap-2 text-sm text-muted-foreground cursor-pointer select-none ${
disabled ? "opacity-50 cursor-not-allowed" : ""
}`}
>
<span
aria-hidden
className={`inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-[3px] border-2 transition-colors ${
checked
? "border-[var(--admin-accent)] bg-[var(--admin-accent)]"
: "border-[var(--admin-border)] bg-transparent"
}`}
>
{checked && <Check className="h-3 w-3 text-[var(--admin-surface)]" />}
</span>
<span className="pointer-events-none">{children}</span>
</button>
);
}
export function AuditClient() {
const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState("");
const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues");
const [repairEnabled, setRepairEnabled] = useState(true);
const [repairNitros, setRepairNitros] = useState(true);
const [generateSql, setGenerateSql] = useState(false);
const [applySql, setApplySql] = useState(false);
const [repairFurniData, setRepairFurniData] = useState(false);
const [issues, setIssues] = useState<AuditIssue[]>([]);
const [missingFromSources, setMissingFromSources] = useState<
MissingFromSource[]
>([]);
const [summary, setSummary] = useState<AuditSummary | null>(null);
const [repairStats, setRepairStats] = useState<RepairStats | null>(null);
const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>(
null,
);
const [sqlText, setSqlText] = useState<string | null>(null);
const [sqlApplied, setSqlApplied] = useState<{
applied: number;
failed: number;
} | null>(null);
const [furniDataResult, setFurniDataResult] = useState<{
added: number;
removedDuplicates: number;
} | null>(null);
const [checkProgress, setCheckProgress] = useState<{
section: string;
@@ -86,6 +165,11 @@ export function AuditClient() {
setIssues([]);
setMissingFromSources([]);
setSummary(null);
setRepairStats(null);
setNitroRepairStats(null);
setSqlText(null);
setSqlApplied(null);
setFurniDataResult(null);
setCheckProgress(null);
setActiveTab("issues");
@@ -96,6 +180,14 @@ export function AuditClient() {
const res = await adminFetch("/api/admin/import/audit", {
method: "POST",
signal: abort.signal,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
repair: repairEnabled,
repairNitros,
sql: generateSql || applySql,
applySql,
repairFurniData,
}),
});
if (!res.body) {
@@ -155,6 +247,26 @@ export function AuditClient() {
}
}
if (evt.type === "repair_complete") {
if (evt.repair) setRepairStats(evt.repair);
if (evt.repairNitro) setNitroRepairStats(evt.repairNitro);
}
if (evt.type === "sql_ready") {
setSqlText(evt.sql ?? null);
}
if (evt.type === "sql_applied") {
setSqlApplied({
applied: evt.applied ?? 0,
failed: evt.sqlFailed ?? 0,
});
}
if (evt.type === "furnidata_repair_complete") {
if (evt.furniData) setFurniDataResult(evt.furniData);
}
if (evt.type === "batch_complete") {
setIssues(evt.issues ?? []);
setMissingFromSources(evt.missingFromSources ?? []);
@@ -197,11 +309,22 @@ export function AuditClient() {
setLoading(false);
abortRef.current = null;
}
}, []);
}, [repairEnabled, repairNitros, generateSql, applySql, repairFurniData]);
const errors = issues.filter((i) => i.severity === "error");
const warnings = issues.filter((i) => i.severity === "warning");
const downloadSql = () => {
if (!sqlText) return;
const blob = new Blob([sqlText], { type: "text/sql" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `catalog-repair-${new Date().toISOString().slice(0, 10)}.sql`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div className="space-y-6">
{!summary && (
@@ -214,6 +337,41 @@ export function AuditClient() {
)}
{loading ? progress || "Running…" : "Run Full Catalog Audit"}
</Button>
<AuditCheckbox
checked={repairEnabled}
disabled={loading}
onChange={setRepairEnabled}
>
Repair missing icons
</AuditCheckbox>
<AuditCheckbox
checked={repairNitros}
disabled={loading}
onChange={setRepairNitros}
>
Repair missing .nitro
</AuditCheckbox>
<AuditCheckbox
checked={generateSql}
disabled={loading}
onChange={setGenerateSql}
>
Generate catalog SQL
</AuditCheckbox>
<AuditCheckbox
checked={applySql}
disabled={loading}
onChange={setApplySql}
>
Apply SQL to database
</AuditCheckbox>
<AuditCheckbox
checked={repairFurniData}
disabled={loading}
onChange={setRepairFurniData}
>
Repair FurnitureData.json (add missing + dedupe)
</AuditCheckbox>
{loading && (
<Button
variant="outline"
@@ -328,6 +486,121 @@ export function AuditClient() {
sources
</span>
</div>
{repairStats && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Icon repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{repairStats.succeeded} fixed
</span>
{repairStats.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{repairStats.failed} failed
</span>
</>
)}
{repairStats.skipped > 0 && (
<>
{" · "}
<span className="text-[var(--admin-text-muted)]">
{repairStats.skipped} skipped
</span>
</>
)}
{repairStats.stillMissing > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{repairStats.stillMissing} still missing
</span>
</>
)}
</span>
</div>
)}
{nitroRepairStats && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Nitro repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{nitroRepairStats.succeeded} fixed
</span>
{nitroRepairStats.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{nitroRepairStats.failed} failed
</span>
</>
)}
{nitroRepairStats.skipped > 0 && (
<>
{" · "}
<span className="text-[var(--admin-text-muted)]">
{nitroRepairStats.skipped} skipped
</span>
</>
)}
{nitroRepairStats.stillMissing > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{nitroRepairStats.stillMissing} still missing
</span>
</>
)}
</span>
</div>
)}
{sqlText && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
Catalog SQL:{" "}
<span className="font-medium">
{summary.catalogSqlCount} statement(s) generated
</span>
{sqlApplied && (
<>
{" · "}
<span className="text-[var(--admin-success)] font-medium">
{sqlApplied.applied} applied
</span>
{sqlApplied.failed > 0 && (
<>
{" · "}
<span className="text-[var(--admin-error)] font-medium">
{sqlApplied.failed} failed
</span>
</>
)}
</>
)}
</span>
<Button variant="outline" size="sm" onClick={downloadSql}>
Download .sql
</Button>
</div>
)}
{furniDataResult && (
<div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground">
FurnitureData repair:{" "}
<span className="text-[var(--admin-success)] font-medium">
{furniDataResult.added} added
</span>
{furniDataResult.removedDuplicates > 0 && (
<>
{" · "}
<span className="text-[var(--admin-warning)] font-medium">
{furniDataResult.removedDuplicates} duplicates removed
</span>
</>
)}
</span>
</div>
)}
</div>
{/* Tabs */}
@@ -455,6 +728,11 @@ export function AuditClient() {
setSummary(null);
setIssues([]);
setMissingFromSources([]);
setRepairStats(null);
setNitroRepairStats(null);
setSqlText(null);
setSqlApplied(null);
setFurniDataResult(null);
setProgress("");
setCheckProgress(null);
}}