Auto-Sync manager: filter, failure indicators, history filters
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.
This commit is contained in:
parent
dfdc6c6277
commit
d668bf4e6d
3 changed files with 366 additions and 10 deletions
|
|
@ -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() {
|
|||
<div class="auto-sync-tabs">
|
||||
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
|
||||
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
|
||||
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">Run History</button>
|
||||
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">
|
||||
Run History
|
||||
${(() => {
|
||||
const errs = (runHistory || []).filter(h => h.status === 'error' || h.status === 'skipped').length;
|
||||
return errs ? `<span class="auto-sync-tab-badge error">${errs}</span>` : '';
|
||||
})()}
|
||||
</button>
|
||||
</div>
|
||||
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</div>
|
||||
<div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
|
||||
|
|
@ -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 `
|
||||
<div class="auto-sync-board-intro">
|
||||
<div>
|
||||
|
|
@ -319,6 +332,11 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
|
|||
<div class="auto-sync-body">
|
||||
<aside class="auto-sync-sidebar">
|
||||
<div class="auto-sync-sidebar-title">Mirrored playlists</div>
|
||||
<div class="auto-sync-sidebar-filter">
|
||||
<input type="search" class="auto-sync-sidebar-search" placeholder="Filter playlists…"
|
||||
value="${filterValue}" oninput="setAutoSyncSidebarFilter(this.value)" />
|
||||
${_autoSyncSidebarFilter ? `<button type="button" class="auto-sync-sidebar-filter-clear" onclick="setAutoSyncSidebarFilter('')" aria-label="Clear filter">×</button>` : ''}
|
||||
</div>
|
||||
<div class="auto-sync-source-list">${sidebarHtml}${unavailableHtml}</div>
|
||||
</aside>
|
||||
<main class="auto-sync-board">${bucketHtml}</main>
|
||||
|
|
@ -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) {
|
|||
</div>
|
||||
`;
|
||||
}
|
||||
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]) => `
|
||||
<button class="auto-sync-history-filter-btn ${filter === key ? 'active' : ''} ${key === 'error' && count > 0 ? 'has-errors' : ''}"
|
||||
onclick="setAutoSyncHistoryFilter('${key}')" type="button">
|
||||
${_esc(label)} <em>${count}</em>
|
||||
</button>
|
||||
`).join('');
|
||||
const canLoadMore = total > history.length;
|
||||
return `
|
||||
<div class="auto-sync-history-intro">
|
||||
<div>
|
||||
<strong>Playlist pipeline run history</strong>
|
||||
<span>Each run records what changed on the mirrored playlist before and after refresh, discovery, sync, and wishlist processing.</span>
|
||||
</div>
|
||||
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
|
||||
<div class="auto-sync-history-intro-controls">
|
||||
<div class="auto-sync-history-filters">${filterTabsHtml}</div>
|
||||
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="auto-sync-history-list" data-renderer="pending">
|
||||
<div class="auto-sync-history-loading">Preparing run history...</div>
|
||||
</div>
|
||||
${canLoadMore ? `
|
||||
<div class="auto-sync-history-load-more-row">
|
||||
<button type="button" class="auto-sync-history-load-more" onclick="loadMoreAutoSyncHistory()">
|
||||
Load more (${history.length} of ${total})
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
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]) => `<div class="auto-sync-history-fact"><span>${_esc(k)}</span><strong>${_esc(autoSyncValueLabel(v))}</strong></div>`).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]) => `<span class="auto-sync-history-result-pill"><em>${_esc(k)}</em>${_esc(String(v))}</span>`).join('');
|
||||
const errorBlock = result.error ? `<div class="auto-sync-history-error">${_esc(result.error)}</div>` : '';
|
||||
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
|
||||
? `<div class="auto-sync-history-detail-actions">
|
||||
<button type="button" class="auto-sync-history-run-again"
|
||||
onclick="event.stopPropagation(); runMirroredPlaylistPipeline(${parseInt(playlistId, 10)}, '${_escAttr(playlistName)}')">
|
||||
Run pipeline again
|
||||
</button>
|
||||
</div>`
|
||||
: '';
|
||||
return `
|
||||
<div class="auto-sync-history-stats-grid">${statsHtml}</div>
|
||||
<div class="auto-sync-history-facts-row">${factsHtml}</div>
|
||||
${resultPills ? `<div class="auto-sync-history-result-row">${resultPills}</div>` : ''}
|
||||
${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 `
|
||||
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
|
||||
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'} ${healthClass}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
|
||||
<div class="auto-sync-scheduled-main">
|
||||
<div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
|
||||
<div class="auto-sync-scheduled-name">
|
||||
${health.level !== 'ok' ? `<span class="auto-sync-scheduled-health ${healthClass}" title="${_escAttr(health.tooltip)}">${health.level === 'failing' ? '!' : '⚠'}</span>` : ''}
|
||||
${_esc(playlist.name)}
|
||||
</div>
|
||||
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks</div>
|
||||
<div class="auto-sync-scheduled-timing">
|
||||
<span>${_esc(autoSyncIntervalLabel(schedule?.hours || 24))}</span>
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue