From f827ddc2825ea64292b717620329ecffbee11228 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Tue, 2 Jun 2026 20:27:57 -0700 Subject: [PATCH] Enrichment manager: merge coverage+order into one card set, fix ordering, richer rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three pieces of UI feedback: - Fix entity order: enrichment coverage was rendering by object-key order (albums first). Now sorted canonically artist β†’ album β†’ track via _emOrderEntities, used everywhere. - Combine 'Processing order' and 'Enrichment coverage' into a single set of entity cards: each card shows coverage (segmented matched/not_found/pending bar + %) AND is the click target to pin that group to enrich first, with live 'Now' / pinned 'First πŸ“Œ' / 'Done' states and per-worker accent. Drops the two redundant sections (and the old chain/stats renderers). - Richer match rows: status stripe down the left edge (red=not found, amber=pending), larger rounded artwork with a gradient placeholder, parent context (artist/album), and a subtle slide-on-hover. Frontend only; JS syntax clean. --- webui/static/enrichment-manager.js | 202 +++++++++++++---------------- webui/static/style.css | 98 ++++++++------ 2 files changed, 150 insertions(+), 150 deletions(-) diff --git a/webui/static/enrichment-manager.js b/webui/static/enrichment-manager.js index 82d9a8de..f5109c9b 100644 --- a/webui/static/enrichment-manager.js +++ b/webui/static/enrichment-manager.js @@ -94,6 +94,15 @@ function _emEntityLabel(entity, plural) { return plural ? base + 's' : base; } +// Always present entities in the worker's real processing order, regardless of +// the order the API/object keys happen to arrive in. +const _EM_ENTITY_ORDER = { artist: 0, album: 1, track: 2 }; +function _emOrderEntities(list) { + return [...new Set(list || [])].sort( + (a, b) => (_EM_ENTITY_ORDER[a] ?? 9) - (_EM_ENTITY_ORDER[b] ?? 9) + ); +} + function _emEscape(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ( { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] @@ -207,7 +216,7 @@ async function refreshEnrichmentManager(btn) { renderEnrichmentRail(); const sel = enrichmentManagerState.selected; if (sel) await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]); - _emRenderStats(); + _emRenderEntityCards(); _emRenderUnmatchedList(); _emRenderPanelHeader(); if (btn) setTimeout(() => btn.classList.remove('em-spinning'), 400); @@ -236,7 +245,7 @@ async function _emPollSelected() { enrichmentManagerState.statuses[id] = await res.json(); _emUpdateHeaderLive(); // in-place β€” no logo reflow/flicker _emUpdateRailRow(id); - _emRenderChain(); // current-phase highlight tracks live + _emRenderEntityCards(); } } catch (_e) { /* transient β€” keep last */ } } @@ -408,12 +417,11 @@ function renderEnrichmentPanel() { panel.innerHTML = `
-
- Enrichment coverage + Coverage & processing order β€” click a group to enrich it first
-
+
@@ -421,56 +429,91 @@ function renderEnrichmentPanel() {
`; _emRenderPanelHeader(); - _emRenderChain(); - _emRenderStats(); + _emRenderEntityCards(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } -// Processing-order strip: shows the artistβ†’albumβ†’track chain, where the worker -// currently is, and lets the user pin one group to run first. -function _emRenderChain() { - const host = document.getElementById('em-chain'); +// Combined coverage + processing-order cards. Each entity card both visualises +// coverage (matched/not_found/pending segmented bar + %) AND is the click target +// to pin that group to enrich first ('now' = live phase, 'πŸ“Œ first' = pinned, +// 'βœ“ done' = nothing left). One section instead of two redundant ones. +function _emRenderEntityCards() { + const host = document.getElementById('em-cards'); if (!host) return; const id = enrichmentManagerState.selected; - const supported = (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types) - || (enrichmentManagerState.breakdown && Object.keys(enrichmentManagerState.breakdown)) - || ['artist']; + const bd = enrichmentManagerState.breakdown; + const supported = _emOrderEntities( + (enrichmentManagerState.unmatched && enrichmentManagerState.unmatched.entity_types) + || (bd && Object.keys(bd)) || ['artist'] + ); + + if (!bd) { + host.innerHTML = supported.map(() => ` +
+
+
+
+
`).join(''); + return; + } + const status = enrichmentManagerState.statuses[id]; const phase = _emCurrentPhase(status); const pinned = enrichmentManagerState.priority; - const bd = enrichmentManagerState.breakdown || {}; const glyphs = { artist: '🎀', album: 'πŸ’Ώ', track: '🎡' }; - const steps = supported.map((e, i) => { + host.innerHTML = supported.map(e => { const d = bd[e] || {}; - const pending = (d.pending || 0) + (d.not_found || 0); - const isCurrent = phase === e; - const isPinned = pinned === e; - const isDone = bd[e] && pending === 0; - const cls = ['em-step', - isPinned ? 'em-step--pinned' : '', - isCurrent ? 'em-step--current' : '', - isDone ? 'em-step--done' : ''].filter(Boolean).join(' '); - const note = isPinned ? 'first' : (isCurrent ? 'now' : (isDone ? 'done' : `${pending.toLocaleString()} left`)); - const arrow = i < supported.length - 1 ? 'β†’' : ''; + const total = d.total || 0, matched = d.matched || 0, nf = d.not_found || 0, pend = d.pending || 0; + const pct = total ? Math.round((matched / total) * 100) : 0; + const seg = (n) => (total ? (n / total) * 100 : 0); + const isPinned = pinned === e, isCurrent = phase === e, isDone = total > 0 && (pend + nf) === 0; + const cls = ['em-card', + isPinned ? 'em-card--pinned' : '', + isCurrent ? 'em-card--current' : '', + isDone ? 'em-card--done' : ''].filter(Boolean).join(' '); + const badge = isPinned + ? 'πŸ“Œ First' + : isCurrent + ? '● Now' + : isDone + ? 'βœ“ Done' + : `${(pend + nf).toLocaleString()} left`; return ` - ${arrow}`; +
+ ${glyphs[e] || 'β€’'} + ${_emEntityLabel(e, true)} + ${badge} + ${pct}% +
+
+
+
+
+
+
+ ${matched.toLocaleString()} + ${nf.toLocaleString()} + ${pend.toLocaleString()} +
+ `; }).join(''); - host.innerHTML = ` -
- - ${pinned - ? `Pinned ${_emEntityLabel(pinned, true)} first Β· click again for auto` - : 'Click a group to process it first'} -
-
${steps}
`; + // Overall matched coverage across every entity type. + const overall = document.getElementById('em-coverage-overall'); + if (overall) { + let m = 0, t = 0; + Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; }); + const pct = t ? Math.round((m / t) * 100) : 0; + overall.innerHTML = t ? `${pct}% matched Β· ${m.toLocaleString()} of ${t.toLocaleString()}` : ''; + } + + requestAnimationFrame(() => { + host.querySelectorAll('.em-seg-fill').forEach(el => { el.style.width = `${el.dataset.pct || 0}%`; }); + }); } async function setEnrichmentPriority(entity) { @@ -491,7 +534,7 @@ async function setEnrichmentPriority(entity) { if (!enrichmentManagerState.priority) { showToast(`${name} back to automatic order`, 'success'); - _emRenderChain(); + _emRenderEntityCards(); return; } @@ -515,10 +558,9 @@ async function setEnrichmentPriority(entity) { ? `${name} will process all ${label} first Β· re-queued ${reset.toLocaleString()} previously-failed` : `${name} will process ${label} first`, 'success'); - // Counts changed (failed -> pending) β€” refresh stats, chain and list. + // Counts changed (failed -> pending) β€” refresh cards + list. await Promise.all([_emLoadBreakdown(id), _emLoadUnmatched()]); - _emRenderStats(); - _emRenderChain(); + _emRenderEntityCards(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } catch (_e) { @@ -622,69 +664,6 @@ function _emHumanDuration(seconds) { return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`; } -function _emRenderStats() { - const host = document.getElementById('em-stats'); - if (!host) return; - const bd = enrichmentManagerState.breakdown; - if (!bd) { - // Skeleton cards (count unknown yet β€” 3 covers the common case). - host.innerHTML = Array.from({ length: 3 }, () => ` -
-
-
-
-
`).join(''); - return; - } - - const glyphs = { artist: '🎀', album: 'πŸ’Ώ', track: '🎡' }; - host.innerHTML = Object.keys(bd).map(entity => { - const d = bd[entity] || {}; - const total = d.total || 0; - const matched = d.matched || 0; - const notFound = d.not_found || 0; - const pending = d.pending || 0; - const pct = total ? Math.round((matched / total) * 100) : 0; - const seg = (n) => (total ? (n / total) * 100 : 0); - return ` -
-
- ${glyphs[entity] || 'β€’'}${_emEntityLabel(entity, true)} - ${pct}% -
-
-
-
-
-
-
- ${matched.toLocaleString()} matched - ${notFound.toLocaleString()} missed - ${pending.toLocaleString()} pending -
-
`; - }).join(''); - - // Overall matched coverage across every entity type (relocated here from - // the hero per the refined-banner header). - const overall = document.getElementById('em-coverage-overall'); - if (overall) { - let m = 0, t = 0; - Object.values(bd).forEach(d => { m += d.matched || 0; t += d.total || 0; }); - const pct = t ? Math.round((m / t) * 100) : 0; - overall.innerHTML = t - ? `${pct}% matched Β· ${m.toLocaleString()} of ${t.toLocaleString()}` - : ''; - } - - // Animate the segments in from 0 on the next frame (CSS transition does the rest). - requestAnimationFrame(() => { - host.querySelectorAll('.em-seg-fill').forEach(el => { - el.style.width = `${el.dataset.pct || 0}%`; - }); - }); -} - function _emRenderUnmatchedControls() { const host = document.getElementById('em-unmatched-controls'); if (!host) return; @@ -774,8 +753,9 @@ function _emRenderUnmatchedList() { ? `Β· ${_emEscape(item.parent)}` : ''; const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : ''; + const rowCls = item.status === 'not_found' ? 'em-row em-row--nf' : 'em-row em-row--pend'; return ` -
+
@@ -857,7 +837,7 @@ async function retrySelectedEnrichment(btn) { showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error'); enrichmentManagerState.selectedItems.clear(); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); - _emRenderStats(); + _emRenderEntityCards(); _emRenderUnmatchedList(); } @@ -944,7 +924,7 @@ async function retryEnrichmentItem(service, entityType, entityId, btn) { if (res.ok && data.success) { showToast('Re-queued for enrichment', 'success'); await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); - _emRenderStats(); + _emRenderEntityCards(); _emRenderUnmatchedList(); } else { showToast(data.error || 'Failed to re-queue', 'error'); @@ -976,7 +956,7 @@ async function retryAllFailedEnrichment(btn) { showToast(`Re-queued ${(data.reset || 0).toLocaleString()} item(s)`, 'success'); enrichmentManagerState.page = 0; await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]); - _emRenderStats(); + _emRenderEntityCards(); _emRenderUnmatchedControls(); _emRenderUnmatchedList(); } else { @@ -1089,7 +1069,7 @@ async function _emApplyMatch(entityType, entityId, service, serviceId, overlay) // Refresh the manager's stats + list for the *selected* worker. const sel = enrichmentManagerState.selected; await Promise.all([_emLoadBreakdown(sel), _emLoadUnmatched()]); - _emRenderStats(); + _emRenderEntityCards(); _emRenderUnmatchedList(); } else { showToast(data.error || 'Failed to match', 'error'); diff --git a/webui/static/style.css b/webui/static/style.css index 2caa10e2..db31b18a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -64978,16 +64978,34 @@ body.reduce-effects .dash-card::after { .em-unmatched-list { flex: 1 1 auto; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; padding-right: 4px; } .em-row { - display: flex; align-items: center; gap: 12px; padding: 9px 12px; - background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; + display: flex; align-items: center; gap: 13px; padding: 10px 14px 10px 16px; + background: linear-gradient(100deg, rgba(255,255,255,0.045), rgba(255,255,255,0.02)); + border: 1px solid rgba(255,255,255,0.07); border-radius: 12px; + position: relative; overflow: hidden; + transition: transform 0.15s ease, border-color 0.15s ease, background 0.15s ease; } -.em-row:hover { background: rgba(255,255,255,0.06); } -.em-row-img { width: 42px; height: 42px; border-radius: 8px; object-fit: cover; flex: 0 0 auto; } -.em-row-img--ph { display: flex; align-items: center; justify-content: center; background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4); font-size: 18px; } +/* status stripe down the left edge */ +.em-row::before { + content: ''; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; + background: rgba(255,255,255,0.15); +} +.em-row--nf::before { background: #e0586b; } +.em-row--pend::before { background: #f0c060; } +.em-row:hover { + transform: translateX(2px); + background: linear-gradient(100deg, rgba(255,255,255,0.07), rgba(255,255,255,0.03)); + border-color: rgba(var(--em-accent-rgb, 255,255,255), 0.22); +} +.em-row-img { width: 48px; height: 48px; border-radius: 10px; object-fit: cover; flex: 0 0 auto; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); } +.em-row-img--ph { display: flex; align-items: center; justify-content: center; + background: linear-gradient(135deg, rgba(var(--em-accent-rgb, 99,102,241), 0.3), rgba(255,255,255,0.06)); + color: rgba(255,255,255,0.55); font-size: 20px; } .em-row-info { flex: 1 1 auto; min-width: 0; } -.em-row-name { font-size: 14px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.em-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 3px; font-size: 11px; } -.em-row-actions { display: flex; gap: 6px; flex: 0 0 auto; } +.em-row-name { font-size: 14.5px; font-weight: 600; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.em-row-meta { display: flex; gap: 8px; align-items: center; margin-top: 4px; font-size: 11px; } +.em-row-actions { display: flex; gap: 6px; flex: 0 0 auto; opacity: 0.85; transition: opacity 0.15s ease; } +.em-row:hover .em-row-actions { opacity: 1; } .em-empty { text-align: center; padding: 48px 20px; color: rgba(255,255,255,0.55); font-size: 15px; } .em-empty-emoji { font-size: 38px; margin-bottom: 10px; opacity: 0.85; } .em-pager { display: flex; align-items: center; justify-content: center; gap: 14px; flex: 0 0 auto; padding-top: 4px; font-size: 12px; } @@ -65093,39 +65111,41 @@ body.em-scroll-lock { overflow: hidden; } .em-ph-name-sub { font-size: 14px; font-weight: 500; color: rgba(255,255,255,0.45); } .em-hero .em-ph-actions { z-index: 1; } -/* Processing-order chain strip */ -.em-chain { display: flex; flex-direction: column; gap: 9px; flex: 0 0 auto; } -.em-chain-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; } -.em-chain-hint { font-size: 11.5px; color: rgba(255,255,255,0.4); } -.em-chain-hint strong { color: rgb(var(--em-accent-rgb, 129,140,248)); } -.em-chain-flow { display: flex; align-items: stretch; gap: 8px; flex-wrap: wrap; } -.em-step { - flex: 1 1 0; min-width: 120px; - display: flex; flex-direction: column; align-items: flex-start; gap: 2px; - padding: 10px 13px; border-radius: 12px; cursor: pointer; text-align: left; - background: rgba(255,255,255,0.035); border: 1.5px solid rgba(255,255,255,0.08); - transition: all 0.2s ease; position: relative; overflow: hidden; +/* Combined coverage + processing-order cards (click to enrich a group first) */ +.em-section-sub { font-weight: 500; text-transform: none; letter-spacing: 0; color: rgba(255,255,255,0.35); font-size: 11px; } +.em-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(215px, 1fr)); gap: 12px; flex: 0 0 auto; } +.em-card { + display: flex; flex-direction: column; gap: 10px; text-align: left; cursor: pointer; + padding: 14px; border-radius: 14px; position: relative; overflow: hidden; + background: linear-gradient(160deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02)); + border: 1.5px solid rgba(255,255,255,0.08); + transition: transform 0.2s ease, border-color 0.2s ease, background 0.2s ease; } -.em-step:hover { background: rgba(255,255,255,0.06); border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.4); transform: translateY(-1px); } -.em-step-glyph { font-size: 16px; } -.em-step-name { font-size: 13px; font-weight: 700; color: #fff; } -.em-step-note { font-size: 10.5px; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.4); } -.em-step-arrow { align-self: center; color: rgba(255,255,255,0.25); font-size: 16px; } -/* Pinned = will run first */ -.em-step--pinned { - background: rgba(var(--em-accent-rgb, 99,102,241), 0.16); - border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.6); +.em-card:hover { + transform: translateY(-2px); + border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.45); + background: linear-gradient(160deg, rgba(255,255,255,0.09), rgba(255,255,255,0.03)); } -.em-step--pinned .em-step-note { color: rgb(var(--em-accent-rgb, 129,140,248)); font-weight: 800; } -.em-step--pinned::after { content: 'πŸ“Œ'; position: absolute; top: 6px; right: 8px; font-size: 11px; } -/* Current = being processed now */ -.em-step--current .em-step-name::after { content: ''; display: inline-block; width: 7px; height: 7px; margin-left: 6px; border-radius: 50%; background: #4ade80; box-shadow: 0 0 8px #4ade80; animation: em-glow-pulse 1.6s ease-in-out infinite; vertical-align: middle; } -.em-step--current { border-color: rgba(74,222,128,0.45); } -.em-step--current .em-step-note { color: #4ade80; } -/* Done = nothing left */ -.em-step--done .em-step-glyph { opacity: 0.5; } -.em-step--done .em-step-note { color: rgba(74,222,128,0.7); } -@media (prefers-reduced-motion: reduce) { .em-step--current .em-step-name::after { animation: none; } } +.em-card-top { display: flex; align-items: center; gap: 8px; } +.em-card-glyph { font-size: 15px; } +.em-card-title { font-size: 12.5px; font-weight: 800; color: #fff; text-transform: uppercase; letter-spacing: 0.5px; } +.em-card-pct { margin-left: auto; font-size: 21px; font-weight: 800; color: #fff; line-height: 1; } +.em-card-badge { font-size: 9.5px; font-weight: 800; text-transform: uppercase; letter-spacing: 0.5px; padding: 2px 7px; border-radius: 999px; background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.55); white-space: nowrap; } +.em-card-badge--pin { background: rgba(var(--em-accent-rgb, 99,102,241), 0.28); color: #fff; } +.em-card-badge--now { background: rgba(74,222,128,0.2); color: #4ade80; } +.em-card-badge--done { background: rgba(74,222,128,0.14); color: rgba(74,222,128,0.85); } +.em-card .em-stat-legend { gap: 12px; margin-top: 0; } +.em-card .em-leg { font-size: 11.5px; color: rgba(255,255,255,0.7); } +/* States */ +.em-card--pinned { + border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.65); + background: linear-gradient(160deg, rgba(var(--em-accent-rgb, 99,102,241), 0.16), rgba(255,255,255,0.02)); + box-shadow: 0 0 0 1px rgba(var(--em-accent-rgb, 99,102,241), 0.25), 0 8px 24px rgba(var(--em-accent-rgb, 99,102,241), 0.15); +} +.em-card--current { border-color: rgba(74,222,128,0.4); } +.em-card--current .em-card-badge--now { animation: em-glow-pulse 1.8s ease-in-out infinite; } +.em-card--done { opacity: 0.74; } +@media (prefers-reduced-motion: reduce) { .em-card--current .em-card-badge--now { animation: none; } } /* Section label as a row (coverage % relocated here from the hero) */ .em-section-label--row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }