From d668bf4e6de57c014885c99df5f3d210ffcafaef Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Mon, 25 May 2026 14:22:56 -0700 Subject: [PATCH] Auto-Sync manager: filter, failure indicators, history filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six small UX additions on the Playlist Auto-Sync manager: - Sidebar gets a "Filter playlists…" search input. Re-renders only the schedule panel on input so focus is preserved while typing. - Scheduled cards show a red `!` badge + red border tint when the last three pipeline runs failed (yellow `⚠` if at least one of the last few failed). Surfaces chronically broken schedules visually instead of leaving them indistinguishable from healthy ones. - Run History tab title shows a red error count badge when there are failed runs in the loaded window. - Run History tab body gains All / Errors / Completed filter pills with per-bucket counts. - Load-more button at the bottom of the history tab pulls another 50 entries (capped at 500). - "Run pipeline again" button in the expanded detail of each history card re-triggers that playlist's pipeline directly. Also dropped the "Discovered: completed" result pill — `tracks_discovered` in the result payload is a status string, not a count, and the same data is already in the before/after stats grid above. --- webui/static/auto-sync.js | 142 +++++++++++++++++++++-- webui/static/helper.js | 1 + webui/static/style.css | 233 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 366 insertions(+), 10 deletions(-) diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index eed7fd34..ce6dc3c4 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -21,6 +21,9 @@ let _autoSyncScheduleState = { runHistoryTotal: 0, }; let _autoSyncActiveTab = 'schedule'; +let _autoSyncSidebarFilter = ''; +let _autoSyncHistoryFilter = 'all'; // 'all' | 'error' | 'completed' | 'skipped' +let _autoSyncHistoryLimit = 50; function getMirroredSourceRef(p) { if (p && p.source_ref) return String(p.source_ref); @@ -176,7 +179,7 @@ async function refreshAutoSyncScheduleModal() { const [playlistRes, automationRes, historyRes] = await Promise.all([ fetch('/api/mirrored-playlists'), fetch('/api/automations'), - fetch('/api/playlist-pipeline/history?limit=50'), + fetch(`/api/playlist-pipeline/history?limit=${_autoSyncHistoryLimit}`), ]); const playlists = await playlistRes.json(); const automations = await automationRes.json(); @@ -238,7 +241,13 @@ function renderAutoSyncScheduleModal() {
- +
${schedulePanel}
${automationPanel}
@@ -255,8 +264,11 @@ function setAutoSyncTab(tab) { } function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { - const schedulablePlaylists = playlists.filter(autoSyncCanSchedulePlaylist); - const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p)); + const filter = (_autoSyncSidebarFilter || '').trim().toLowerCase(); + const matchesFilter = (p) => !filter || (p.name || '').toLowerCase().includes(filter) + || autoSyncSourceLabel(p.source || '').toLowerCase().includes(filter); + const schedulablePlaylists = playlists.filter(p => autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); + const unavailablePlaylists = playlists.filter(p => !autoSyncCanSchedulePlaylist(p) && matchesFilter(p)); const grouped = schedulablePlaylists.reduce((acc, p) => { const key = p.source || 'other'; if (!acc[key]) acc[key] = []; @@ -308,6 +320,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { `; }).join(''); + const filterValue = _esc(_autoSyncSidebarFilter || ''); return `
@@ -319,6 +332,11 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
${bucketHtml}
@@ -326,6 +344,21 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) { `; } +function setAutoSyncSidebarFilter(value) { + _autoSyncSidebarFilter = String(value || ''); + // Only re-render the sidebar/board portion to keep input focus. + const panel = document.getElementById('auto-sync-schedule-panel'); + if (!panel) return; + const { playlists, playlistSchedules } = _autoSyncScheduleState; + panel.innerHTML = renderAutoSyncSchedulePanel(playlists, playlistSchedules); + // Restore focus to the search input + caret position at end. + const input = panel.querySelector('.auto-sync-sidebar-search'); + if (input) { + input.focus(); + try { input.setSelectionRange(value.length, value.length); } catch (_) {} + } +} + function getAutoSyncPipelinePlaylists(playlists) { return playlists .map(p => ({ playlist: p, state: p.pipeline_state || null })) @@ -431,28 +464,77 @@ function renderAutoSyncHistoryPanel(history, total) {
`; } + const filter = _autoSyncHistoryFilter || 'all'; + const tabs = [ + ['all', 'All', history.length], + ['error', 'Errors', history.filter(h => h.status === 'error' || h.status === 'skipped').length], + ['completed', 'Completed', history.filter(h => h.status === 'completed' || h.status === 'finished').length], + ]; + const filterTabsHtml = tabs.map(([key, label, count]) => ` + + `).join(''); + const canLoadMore = total > history.length; return `
Playlist pipeline run history Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.
- +
+
${filterTabsHtml}
+ +
Preparing run history...
+ ${canLoadMore ? ` +
+ +
+ ` : ''} `; } +function setAutoSyncHistoryFilter(key) { + _autoSyncHistoryFilter = ['error', 'completed'].includes(key) ? key : 'all'; + renderAutoSyncScheduleModal(); +} + +function loadMoreAutoSyncHistory() { + _autoSyncHistoryLimit = Math.min(500, _autoSyncHistoryLimit + 50); + refreshAutoSyncScheduleModal(); +} + function populateAutoSyncHistoryList(root = document) { const list = root.querySelector('.auto-sync-history-list'); if (!list) return; - const history = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : []; - const total = _autoSyncScheduleState.runHistoryTotal || 0; + const allHistory = Array.isArray(_autoSyncScheduleState.runHistory) ? _autoSyncScheduleState.runHistory : []; + const filter = _autoSyncHistoryFilter || 'all'; + const history = allHistory.filter(h => { + if (filter === 'error') return h.status === 'error' || h.status === 'skipped'; + if (filter === 'completed') return h.status === 'completed' || h.status === 'finished'; + return true; + }); list.innerHTML = ''; list.dataset.renderer = 'dom-cards'; list.dataset.renderedCount = '0'; + if (filter !== 'all' && !history.length) { + const note = document.createElement('div'); + note.className = 'auto-sync-history-empty'; + const strong = document.createElement('strong'); + strong.textContent = filter === 'error' ? 'No failed runs in the loaded window' : 'No completed runs in the loaded window'; + const small = document.createElement('span'); + small.textContent = 'Switch filters or load more history.'; + note.append(strong, small); + list.appendChild(note); + return; + } history.forEach((entry, index) => { try { list.appendChild(createAutoSyncHistoryEntryElement(entry, index)); @@ -769,9 +851,12 @@ function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) { ['Duration', entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '—'], ['Source', entry.source || after.source || before.source || '—'], ].map(([k, v]) => `
${_esc(k)}${_esc(autoSyncValueLabel(v))}
`).join(''); + // `tracks_discovered` was a status STRING (e.g. "completed"), not a + // count — kept it out of the pills so the panel doesn't show a + // confusing "Discovered: completed" chip. Same data is already + // surfaced as a before/after stat card above. const resultPills = [ ['Refreshed', result.playlists_refreshed], - ['Discovered', result.tracks_discovered], ['Synced', result.tracks_synced], ['Skipped', result.sync_skipped], ['Wishlisted', result.wishlist_queued], @@ -779,10 +864,21 @@ function autoSyncHistoryDetailHtml(entry, before, after, result, deltas) { .map(([k, v]) => `${_esc(k)}${_esc(String(v))}`).join(''); const errorBlock = result.error ? `
${_esc(result.error)}
` : ''; const logsHtml = autoSyncHistoryLogsCompactHtml(entry.log_lines); + const playlistId = entry.playlist_id || after.playlist_id || before.playlist_id || ''; + const playlistName = entry.playlist_name || after.name || before.name || ''; + const runAgainHtml = playlistId + ? `
+ +
` + : ''; return `
${statsHtml}
${factsHtml}
${resultPills ? `
${resultPills}
` : ''} + ${runAgainHtml} ${errorBlock} ${logsHtml} `; @@ -944,10 +1040,17 @@ function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; const isRunning = playlist.pipeline_state?.status === 'running'; + const health = autoSyncPlaylistHealth(playlist.id); + const healthClass = health.level === 'failing' ? 'failing' + : health.level === 'warning' ? 'warning' + : ''; return ` -
+
-
${_esc(playlist.name)}
+
+ ${health.level !== 'ok' ? `${health.level === 'failing' ? '!' : '⚠'}` : ''} + ${_esc(playlist.name)} +
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} @@ -962,6 +1065,25 @@ function autoSyncScheduledCardHtml(playlist, schedule) { `; } +function autoSyncPlaylistHealth(playlistId) { + // Look at the last 3 runs for this playlist in the loaded history. + // 3-in-a-row errors = failing (red dot). 1+ recent error = warning. + const history = _autoSyncScheduleState.runHistory || []; + const id = parseInt(playlistId, 10); + const recent = history + .filter(h => parseInt(h.playlist_id, 10) === id) + .slice(0, 3); + if (!recent.length) return { level: 'ok', tooltip: '' }; + const errored = recent.filter(h => h.status === 'error' || h.status === 'skipped'); + if (errored.length >= 3) { + return { level: 'failing', tooltip: `Last ${recent.length} runs failed — check Run History tab` }; + } + if (errored.length) { + return { level: 'warning', tooltip: `${errored.length} of last ${recent.length} runs failed` }; + } + return { level: 'ok', tooltip: '' }; +} + function autoSyncNextRunLabel(nextRun) { if (!nextRun) return ''; const ts = _autoParseUTC(nextRun); diff --git a/webui/static/helper.js b/webui/static/helper.js index 8a8c621f..5529e794 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -3415,6 +3415,7 @@ function closeHelperSearch() { const WHATS_NEW = { '2.6.2': [ { date: 'May 24, 2026 — 2.6.2 release' }, + { title: 'Auto-Sync manager: filter, failure indicators, history filters + load more', desc: 'a handful of UX additions on the Playlist Auto-Sync modal. sidebar gets a "Filter playlists…" search input so you can narrow down a long mirrored-playlist list. scheduled cards on the board now show a red ! badge when the last three pipeline runs all failed (yellow ⚠ if at least one of the last few failed) so chronically broken schedules surface visually instead of getting buried in the run-history tab. run history tab title shows a red error count when there are failed runs in the loaded window. the history tab itself gains All / Errors / Completed filter pills, a "Load more" footer that pulls another 50 entries, and a "Run pipeline again" button inside the expanded detail panel so you can re-trigger a specific playlist without leaving the modal. also dropped the "Discovered: completed" pill — `tracks_discovered` was a status string, not a count, and the same data is already in the before/after stats grid above.' }, { title: 'Auto-Sync manager redesigned to match the rest of the app', desc: 'the Auto-Sync modal got a top-to-bottom restyle so it stops feeling like it lives in a different app. modal shell now uses the standard SoulSync gradient + accent-tinted border + 20px radius, the KPI summary became inset stat tiles, the live pipeline monitor is auto-fill instead of a fixed 4-col grid, tabs are now underline-style instead of pill buttons, and the schedule board sidebar/columns use the dense dark-card pattern that Automations + Library pages already use. modal also fills more of the screen since there was a lot of unused real estate. run history cards got the same treatment — slim horizontal row matching `.automation-card` (status dot + name + flow chips + meta row), and the expanded panel uses the same stats-grid / log-section structure as the Automations run-history modal.' }, { title: 'Fix: Auto-Sync manager hung forever on "Loading schedule..."', desc: 'a regression while wiring up the new "in library" count: the join used `COLLATE NOCASE` on `tracks.title` and `artists.name`, which can\'t use the indexes those columns have. on a 300k-track library each playlist took ~18s. with 30 mirrored playlists the modal\'s `/api/mirrored-playlists` call would never come back. switched to case-sensitive equality so SQLite uses `idx_artists_name` + `idx_tracks_title` (6ms per playlist). misses pure-case-different matches but Spotify names are canonical against library imports, so it\'s a rounding-error tradeoff for the 3000× speedup.' }, { title: 'Fix: Auto-Sync run history `in library` always showed 0', desc: 'the SQL query was referencing `tracks.artist` — a column that hasn\'t existed since the schema went relational. `tracks` has `artist_id` (FK to `artists`), so the query threw "no such column", got swallowed by the surrounding try/except, and the count silently defaulted to 0 on every playlist. rewired the join through `artists` so the count actually reflects how many mirrored tracks already live in your library.' }, diff --git a/webui/static/style.css b/webui/static/style.css index afc4ed5d..9d4167f1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -11622,6 +11622,56 @@ body.helper-mode-active #dashboard-activity-feed:hover { letter-spacing: 0.3px; } +.auto-sync-sidebar-filter { + position: relative; + padding: 4px 16px 10px; +} + +.auto-sync-sidebar-search { + width: 100%; + height: 30px; + padding: 0 28px 0 10px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + background: rgba(0, 0, 0, 0.3); + color: #fff; + font-size: 12px; + font-family: inherit; + outline: none; + transition: border-color 0.2s ease, background 0.2s ease; +} + +.auto-sync-sidebar-search:focus { + border-color: rgba(var(--accent-rgb), 0.45); + background: rgba(var(--accent-rgb), 0.04); +} + +.auto-sync-sidebar-search::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.auto-sync-sidebar-filter-clear { + position: absolute; + right: 22px; + top: 50%; + transform: translateY(-50%); + width: 18px; + height: 18px; + border: 0; + border-radius: 50%; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.65); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; +} + +.auto-sync-sidebar-filter-clear:hover { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + .auto-sync-source-list { min-height: 0; overflow-y: auto; @@ -11963,6 +12013,189 @@ body.helper-mode-active #dashboard-activity-feed:hover { border-color: rgba(239, 68, 68, 0.4); } +.auto-sync-scheduled-card.failing { + border-color: rgba(239, 68, 68, 0.4); + box-shadow: 0 0 0 1px rgba(239, 68, 68, 0.15); +} + +.auto-sync-scheduled-card.warning { + border-color: rgba(250, 204, 21, 0.3); +} + +.auto-sync-scheduled-health { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + border-radius: 50%; + margin-right: 6px; + font-size: 10px; + font-weight: 800; + line-height: 1; + vertical-align: middle; +} + +.auto-sync-scheduled-health.failing { + background: rgba(239, 68, 68, 0.18); + color: #ef4444; + box-shadow: 0 0 6px rgba(239, 68, 68, 0.35); +} + +.auto-sync-scheduled-health.warning { + background: rgba(250, 204, 21, 0.18); + color: #facc15; +} + +/* Run history filters + load more + tab badge */ +.auto-sync-history-intro-controls { + display: flex; + align-items: center; + gap: 10px; +} + +.auto-sync-history-filters { + display: flex; + gap: 4px; + padding: 3px; + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + background: rgba(0, 0, 0, 0.25); +} + +.auto-sync-history-filter-btn { + display: inline-flex; + align-items: center; + gap: 6px; + height: 26px; + padding: 0 10px; + border: 0; + border-radius: 7px; + background: transparent; + color: rgba(255, 255, 255, 0.5); + cursor: pointer; + font-size: 11px; + font-weight: 600; + font-family: inherit; + transition: color 0.15s ease, background 0.15s ease; +} + +.auto-sync-history-filter-btn:hover { + color: rgba(255, 255, 255, 0.85); +} + +.auto-sync-history-filter-btn.active { + background: rgba(var(--accent-rgb), 0.18); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-history-filter-btn em { + font-style: normal; + padding: 1px 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.06); + color: rgba(255, 255, 255, 0.55); + font-size: 10px; + font-weight: 700; +} + +.auto-sync-history-filter-btn.active em { + background: rgba(var(--accent-rgb), 0.25); + color: rgb(var(--accent-light-rgb)); +} + +.auto-sync-history-filter-btn.has-errors:not(.active) em { + background: rgba(239, 68, 68, 0.18); + color: #ef4444; +} + +.auto-sync-history-filter-btn.has-errors.active { + background: rgba(239, 68, 68, 0.16); + color: #ef4444; +} + +.auto-sync-history-load-more-row { + display: flex; + justify-content: center; + padding: 12px 14px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-load-more { + height: 32px; + padding: 0 18px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 8px; + background: rgba(255, 255, 255, 0.04); + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; + transition: all 0.2s ease; + font-family: inherit; +} + +.auto-sync-history-load-more:hover { + background: rgba(var(--accent-rgb), 0.16); + border-color: rgba(var(--accent-rgb), 0.4); + color: rgb(var(--accent-light-rgb)); +} + +/* Tab badge (error count on Run History tab) */ +.auto-sync-tab-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 16px; + padding: 0 5px; + margin-left: 6px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.1); + color: rgba(255, 255, 255, 0.7); + font-size: 10px; + font-weight: 700; + line-height: 1; + vertical-align: middle; +} + +.auto-sync-tab-badge.error { + background: rgba(239, 68, 68, 0.2); + color: #ef4444; +} + +/* Run-again button inside history expanded detail */ +.auto-sync-history-detail-actions { + display: flex; + justify-content: flex-end; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.04); +} + +.auto-sync-history-run-again { + height: 30px; + padding: 0 14px; + border: 1px solid rgba(var(--accent-rgb), 0.3); + border-radius: 8px; + background: rgba(var(--accent-rgb), 0.12); + color: rgb(var(--accent-light-rgb)); + cursor: pointer; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.3px; + text-transform: uppercase; + font-family: inherit; + transition: all 0.2s ease; +} + +.auto-sync-history-run-again:hover { + background: rgba(var(--accent-rgb), 0.22); + border-color: rgba(var(--accent-rgb), 0.5); + color: rgb(var(--accent-neon-rgb)); +} + .auto-sync-automation-list { min-height: 0; overflow-y: auto;