feat: parallelize audit repair and add nitro/SQL repair options
- 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:
1 parent
0c8e62357f
commit
5308ce6a12
8 files changed
+1166
-58
No files matched your search
@@ -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);
|
||||
}}
|
||||
|
||||
Reference in new issue
Block a user