feat: group unrepairable legacy items separately in catalog audit
CI / check (push) Successful in 25s
CI / release (push) Skipped
CI / deploy (push) Successful in 1m2s

After a repair attempt, items whose nitro/icon assets cannot be restored
from any source are reclassified from hard errors into a dedicated
'Unrepairable (legacy)' group (info), so a fully repaired catalog can
reach 0 errors while still listing exactly what is not restorable. Adds
an unrepairable summary count and a dedicated tab in the audit UI.
This commit is contained in:
openhands committed 2026-08-04 11:18:37 +02:00
1 parent d587749b50
commit 2ee5ba5c4c
2 files changed
+124 -24

No files matched your search

+72 -13
View File
@@ -32,6 +32,12 @@ interface MissingFromSource {
itemName: string; itemName: string;
} }
interface UnrepairableItem {
classname: string;
itemId: number;
missing: Array<"nitro" | "icon">;
}
interface AuditSummary { interface AuditSummary {
totalItemsBase: number; totalItemsBase: number;
totalCatalogItems: number; totalCatalogItems: number;
@@ -41,6 +47,7 @@ interface AuditSummary {
missingIcon: number; missingIcon: number;
duplicates: number; duplicates: number;
missingFromSourcesTotal: number; missingFromSourcesTotal: number;
unrepairableCount: number;
iconsRepaired: number; iconsRepaired: number;
iconsStillMissing: number; iconsStillMissing: number;
nitrosRepaired: number; nitrosRepaired: number;
@@ -71,6 +78,7 @@ interface AuditEvent {
section?: string; section?: string;
issues?: AuditIssue[]; issues?: AuditIssue[];
missingFromSources?: MissingFromSource[]; missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary; summary?: AuditSummary;
repair?: RepairStats; repair?: RepairStats;
repairNitro?: RepairStats; repairNitro?: RepairStats;
@@ -138,7 +146,9 @@ function AuditCheckbox({
export function AuditClient() { export function AuditClient() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [progress, setProgress] = useState(""); const [progress, setProgress] = useState("");
const [activeTab, setActiveTab] = useState<"issues" | "sources">("issues"); const [activeTab, setActiveTab] = useState<
"issues" | "sources" | "unrepairable"
>("issues");
const [repairEnabled, setRepairEnabled] = useState(true); const [repairEnabled, setRepairEnabled] = useState(true);
const [repairNitros, setRepairNitros] = useState(true); const [repairNitros, setRepairNitros] = useState(true);
const [generateSql, setGenerateSql] = useState(false); const [generateSql, setGenerateSql] = useState(false);
@@ -150,6 +160,7 @@ export function AuditClient() {
const [missingFromSources, setMissingFromSources] = useState< const [missingFromSources, setMissingFromSources] = useState<
MissingFromSource[] MissingFromSource[]
>([]); >([]);
const [unrepairable, setUnrepairable] = useState<UnrepairableItem[]>([]);
const [summary, setSummary] = useState<AuditSummary | null>(null); const [summary, setSummary] = useState<AuditSummary | null>(null);
const [repairStats, setRepairStats] = useState<RepairStats | null>(null); const [repairStats, setRepairStats] = useState<RepairStats | null>(null);
const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>( const [nitroRepairStats, setNitroRepairStats] = useState<RepairStats | null>(
@@ -185,6 +196,7 @@ export function AuditClient() {
setProgress("Starting audit…"); setProgress("Starting audit…");
setIssues([]); setIssues([]);
setMissingFromSources([]); setMissingFromSources([]);
setUnrepairable([]);
setSummary(null); setSummary(null);
setRepairStats(null); setRepairStats(null);
setNitroRepairStats(null); setNitroRepairStats(null);
@@ -298,21 +310,22 @@ export function AuditClient() {
if (evt.type === "batch_complete") { if (evt.type === "batch_complete") {
setIssues(evt.issues ?? []); setIssues(evt.issues ?? []);
setMissingFromSources(evt.missingFromSources ?? []); setMissingFromSources(evt.missingFromSources ?? []);
setUnrepairable(evt.unrepairable ?? []);
if (evt.summary) setSummary(evt.summary); if (evt.summary) setSummary(evt.summary);
setCheckProgress(null); setCheckProgress(null);
setProgress("Done"); setProgress("Done");
if ( const errCount =
evt.issues && evt.issues?.filter((i) => i.severity === "error").length ?? 0;
evt.issues.filter((i) => i.severity === "error").length > 0 const warnCount =
) { evt.issues?.filter((i) => i.severity === "warning").length ??
toast.error( 0;
`${evt.issues.filter((i) => i.severity === "error").length} error(s) found`, const legacyCount = evt.unrepairable?.length ?? 0;
); if (errCount > 0) {
} else if ( toast.error(`${errCount} error(s) found`);
evt.issues && } else if (warnCount > 0) {
evt.issues.filter((i) => i.severity === "warning").length > 0
) {
toast.warning("Warnings found"); toast.warning("Warnings found");
} else if (legacyCount > 0) {
toast.info(`${legacyCount} legacy item(s) can't be repaired`);
} else { } else {
toast.success("No issues found"); toast.success("No issues found");
} }
@@ -529,6 +542,17 @@ export function AuditClient() {
sources sources
</span> </span>
</div> </div>
<div className="flex items-center gap-2">
{summary.unrepairableCount > 0 ? (
<AlertCircle className="w-4 h-4 text-[var(--admin-text-muted)]" />
) : (
<CheckCircle2 className="w-4 h-4 text-[var(--admin-success)]" />
)}
<span>
{summary.unrepairableCount} item(s) can't be repaired
(legacy/broken)
</span>
</div>
{repairStats && ( {repairStats && (
<div className="flex items-center gap-2 col-span-full"> <div className="flex items-center gap-2 col-span-full">
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -690,7 +714,9 @@ export function AuditClient() {
</div> </div>
{/* Tabs */} {/* Tabs */}
{issues.length > 0 || missingFromSources.length > 0 ? ( {issues.length > 0 ||
missingFromSources.length > 0 ||
unrepairable.length > 0 ? (
<div className="border-b border-border"> <div className="border-b border-border">
<div className="flex gap-4"> <div className="flex gap-4">
<button <button
@@ -715,6 +741,17 @@ export function AuditClient() {
> >
Missing from sources ({missingFromSources.length}) Missing from sources ({missingFromSources.length})
</button> </button>
<button
type="button"
onClick={() => setActiveTab("unrepairable")}
className={`pb-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "unrepairable"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Unrepairable (legacy) ({unrepairable.length})
</button>
</div> </div>
</div> </div>
) : null} ) : null}
@@ -796,6 +833,27 @@ export function AuditClient() {
</div> </div>
)} )}
{/* Unrepairable (legacy) list */}
{activeTab === "unrepairable" && unrepairable.length > 0 && (
<div className="space-y-2">
{unrepairable.map((item) => (
<div
key={item.classname}
className="rounded-xl border bg-card p-3 text-sm flex items-center gap-2"
>
<AlertCircle className="w-3.5 h-3.5 text-[var(--admin-text-muted)] shrink-0" />
<code className="font-mono text-xs">{item.classname}</code>
<span className="text-xs text-muted-foreground">
(ID {item.itemId})
</span>
<span className="text-xs text-[var(--admin-warning)] ml-auto">
missing {item.missing.join(" + ")}
</span>
</div>
))}
</div>
)}
{activeTab === "issues" && {activeTab === "issues" &&
issues.length === 0 && issues.length === 0 &&
missingFromSources.length === 0 && ( missingFromSources.length === 0 && (
@@ -814,6 +872,7 @@ export function AuditClient() {
setSummary(null); setSummary(null);
setIssues([]); setIssues([]);
setMissingFromSources([]); setMissingFromSources([]);
setUnrepairable([]);
setRepairStats(null); setRepairStats(null);
setNitroRepairStats(null); setNitroRepairStats(null);
setSqlText(null); setSqlText(null);
+52 -11
View File
@@ -47,6 +47,7 @@ export interface AuditEvent {
section?: string; section?: string;
issues?: AuditIssue[]; issues?: AuditIssue[];
missingFromSources?: MissingFromSource[]; missingFromSources?: MissingFromSource[];
unrepairable?: UnrepairableItem[];
summary?: AuditSummary; summary?: AuditSummary;
repair?: RepairStats; repair?: RepairStats;
repairNitro?: RepairStats; repairNitro?: RepairStats;
@@ -88,6 +89,12 @@ export interface MissingFromSource {
itemName: string; itemName: string;
} }
export interface UnrepairableItem {
classname: string;
itemId: number;
missing: Array<"nitro" | "icon">;
}
export interface AuditSummary { export interface AuditSummary {
totalItemsBase: number; totalItemsBase: number;
totalCatalogItems: number; totalCatalogItems: number;
@@ -97,6 +104,7 @@ export interface AuditSummary {
missingIcon: number; missingIcon: number;
duplicates: number; duplicates: number;
missingFromSourcesTotal: number; missingFromSourcesTotal: number;
unrepairableCount: number;
iconsRepaired: number; iconsRepaired: number;
iconsStillMissing: number; iconsStillMissing: number;
nitrosRepaired: number; nitrosRepaired: number;
@@ -127,6 +135,13 @@ export async function runCatalogAudit(
): Promise<void> { ): Promise<void> {
let issues: AuditIssue[] = []; let issues: AuditIssue[] = [];
const missingFromSources: MissingFromSource[] = []; const missingFromSources: MissingFromSource[] = [];
// Items whose missing assets could not be restored from any source even
// after a repair attempt — legacy / broken furniture. Keyed by classname so
// an item missing both assets is listed once.
const unrepairable = new Map<
string,
{ itemId: number; missing: Set<"nitro" | "icon"> }
>();
try { try {
await ensureDirectories(); await ensureDirectories();
@@ -450,7 +465,8 @@ export async function runCatalogAudit(
}); });
// Re-check which of the previously-missing icons are still missing // Re-check which of the previously-missing icons are still missing
// and drop the ones the repair fixed from the issue list. // and drop the ones the repair fixed from the issue list. The ones
// left over cannot be restored from any source → legacy/broken.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingIconClassnames) { for (const classname of missingIconClassnames) {
const hasIcon = assetNameCandidates(classname).icon.some((n) => const hasIcon = assetNameCandidates(classname).icon.some((n) =>
@@ -460,10 +476,15 @@ export async function runCatalogAudit(
stillMissing.push(classname); stillMissing.push(classname);
} }
} }
issues = issues.filter( issues = issues.filter((i) => i.type !== "missing_icon");
(i) => for (const classname of stillMissing) {
i.type !== "missing_icon" || stillMissing.includes(i.classname ?? ""), const entry = unrepairable.get(classname) ?? {
); itemId: itemByName.get(classname)?.id ?? 0,
missing: new Set(),
};
entry.missing.add("icon");
unrepairable.set(classname, entry);
}
repairStats = { repairStats = {
succeeded: result.succeeded, succeeded: result.succeeded,
@@ -508,7 +529,8 @@ export async function runCatalogAudit(
}); });
// Re-check which of the previously-missing nitros are still missing // Re-check which of the previously-missing nitros are still missing
// and drop the ones the repair fixed from the issue list. // and drop the ones the repair fixed from the issue list. The ones
// left over cannot be restored from any source → legacy/broken.
const stillMissing: string[] = []; const stillMissing: string[] = [];
for (const classname of missingNitroClassnames) { for (const classname of missingNitroClassnames) {
const hasNitro = assetNameCandidates(classname).nitro.some((n) => const hasNitro = assetNameCandidates(classname).nitro.some((n) =>
@@ -518,11 +540,15 @@ export async function runCatalogAudit(
stillMissing.push(classname); stillMissing.push(classname);
} }
} }
issues = issues.filter( issues = issues.filter((i) => i.type !== "missing_nitro");
(i) => for (const classname of stillMissing) {
i.type !== "missing_nitro" || const entry = unrepairable.get(classname) ?? {
stillMissing.includes(i.classname ?? ""), itemId: itemByName.get(classname)?.id ?? 0,
); missing: new Set(),
};
entry.missing.add("nitro");
unrepairable.set(classname, entry);
}
nitroRepairStats = { nitroRepairStats = {
succeeded: result.succeeded, succeeded: result.succeeded,
@@ -720,11 +746,24 @@ export async function runCatalogAudit(
} }
} }
// Items that a repair attempt could not fix are legacy/broken furniture —
// surfaced as a separate informational group (not counted as errors) so a
// fully repaired catalog can reach 0 errors while still listing exactly
// what is not restorable.
issues.sort((a, b) => { issues.sort((a, b) => {
const sev = { error: 0, warning: 1, info: 2 }; const sev = { error: 0, warning: 1, info: 2 };
return (sev[a.severity] ?? 0) - (sev[b.severity] ?? 0); return (sev[a.severity] ?? 0) - (sev[b.severity] ?? 0);
}); });
const unrepairableList: UnrepairableItem[] = [...unrepairable.entries()]
.sort(([a], [b]) => a.localeCompare(b))
.map(([classname, entry]) => ({
classname,
itemId: entry.itemId,
missing: [...entry.missing].sort(),
}));
const summary: AuditSummary = { const summary: AuditSummary = {
totalItemsBase: items.length, totalItemsBase: items.length,
totalCatalogItems: catalogItems.length, totalCatalogItems: catalogItems.length,
@@ -734,6 +773,7 @@ export async function runCatalogAudit(
missingIcon: issues.filter((i) => i.type === "missing_icon").length, missingIcon: issues.filter((i) => i.type === "missing_icon").length,
duplicates: issues.filter((i) => i.type === "duplicate_classname").length, duplicates: issues.filter((i) => i.type === "duplicate_classname").length,
missingFromSourcesTotal: missingFromSources.length, missingFromSourcesTotal: missingFromSources.length,
unrepairableCount: unrepairableList.length,
iconsRepaired: repairStats?.succeeded ?? 0, iconsRepaired: repairStats?.succeeded ?? 0,
iconsStillMissing: iconsStillMissing:
repairStats?.stillMissing ?? missingIconClassnames.length, repairStats?.stillMissing ?? missingIconClassnames.length,
@@ -755,6 +795,7 @@ export async function runCatalogAudit(
type: "batch_complete", type: "batch_complete",
issues, issues,
missingFromSources, missingFromSources,
unrepairable: unrepairableList,
summary, summary,
}); });
} }