Enrichment manager: 6 UX improvements
- #1 Unconfigured-source banner: when a source has enabled=false, show a notice that browsing works but matches/retries won't run until it's set up. - #2 Rate-limit detail: when rate_limited, surface 'resumes in ~Xm' (from the status payload) instead of just a pill. - #3 Richer rows: unmatched items now show parent context — an album's artist, a track's album — via a parent expression in the query (+ test). - #4 Bulk select: per-row checkboxes + a bulk bar to retry several at once (capped concurrency), reusing the /retry item endpoint. - #5 Remember last worker: selection persists in localStorage and is restored on open; openEnrichmentManager(workerId) supports future deep-linking (bubbles left on their pause-on-click behaviour). - #6 Keyboard nav: ArrowUp/Down moves focus between rows; actions are native buttons (Enter/Space) and Escape closes — list isn't poll-refreshed so focus is stable. 53 enrichment tests green; JS syntax clean.
This commit is contained in:
parent
e53a157793
commit
62ee1f8520
4 changed files with 161 additions and 14 deletions
|
|
@ -34,21 +34,26 @@ SERVICE_ENTITY_SUPPORT = {
|
|||
'amazon': ('artist', 'album', 'track'),
|
||||
}
|
||||
|
||||
# entity_type -> table / display-name column / image expression / optional join.
|
||||
# entity_type -> table / display-name column / image expression / optional join
|
||||
# / parent-context expression (the artist an album belongs to; the album a
|
||||
# track belongs to) so the UI can disambiguate same-named items.
|
||||
# tracks carry no artwork column of their own, so we borrow the parent album's.
|
||||
_ENTITY_TABLE = {
|
||||
'artist': {
|
||||
'table': 'artists', 'name': 'name',
|
||||
'image': 'artists.thumb_url', 'join': '',
|
||||
'image': 'artists.thumb_url', 'join': '', 'parent': None,
|
||||
},
|
||||
'album': {
|
||||
'table': 'albums', 'name': 'title',
|
||||
'image': 'albums.thumb_url', 'join': '',
|
||||
'image': 'albums.thumb_url',
|
||||
'join': 'LEFT JOIN artists par ON albums.artist_id = par.id',
|
||||
'parent': 'par.name',
|
||||
},
|
||||
'track': {
|
||||
'table': 'tracks', 'name': 'title',
|
||||
'image': 'al.thumb_url',
|
||||
'join': 'LEFT JOIN albums al ON tracks.album_id = al.id',
|
||||
'parent': 'al.title',
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -134,9 +139,11 @@ def build_unmatched_query(
|
|||
where.append(f"{table}.{name_col} LIKE ?")
|
||||
params.append(f"%{query}%")
|
||||
|
||||
parent_expr = meta.get('parent')
|
||||
parent_select = f"{parent_expr} AS parent" if parent_expr else "NULL AS parent"
|
||||
sql = (
|
||||
f"SELECT {table}.id AS id, {table}.{name_col} AS name, "
|
||||
f"{image_expr} AS image_url, {table}.{ms} AS status, "
|
||||
f"{image_expr} AS image_url, {parent_select}, {table}.{ms} AS status, "
|
||||
f"{table}.{la} AS last_attempted "
|
||||
f"FROM {table} {join} "
|
||||
f"WHERE {' AND '.join(where)} "
|
||||
|
|
|
|||
|
|
@ -150,6 +150,16 @@ def test_track_unmatched_borrows_album_artwork(db):
|
|||
assert res['items'][0]['image_url'] == 'http://img/evolve.jpg'
|
||||
|
||||
|
||||
def test_unmatched_includes_parent_context(db):
|
||||
# album's parent is its artist; track's parent is its album
|
||||
album = db.get_enrichment_unmatched('spotify', 'album', status='not_found')['items'][0]
|
||||
assert album['parent'] == 'Failed Dragons'
|
||||
track = db.get_enrichment_unmatched('spotify', 'track', status='not_found')['items'][0]
|
||||
assert track['parent'] == 'Evolve'
|
||||
artist = db.get_enrichment_unmatched('spotify', 'artist', status='not_found')['items'][0]
|
||||
assert artist['parent'] is None
|
||||
|
||||
|
||||
def test_db_raises_on_bad_input(db):
|
||||
with pytest.raises(UnmatchedQueryError):
|
||||
db.get_enrichment_unmatched('spotify', 'artist', status='bogus')
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@ const enrichmentManagerState = {
|
|||
page: 0,
|
||||
pageSize: 25,
|
||||
unmatched: null, // { total, items }
|
||||
selectedItems: new Set(), // ids checked for bulk retry
|
||||
pollTimer: null,
|
||||
loadToken: 0, // guards against out-of-order async renders
|
||||
};
|
||||
|
|
@ -120,7 +121,7 @@ function _emRelativeTime(value) {
|
|||
|
||||
// ── Open / close ──────────────────────────────────────────────────────────
|
||||
|
||||
async function openEnrichmentManager() {
|
||||
async function openEnrichmentManager(workerId) {
|
||||
let overlay = document.getElementById('enrichment-manager-overlay');
|
||||
if (!overlay) {
|
||||
overlay = document.createElement('div');
|
||||
|
|
@ -157,11 +158,16 @@ async function openEnrichmentManager() {
|
|||
await refreshAllEnrichmentStatuses();
|
||||
renderEnrichmentRail();
|
||||
|
||||
// Default selection: first running worker, else first in the list.
|
||||
const running = ENRICHMENT_WORKERS.find(
|
||||
w => enrichmentManagerState.statuses[w.id]?.running
|
||||
);
|
||||
selectEnrichmentWorker((running || ENRICHMENT_WORKERS[0]).id);
|
||||
// Selection priority: explicit deep-link arg → last-viewed (remembered) →
|
||||
// first running worker → first in the list.
|
||||
let remembered = null;
|
||||
try { remembered = localStorage.getItem('em-last-worker'); } catch (_e) { /* ignore */ }
|
||||
const valid = (wid) => wid && _emWorkerById[wid];
|
||||
const running = ENRICHMENT_WORKERS.find(w => enrichmentManagerState.statuses[w.id]?.running);
|
||||
const initial = (valid(workerId) && workerId)
|
||||
|| (valid(remembered) && remembered)
|
||||
|| (running || ENRICHMENT_WORKERS[0]).id;
|
||||
selectEnrichmentWorker(initial);
|
||||
if (modal) setTimeout(() => modal.focus(), 60);
|
||||
|
||||
if (enrichmentManagerState.pollTimer) clearInterval(enrichmentManagerState.pollTimer);
|
||||
|
|
@ -307,6 +313,8 @@ function _emUpdateRailRow(id) {
|
|||
|
||||
async function selectEnrichmentWorker(id) {
|
||||
enrichmentManagerState.selected = id;
|
||||
try { localStorage.setItem('em-last-worker', id); } catch (_e) { /* ignore */ }
|
||||
enrichmentManagerState.selectedItems.clear();
|
||||
enrichmentManagerState.breakdown = null;
|
||||
enrichmentManagerState.unmatched = null;
|
||||
enrichmentManagerState.priority = '';
|
||||
|
|
@ -389,6 +397,7 @@ function renderEnrichmentPanel() {
|
|||
|
||||
panel.innerHTML = `
|
||||
<div class="em-panel-header" id="em-panel-header"></div>
|
||||
<div class="em-banner" id="em-banner" hidden></div>
|
||||
<div class="em-chain" id="em-chain"></div>
|
||||
<div class="em-section-label em-section-label--row">
|
||||
<span>Enrichment coverage</span>
|
||||
|
|
@ -397,7 +406,8 @@ function renderEnrichmentPanel() {
|
|||
<div class="em-stats" id="em-stats"></div>
|
||||
<div class="em-unmatched">
|
||||
<div class="em-unmatched-controls" id="em-unmatched-controls"></div>
|
||||
<div class="em-unmatched-list" id="em-unmatched-list"></div>
|
||||
<div class="em-bulk-bar" id="em-bulk-bar" hidden></div>
|
||||
<div class="em-unmatched-list" id="em-unmatched-list" onkeydown="onEnrichmentListKey(event)"></div>
|
||||
<div class="em-pager" id="em-pager"></div>
|
||||
</div>`;
|
||||
_emRenderPanelHeader();
|
||||
|
|
@ -543,6 +553,33 @@ function _emUpdateHeaderLive() {
|
|||
toggle.classList.toggle('em-btn--go', !!isPaused);
|
||||
toggle.textContent = isPaused ? '▶ Resume' : '⏸ Pause';
|
||||
}
|
||||
|
||||
// #1 unconfigured + #2 rate-limit banner.
|
||||
const banner = document.getElementById('em-banner');
|
||||
if (banner) {
|
||||
let html = '', cls = 'em-banner';
|
||||
if (status && status.enabled === false) {
|
||||
cls += ' em-banner--warn';
|
||||
html = '⚙️ This source isn’t configured — add its credentials in Settings. '
|
||||
+ 'Browsing works, but matches and retries won’t run until it’s set up.';
|
||||
} else if (status && status.rate_limited) {
|
||||
cls += ' em-banner--warn';
|
||||
const rl = status.rate_limit || {};
|
||||
const secs = rl.retry_after || rl.reset_in || rl.cooldown_seconds;
|
||||
const when = secs ? ` — resumes in ~${_emHumanDuration(secs)}` : ' — it will resume automatically';
|
||||
html = `⏳ Rate-limited by the source${when}.`;
|
||||
}
|
||||
banner.className = cls;
|
||||
banner.innerHTML = html;
|
||||
banner.hidden = !html;
|
||||
}
|
||||
}
|
||||
|
||||
function _emHumanDuration(seconds) {
|
||||
const s = Math.max(0, Math.round(Number(seconds) || 0));
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.round(s / 60);
|
||||
return m < 60 ? `${m}m` : `${Math.round(m / 60)}h`;
|
||||
}
|
||||
|
||||
function _emRenderStats() {
|
||||
|
|
@ -692,22 +729,96 @@ function _emRenderUnmatchedList() {
|
|||
? '<span class="em-chip em-chip--nf">not found</span>'
|
||||
: '<span class="em-chip em-chip--pend">pending</span>';
|
||||
const safeName = _emEscape(item.name || 'Unknown');
|
||||
const safeId = _emEscape(item.id);
|
||||
const parent = item.parent
|
||||
? `<span class="em-row-parent" title="${_emEscape(item.parent)}">· ${_emEscape(item.parent)}</span>`
|
||||
: '';
|
||||
const checked = enrichmentManagerState.selectedItems.has(String(item.id)) ? 'checked' : '';
|
||||
return `
|
||||
<div class="em-row">
|
||||
<input type="checkbox" class="em-row-check" ${checked}
|
||||
aria-label="Select ${safeName}"
|
||||
onchange="toggleEnrichmentRowSelect('${safeId}', this.checked)">
|
||||
${img}
|
||||
<div class="em-row-info">
|
||||
<div class="em-row-name" title="${safeName}">${safeName}</div>
|
||||
<div class="em-row-name" title="${safeName}">${safeName} ${parent}</div>
|
||||
<div class="em-row-meta">${statusBadge} ${last}</div>
|
||||
</div>
|
||||
<div class="em-row-actions">
|
||||
<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${_emEscape(item.id)}', this)">Match</button>
|
||||
<button class="em-btn em-btn--sm" onclick="openEnrichmentMatch('${id}','${entity}','${safeId}', this)">Match</button>
|
||||
<button class="em-btn em-btn--sm em-btn--ghost" title="Re-queue for the worker to try again"
|
||||
onclick="retryEnrichmentItem('${id}','${entity}','${_emEscape(item.id)}', this)">Retry</button>
|
||||
onclick="retryEnrichmentItem('${id}','${entity}','${safeId}', this)">Retry</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
_emRenderPager();
|
||||
_emRenderBulkBar();
|
||||
}
|
||||
|
||||
function _emRenderBulkBar() {
|
||||
const bar = document.getElementById('em-bulk-bar');
|
||||
if (!bar) return;
|
||||
const n = enrichmentManagerState.selectedItems.size;
|
||||
if (!n) { bar.hidden = true; bar.innerHTML = ''; return; }
|
||||
bar.hidden = false;
|
||||
bar.innerHTML = `
|
||||
<span class="em-bulk-count">${n} selected</span>
|
||||
<button class="em-btn em-btn--sm" onclick="retrySelectedEnrichment(this)">↻ Retry selected</button>
|
||||
<button class="em-btn em-btn--sm em-btn--ghost" onclick="clearEnrichmentSelection()">Clear</button>`;
|
||||
}
|
||||
|
||||
function toggleEnrichmentRowSelect(id, checked) {
|
||||
if (checked) enrichmentManagerState.selectedItems.add(String(id));
|
||||
else enrichmentManagerState.selectedItems.delete(String(id));
|
||||
_emRenderBulkBar();
|
||||
}
|
||||
|
||||
function clearEnrichmentSelection() {
|
||||
enrichmentManagerState.selectedItems.clear();
|
||||
_emRenderUnmatchedList();
|
||||
}
|
||||
|
||||
// #6 keyboard nav: ↑/↓ moves focus between rows' Match buttons (Enter/Space
|
||||
// then activates natively). The list isn't refreshed by polling, so focus
|
||||
// stays put between user actions.
|
||||
function onEnrichmentListKey(e) {
|
||||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp') return;
|
||||
const host = document.getElementById('em-unmatched-list');
|
||||
if (!host) return;
|
||||
const btns = Array.from(host.querySelectorAll('.em-row .em-row-actions .em-btn:first-child'));
|
||||
if (!btns.length) return;
|
||||
e.preventDefault();
|
||||
const idx = btns.indexOf(document.activeElement);
|
||||
const next = e.key === 'ArrowDown'
|
||||
? (idx < 0 ? 0 : Math.min(idx + 1, btns.length - 1))
|
||||
: (idx <= 0 ? 0 : idx - 1);
|
||||
btns[next].focus();
|
||||
}
|
||||
|
||||
async function retrySelectedEnrichment(btn) {
|
||||
const service = enrichmentManagerState.selected;
|
||||
const entity = enrichmentManagerState.entityTab;
|
||||
const ids = Array.from(enrichmentManagerState.selectedItems);
|
||||
if (!ids.length) return;
|
||||
if (btn) { btn.disabled = true; btn.textContent = 'Re-queuing…'; }
|
||||
let ok = 0;
|
||||
// Cap concurrency to be gentle on the server.
|
||||
for (let i = 0; i < ids.length; i += 5) {
|
||||
const slice = ids.slice(i, i + 5);
|
||||
const results = await Promise.all(slice.map(eid =>
|
||||
fetch(`/api/enrichment/${service}/retry`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ entity_type: entity, scope: 'item', entity_id: eid }),
|
||||
}).then(r => r.ok).catch(() => false)
|
||||
));
|
||||
ok += results.filter(Boolean).length;
|
||||
}
|
||||
showToast(`Re-queued ${ok.toLocaleString()} item(s)`, ok ? 'success' : 'error');
|
||||
enrichmentManagerState.selectedItems.clear();
|
||||
await Promise.all([_emLoadBreakdown(service), _emLoadUnmatched()]);
|
||||
_emRenderStats();
|
||||
_emRenderUnmatchedList();
|
||||
}
|
||||
|
||||
function _emRenderPager() {
|
||||
|
|
@ -961,4 +1072,8 @@ window.toggleEnrichmentWorker = toggleEnrichmentWorker;
|
|||
window.setEnrichmentPriority = setEnrichmentPriority;
|
||||
window.retryEnrichmentItem = retryEnrichmentItem;
|
||||
window.retryAllFailedEnrichment = retryAllFailedEnrichment;
|
||||
window.toggleEnrichmentRowSelect = toggleEnrichmentRowSelect;
|
||||
window.clearEnrichmentSelection = clearEnrichmentSelection;
|
||||
window.retrySelectedEnrichment = retrySelectedEnrichment;
|
||||
window.onEnrichmentListKey = onEnrichmentListKey;
|
||||
window.openEnrichmentMatch = openEnrichmentMatch;
|
||||
|
|
|
|||
|
|
@ -65170,6 +65170,21 @@ body.em-scroll-lock { overflow: hidden; }
|
|||
.em-search:focus, .em-select:focus { outline: none; border-color: rgba(var(--em-accent-rgb, 99,102,241), 0.6); }
|
||||
.em-row:hover { background: rgba(var(--em-accent-rgb, 255,255,255), 0.07); border-color: rgba(var(--em-accent-rgb, 255,255,255), 0.14); }
|
||||
|
||||
/* Disabled / rate-limit banner */
|
||||
.em-banner { flex: 0 0 auto; padding: 10px 14px; border-radius: 10px; font-size: 12.5px; line-height: 1.45;
|
||||
background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: rgba(255,255,255,0.75); }
|
||||
.em-banner--warn { background: rgba(240,176,80,0.12); border-color: rgba(240,176,80,0.35); color: #f3cd86; }
|
||||
|
||||
/* Row checkbox + parent context (#3 / #4) */
|
||||
.em-row-check { width: 16px; height: 16px; flex: 0 0 auto; cursor: pointer; accent-color: rgb(var(--em-accent-rgb, 99,102,241)); }
|
||||
.em-row-parent { font-size: 12px; font-weight: 500; color: rgba(255,255,255,0.4); }
|
||||
|
||||
/* Bulk action bar */
|
||||
.em-bulk-bar { display: flex; align-items: center; gap: 10px; flex: 0 0 auto; padding: 8px 12px;
|
||||
background: rgba(var(--em-accent-rgb, 99,102,241), 0.12); border: 1px solid rgba(var(--em-accent-rgb, 99,102,241), 0.3);
|
||||
border-radius: 10px; }
|
||||
.em-bulk-count { font-size: 13px; font-weight: 700; color: #fff; margin-right: auto; }
|
||||
|
||||
/* Bulk retry-all + helper hint */
|
||||
.em-retry-all { margin-left: 4px; }
|
||||
.em-hint { font-size: 11px; color: rgba(255,255,255,0.38); flex: 0 0 auto; margin-top: -2px; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue