From 65c824fe4827910d65425c7e51ded027f8655419 Mon Sep 17 00:00:00 2001 From: Max Jones <14077947+maxrjones@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:13:59 -0400 Subject: [PATCH] feat: add option to validate all conventions --- metazarr/demo/app.js | 75 +++++++++- metazarr/demo/detail-panel.js | 29 +++- metazarr/demo/report-panel.js | 275 ++++++++++++++++++++++++++++++++++ metazarr/demo/style.css | 222 ++++++++++++++++++++++++++- metazarr/src/index.js | 1 + metazarr/src/report.js | 169 +++++++++++++++++++++ metazarr/src/validator.js | 7 +- metazarr/test/report.test.js | 162 ++++++++++++++++++++ 8 files changed, 935 insertions(+), 5 deletions(-) create mode 100644 metazarr/demo/report-panel.js create mode 100644 metazarr/src/report.js create mode 100644 metazarr/test/report.test.js diff --git a/metazarr/demo/app.js b/metazarr/demo/app.js index 9c27248..ded2969 100644 --- a/metazarr/demo/app.js +++ b/metazarr/demo/app.js @@ -5,8 +5,10 @@ import { openStore, CorsError } from "../src/store.js"; import { buildTree, buildTreeFromV3, buildTreeFromCrawl, openNode, insertNode } from "../src/hierarchy.js"; import { detectConventions } from "../src/conventions.js"; +import { validateStore } from "../src/report.js"; import { renderTree, highlightNode } from "./tree.js"; import { renderDetail } from "./detail-panel.js"; +import { renderReport, renderProgressUI } from "./report-panel.js"; // DOM elements const form = document.getElementById("open-form"); @@ -22,6 +24,8 @@ const addPathBtn = document.getElementById("add-path-btn"); // App state let currentStore = null; let currentTree = null; +let currentReport = null; +let currentAbortController = null; // --- URL state --- @@ -95,6 +99,7 @@ async function openStoreFromUrl(url, autoSelectNode) { } updateUrlParams(url, null); + injectValidateAllButton(); // Auto-select node from URL if provided if (autoSelectNode && currentTree) { @@ -174,12 +179,78 @@ backBtn.addEventListener("click", showTreeView); function onNodeSelect(node) { highlightNode(treeContainer, node.path); - const conventions = detectConventions(node.attrs); - renderDetail(node, conventions, detailPanel); + + // If we have a cached report, show a "Back to Report" link + if (currentReport) { + const backLink = document.createElement("button"); + backLink.className = "report-back-link"; + backLink.textContent = "\u2190 Back to Report"; + backLink.addEventListener("click", () => showReport(currentReport)); + detailPanel.innerHTML = ""; + detailPanel.appendChild(backLink); + + // Render detail below the back link + const detailWrapper = document.createElement("div"); + detailPanel.appendChild(detailWrapper); + const conventions = detectConventions(node.attrs); + renderDetail(node, conventions, detailWrapper); + } else { + const conventions = detectConventions(node.attrs); + renderDetail(node, conventions, detailPanel); + } + updateUrlParams(urlInput.value.trim(), node.path); showDetailView(); } +// --- Validate All --- + +function injectValidateAllButton() { + // Remove existing button if present + const existing = document.getElementById("validate-all-btn"); + if (existing) existing.remove(); + + const btn = document.createElement("button"); + btn.id = "validate-all-btn"; + btn.className = "validate-all-btn"; + btn.textContent = "Validate All"; + btn.addEventListener("click", runValidateAll); + statusEl.appendChild(btn); +} + +async function runValidateAll() { + if (!currentTree) return; + + // Cancel any in-progress validation + if (currentAbortController) { + currentAbortController.abort(); + } + currentAbortController = new AbortController(); + currentReport = null; + + const progressUI = renderProgressUI(detailPanel); + showDetailView(); + + try { + const report = await validateStore(currentTree, { + onProgress: ({ completed, total }) => progressUI.update(completed, total), + signal: currentAbortController.signal, + }); + currentReport = report; + showReport(report); + } catch (err) { + if (err.name !== "AbortError") { + detailPanel.innerHTML = `
`; + } + } finally { + currentAbortController = null; + } +} + +function showReport(report) { + renderReport(report, onNodeSelect, detailPanel); +} + // --- Manual path addition --- addPathBtn.addEventListener("click", addPath); diff --git a/metazarr/demo/detail-panel.js b/metazarr/demo/detail-panel.js index 6ce7908..20d71b2 100644 --- a/metazarr/demo/detail-panel.js +++ b/metazarr/demo/detail-panel.js @@ -350,9 +350,36 @@ function createConventionsSection(node, conventions) { const section = document.createElement("div"); section.className = "conventions-section"; + const header = document.createElement("div"); + header.className = "conventions-header"; + const h3 = document.createElement("h3"); h3.textContent = `Conventions (${conventions.length})`; - section.appendChild(h3); + header.appendChild(h3); + + // Validate All button for this node (only if there are validatable conventions) + const validatable = conventions.filter((c) => c.schemaUrl); + if (validatable.length > 1) { + const validateAllBtn = document.createElement("button"); + validateAllBtn.className = "validate-btn"; + validateAllBtn.textContent = "Validate All"; + validateAllBtn.addEventListener("click", async () => { + validateAllBtn.disabled = true; + const cards = section.querySelectorAll(".convention-card"); + for (const card of cards) { + const btn = card.querySelector(".validate-btn"); + if (btn && btn !== validateAllBtn) { + btn.click(); + } + } + // Re-enable after a tick so all async validations have started + await new Promise((r) => setTimeout(r, 0)); + validateAllBtn.disabled = false; + }); + header.appendChild(validateAllBtn); + } + + section.appendChild(header); if (conventions.length === 0) { const msg = document.createElement("div"); diff --git a/metazarr/demo/report-panel.js b/metazarr/demo/report-panel.js new file mode 100644 index 0000000..169bc09 --- /dev/null +++ b/metazarr/demo/report-panel.js @@ -0,0 +1,275 @@ +/** + * Report panel — renders store-wide validation results grouped by convention. + */ + +/** + * Render the progress UI while validation is running. + * + * @param {HTMLElement} container + * @returns {{ update(completed: number, total: number): void, cancel(): void }} + */ +export function renderProgressUI(container) { + container.innerHTML = ""; + + const wrapper = document.createElement("div"); + wrapper.className = "report-summary"; + + const heading = document.createElement("h2"); + heading.textContent = "Validating store..."; + wrapper.appendChild(heading); + + const progressContainer = document.createElement("div"); + progressContainer.className = "progress-container"; + + const bar = document.createElement("div"); + bar.className = "progress-bar"; + const fill = document.createElement("div"); + fill.className = "progress-fill"; + fill.style.width = "0%"; + bar.appendChild(fill); + progressContainer.appendChild(bar); + + const text = document.createElement("div"); + text.className = "progress-text"; + text.textContent = "0 / 0"; + progressContainer.appendChild(text); + + wrapper.appendChild(progressContainer); + container.appendChild(wrapper); + + return { + update(completed, total) { + const pct = total > 0 ? (completed / total) * 100 : 0; + fill.style.width = `${pct}%`; + text.textContent = `${completed} / ${total}`; + }, + cancel() { + text.textContent += " (cancelled)"; + }, + }; +} + +/** + * Render the full validation report. + * + * @param {import("../src/report.js").StoreValidationReport} report + * @param {(node: import("../src/hierarchy.js").TreeNode) => void} onNodeClick + * @param {HTMLElement} container + */ +export function renderReport(report, onNodeClick, container) { + container.innerHTML = ""; + + const wrapper = document.createElement("div"); + wrapper.className = "report-summary"; + + // Header + const heading = document.createElement("h2"); + heading.textContent = "Store Validation Report"; + wrapper.appendChild(heading); + + const conventionCount = report.byConvention.size; + const totalPass = [...report.byConvention.values()].reduce((s, r) => s + r.passCount, 0); + const totalFail = [...report.byConvention.values()].reduce((s, r) => s + r.failCount, 0); + const totalSkip = [...report.byConvention.values()].reduce((s, r) => s + r.skipCount, 0); + + const summary = document.createElement("p"); + summary.className = "report-header"; + const durationStr = report.duration < 1000 + ? `${Math.round(report.duration)}ms` + : `${(report.duration / 1000).toFixed(1)}s`; + summary.textContent = + `${report.nodesWithConventions} of ${report.totalNodes} nodes checked across ${conventionCount} convention${conventionCount !== 1 ? "s" : ""} (${durationStr})`; + wrapper.appendChild(summary); + + // Overall stats + const statsLine = document.createElement("p"); + statsLine.className = "report-stats"; + const parts = []; + if (totalPass > 0) parts.push(`${totalPass} pass`); + if (totalFail > 0) parts.push(`${totalFail} fail`); + if (totalSkip > 0) parts.push(`${totalSkip} skip`); + statsLine.textContent = parts.join(", "); + wrapper.appendChild(statsLine); + + // Convention sections + for (const convReport of report.byConvention.values()) { + wrapper.appendChild(renderConventionSection(convReport, onNodeClick)); + } + + container.appendChild(wrapper); +} + +/** + * @param {import("../src/report.js").ConventionReport} convReport + * @param {(node: import("../src/hierarchy.js").TreeNode) => void} onNodeClick + * @returns {HTMLElement} + */ +function renderConventionSection(convReport, onNodeClick) { + const section = document.createElement("div"); + section.className = "report-convention"; + + const hasFailures = convReport.failCount > 0; + const total = convReport.nodes.length; + const passRatio = total > 0 ? convReport.passCount / total : 0; + + // Header with progress bar + const header = document.createElement("div"); + header.className = "report-convention-header"; + header.style.cursor = "pointer"; + + const swatch = document.createElement("span"); + swatch.className = "conv-swatch"; + swatch.style.background = convReport.color; + header.appendChild(swatch); + + const title = document.createElement("span"); + title.className = "report-convention-title"; + title.textContent = `${convReport.display}: ${convReport.passCount}/${total} pass`; + header.appendChild(title); + + // Inline progress bar + const bar = document.createElement("span"); + bar.className = "report-inline-bar"; + const fill = document.createElement("span"); + fill.className = "report-inline-fill"; + fill.style.width = `${passRatio * 100}%`; + fill.style.background = hasFailures ? "#dc3545" : convReport.color; + if (!hasFailures) fill.style.background = convReport.color; + else { + // Show pass portion in convention color, rest in red + fill.style.background = convReport.color; + } + bar.appendChild(fill); + header.appendChild(bar); + + section.appendChild(header); + + // Node list (collapsible) + const nodeList = document.createElement("div"); + nodeList.className = "report-node-list"; + // Default: expanded if failures, collapsed if all pass + nodeList.hidden = !hasFailures; + + header.addEventListener("click", () => { + nodeList.hidden = !nodeList.hidden; + header.classList.toggle("expanded", !nodeList.hidden); + }); + + if (hasFailures) { + header.classList.add("expanded"); + } + + for (const entry of convReport.nodes) { + const rowContainer = document.createElement("div"); + rowContainer.className = "report-node-entry"; + + const row = document.createElement("div"); + row.className = `report-node-row report-node-${entry.status}`; + + const badge = document.createElement("span"); + badge.className = `badge badge-${entry.status === "pass" ? "pass" : entry.status === "fail" ? "fail" : "warn"}`; + badge.textContent = entry.status.toUpperCase(); + row.appendChild(badge); + + const pathEl = document.createElement("span"); + pathEl.className = "report-node-path"; + pathEl.textContent = entry.path; + row.appendChild(pathEl); + + if (entry.status === "fail" && entry.result) { + const errorCount = + entry.result.errors.length + + entry.result.containsFailures.reduce((s, f) => s + f.itemErrors.length, 0); + if (errorCount > 0) { + const count = document.createElement("span"); + count.className = "report-error-count"; + count.textContent = `${errorCount} error${errorCount !== 1 ? "s" : ""}`; + row.appendChild(count); + + const chevron = document.createElement("span"); + chevron.className = "report-node-chevron"; + chevron.textContent = "\u25B6"; + row.appendChild(chevron); + } + } + + rowContainer.appendChild(row); + + // Expandable error details for failing nodes + if (entry.status === "fail" && entry.result) { + const details = renderNodeErrors(entry.result, convReport.name); + if (details) { + details.hidden = true; + rowContainer.appendChild(details); + + row.addEventListener("click", (e) => { + details.hidden = !details.hidden; + row.classList.toggle("expanded", !details.hidden); + }); + } + } + + // Link to full node detail view + const viewLink = document.createElement("button"); + viewLink.className = "report-view-node-link"; + viewLink.textContent = "View full node detail \u2192"; + viewLink.addEventListener("click", (e) => { + e.stopPropagation(); + onNodeClick(entry.node); + }); + + if (entry.status === "fail" && entry.result) { + // Append view link inside the error details block + const details = rowContainer.querySelector(".report-node-errors"); + if (details) { + details.appendChild(viewLink); + } + } else { + row.addEventListener("click", () => onNodeClick(entry.node)); + } + + nodeList.appendChild(rowContainer); + } + + section.appendChild(nodeList); + return section; +} + +/** + * Render validation errors inline for a failing node. + * + * @param {import("../src/validator.js").ValidationResult} result + * @param {string} conventionName + * @returns {HTMLElement|null} + */ +function renderNodeErrors(result, conventionName) { + const allErrors = []; + + // Collect contains failures + for (const failure of result.containsFailures) { + for (const err of failure.itemErrors) { + allErrors.push(`zarr_conventions[${failure.bestMatchIndex}]${err.path}: ${err.message}`); + } + } + + // Collect attribute errors + for (const err of result.errors) { + allErrors.push(`${err.path}: ${err.message}`); + } + + if (allErrors.length === 0) return null; + + const container = document.createElement("div"); + container.className = "report-node-errors"; + + const ul = document.createElement("ul"); + ul.className = "error-list"; + for (const msg of allErrors) { + const li = document.createElement("li"); + li.textContent = msg; + ul.appendChild(li); + } + container.appendChild(ul); + + return container; +} diff --git a/metazarr/demo/style.css b/metazarr/demo/style.css index 1625e47..76237d3 100644 --- a/metazarr/demo/style.css +++ b/metazarr/demo/style.css @@ -337,9 +337,15 @@ main { margin-bottom: 1.25rem; } +.conventions-header { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; +} + .conventions-section h3 { font-size: 0.95rem; - margin-bottom: 0.5rem; } .convention-card { @@ -487,6 +493,220 @@ main { word-break: break-word; } +/* --- Report panel --- */ + +.report-summary { + max-width: 700px; +} + +.report-summary h2 { + font-size: 1.1rem; + margin-bottom: 0.5rem; +} + +.report-header { + font-size: 0.85rem; + color: #555; + margin-bottom: 0.15rem; +} + +.report-stats { + font-size: 0.85rem; + color: #555; + font-weight: 600; + margin-bottom: 1rem; +} + +.report-convention { + background: #fff; + border: 1px solid #e0e0e0; + border-radius: 8px; + margin-bottom: 0.5rem; + overflow: hidden; +} + +.report-convention-header { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 0.75rem; + font-size: 0.85rem; + font-weight: 600; + user-select: none; +} + +.report-convention-header::before { + content: "\25B6"; + font-size: 0.65rem; + color: #888; + transition: transform 0.15s; + flex-shrink: 0; +} + +.report-convention-header.expanded::before { + transform: rotate(90deg); +} + +.report-convention-title { + flex: 1; +} + +.report-inline-bar { + width: 60px; + height: 6px; + background: #e8e8e8; + border-radius: 3px; + overflow: hidden; + flex-shrink: 0; +} + +.report-inline-fill { + display: block; + height: 100%; + border-radius: 3px; + transition: width 0.3s; +} + +.report-node-list { + border-top: 1px solid #e0e0e0; +} + +.report-node-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.35rem 0.75rem 0.35rem 1.75rem; + font-size: 0.82rem; + cursor: pointer; + border-bottom: 1px solid #f0f0f0; +} + +.report-node-row:last-child { + border-bottom: none; +} + +.report-node-row:hover { + background: #f0f4ff; +} + +.report-node-path { + flex: 1; + font-family: monospace; + font-size: 0.82rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.report-error-count { + font-size: 0.75rem; + color: #721c24; + flex-shrink: 0; +} + +.report-node-chevron { + font-size: 0.6rem; + color: #888; + flex-shrink: 0; + transition: transform 0.15s; +} + +.report-node-row.expanded .report-node-chevron { + transform: rotate(90deg); +} + +.report-node-errors { + padding: 0.35rem 0.75rem 0.5rem 1.75rem; + background: #fef8f8; + border-bottom: 1px solid #f0f0f0; +} + +.report-node-errors .error-list { + margin: 0; +} + +.report-node-errors .error-list li { + font-size: 0.78rem; +} + +.report-view-node-link { + display: inline-block; + margin-top: 0.35rem; + font-size: 0.78rem; + color: #4a7dff; + background: none; + border: none; + padding: 0; + cursor: pointer; + font-weight: 600; +} + +.report-view-node-link:hover { + text-decoration: underline; + background: none; +} + +.report-back-link { + display: inline-block; + font-size: 0.82rem; + color: #4a7dff; + cursor: pointer; + margin-bottom: 0.75rem; + border: none; + background: none; + padding: 0; + font-weight: 600; +} + +.report-back-link:hover { + text-decoration: underline; +} + +/* --- Progress bar --- */ + +.progress-container { + margin-top: 0.75rem; +} + +.progress-bar { + width: 100%; + max-width: 300px; + height: 8px; + background: #e8e8e8; + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + background: #4a7dff; + border-radius: 4px; + transition: width 0.15s; +} + +.progress-text { + font-size: 0.8rem; + color: #666; + margin-top: 0.25rem; +} + +/* --- Validate All button --- */ + +.validate-all-btn { + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + background: #28a745; + margin-left: 0.5rem; +} + +.validate-all-btn:hover { + background: #218838; +} + +.validate-all-btn:disabled { + background: #8bc49b; +} + /* --- Spinner --- */ .spinner { diff --git a/metazarr/src/index.js b/metazarr/src/index.js index 61b913f..e0152f3 100644 --- a/metazarr/src/index.js +++ b/metazarr/src/index.js @@ -29,4 +29,5 @@ export { tryV3Consolidated, parseV3Consolidated } from "./consolidated-v3.js"; export { crawlDirectory, tryCrawlDirectory } from "./crawl.js"; export { detectConventions, getKnownConvention } from "./conventions.js"; export { validateNode, buildNodeDocument } from "./validator.js"; +export { validateStore, collectNodes, groupByConvention } from "./report.js"; export { fetchSchema, clearCache } from "./schema-cache.js"; diff --git a/metazarr/src/report.js b/metazarr/src/report.js new file mode 100644 index 0000000..2b52500 --- /dev/null +++ b/metazarr/src/report.js @@ -0,0 +1,169 @@ +/** + * Store-wide validation — validate all nodes against all detected conventions. + * + * @typedef {Object} NodeValidationEntry + * @property {string} path - Tree node path + * @property {"pass"|"fail"|"skip"} status + * @property {import("./validator.js").ValidationResult|null} result + * @property {import("./hierarchy.js").TreeNode} node + * + * @typedef {Object} ConventionReport + * @property {string} uuid + * @property {string} name + * @property {string} display + * @property {string} color + * @property {NodeValidationEntry[]} nodes + * @property {number} passCount + * @property {number} failCount + * @property {number} skipCount + * + * @typedef {Object} StoreValidationReport + * @property {Map