Add playlist auto-sync run history

Persist per-playlist pipeline run snapshots from the shared playlist pipeline, expose a history API, and upgrade the Auto-Sync modal with live pipeline monitoring, Run now controls, and a runs-style history tab.
This commit is contained in:
Broque Thomas 2026-05-25 00:55:55 -07:00
parent 5f62152acb
commit efdcde1892
5 changed files with 999 additions and 37 deletions

View file

@ -11,8 +11,10 @@ future playlist-card "Run Pipeline" button can call the same command.
from __future__ import annotations from __future__ import annotations
import json
import threading import threading
import time import time
from datetime import datetime, timezone
from typing import Any, Callable, Dict, List from typing import Any, Callable, Dict, List
@ -48,7 +50,12 @@ def run_mirrored_playlist_pipeline(
else: else:
deps.state.set_pipeline_running(True) deps.state.set_pipeline_running(True)
automation_id = config.get('_automation_id') automation_id = config.get('_automation_id')
trigger_source = config.get('_trigger_source') or (
'manual' if str(automation_id or '').startswith('mirrored_') else 'automation'
)
pipeline_start = time.time() pipeline_start = time.time()
history_playlists: List[Dict[str, Any]] = []
before_snapshots: Dict[int, Dict[str, Any]] = {}
try: try:
db = deps.get_database() db = deps.get_database()
@ -65,6 +72,12 @@ def run_mirrored_playlist_pipeline(
if not playlists: if not playlists:
deps.state.set_pipeline_running(False) deps.state.set_pipeline_running(False)
return {'status': 'error', 'error': 'No refreshable playlists found'} return {'status': 'error', 'error': 'No refreshable playlists found'}
history_playlists = list(playlists)
before_snapshots = {
int(pl['id']): _playlist_history_snapshot(db, pl)
for pl in history_playlists
if pl.get('id')
}
deps.update_progress( deps.update_progress(
automation_id, automation_id,
@ -117,8 +130,7 @@ def run_mirrored_playlist_pipeline(
log_type='success', log_type='success',
) )
deps.state.set_pipeline_running(False) result = {
return {
'status': 'completed', 'status': 'completed',
'_manages_own_progress': True, '_manages_own_progress': True,
'playlists_refreshed': str(refreshed), 'playlists_refreshed': str(refreshed),
@ -128,9 +140,38 @@ def run_mirrored_playlist_pipeline(
'wishlist_queued': str(sync_summary['wishlist_queued']), 'wishlist_queued': str(sync_summary['wishlist_queued']),
'duration_seconds': str(duration), 'duration_seconds': str(duration),
} }
try:
_record_playlist_pipeline_history(
db,
history_playlists,
before_snapshots,
result,
status='completed',
started_at=pipeline_start,
finished_at=time.time(),
trigger_source=trigger_source,
)
except Exception as history_error: # noqa: BLE001 - history should never fail a successful pipeline
deps.logger.debug(f"[Pipeline] History recording failed: {history_error}")
deps.state.set_pipeline_running(False)
return result
except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts except Exception as e: # noqa: BLE001 - pipeline callers should receive status dicts
deps.state.set_pipeline_running(False) deps.state.set_pipeline_running(False)
try:
if history_playlists:
_record_playlist_pipeline_history(
db,
history_playlists,
before_snapshots,
{'status': 'error', 'error': str(e), '_manages_own_progress': True},
status='error',
started_at=pipeline_start,
finished_at=time.time(),
trigger_source=trigger_source,
)
except Exception as history_error: # noqa: BLE001 - history should never mask pipeline errors
deps.logger.debug(f"[Pipeline] History recording failed after error: {history_error}")
deps.update_progress( deps.update_progress(
automation_id, automation_id,
status='error', status='error',
@ -142,6 +183,79 @@ def run_mirrored_playlist_pipeline(
return {'status': 'error', 'error': str(e), '_manages_own_progress': True} return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
def _pipeline_history_timestamp(ts: float) -> str:
return datetime.fromtimestamp(ts, timezone.utc).isoformat()
def _playlist_history_snapshot(db: Any, playlist: Dict[str, Any]) -> Dict[str, Any]:
playlist_id = int(playlist['id'])
current = db.get_mirrored_playlist(playlist_id) or playlist
counts = db.get_mirrored_playlist_status_counts(playlist_id)
return {
'playlist_id': playlist_id,
'name': current.get('name') or playlist.get('name') or '',
'source': current.get('source') or playlist.get('source') or '',
'track_count': int(counts.get('total') or current.get('track_count') or 0),
'discovered_count': int(counts.get('discovered') or 0),
'wishlisted_count': int(counts.get('wishlisted') or 0),
'in_library_count': int(counts.get('in_library') or 0),
}
def _playlist_history_summary(before: Dict[str, Any], after: Dict[str, Any], status: str) -> str:
before_tracks = int(before.get('track_count') or 0)
after_tracks = int(after.get('track_count') or 0)
track_delta = after_tracks - before_tracks
before_discovered = int(before.get('discovered_count') or 0)
after_discovered = int(after.get('discovered_count') or 0)
discovered_delta = after_discovered - before_discovered
parts = [status.capitalize()]
parts.append(f"{before_tracks} -> {after_tracks} tracks")
if track_delta:
parts.append(f"{track_delta:+d} tracks")
if discovered_delta:
parts.append(f"{discovered_delta:+d} discovered")
return ' | '.join(parts)
def _record_playlist_pipeline_history(
db: Any,
playlists: List[Dict[str, Any]],
before_snapshots: Dict[int, Dict[str, Any]],
result: Dict[str, Any],
*,
status: str,
started_at: float,
finished_at: float,
trigger_source: str,
) -> None:
if not hasattr(db, 'insert_playlist_pipeline_run_history'):
return
duration = max(0, finished_at - started_at)
for playlist in playlists:
if not playlist.get('id'):
continue
playlist_id = int(playlist['id'])
before = before_snapshots.get(playlist_id, {})
after = _playlist_history_snapshot(db, playlist)
db.insert_playlist_pipeline_run_history(
playlist_id=playlist_id,
playlist_name=after.get('name') or playlist.get('name') or '',
source=after.get('source') or playlist.get('source') or '',
profile_id=int(playlist.get('profile_id') or 1),
trigger_source=trigger_source,
started_at=_pipeline_history_timestamp(started_at),
finished_at=_pipeline_history_timestamp(finished_at),
duration_seconds=duration,
status=status,
summary=_playlist_history_summary(before, after, status),
before_json=json.dumps(before),
after_json=json.dumps(after),
result_json=json.dumps(result),
log_lines=None,
)
def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None: def _resolve_pipeline_playlists(db: Any, playlist_id: Any, process_all: bool) -> List[Dict[str, Any]] | None:
if process_all: if process_all:
return db.get_mirrored_playlists() return db.get_mirrored_playlists()

View file

@ -531,6 +531,30 @@ class MusicDatabase:
""") """)
cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)")
# Playlist pipeline run history table
cursor.execute("""
CREATE TABLE IF NOT EXISTS playlist_pipeline_run_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
playlist_id INTEGER,
playlist_name TEXT,
source TEXT,
profile_id INTEGER DEFAULT 1,
trigger_source TEXT DEFAULT 'pipeline',
started_at TIMESTAMP,
finished_at TIMESTAMP,
duration_seconds REAL,
status TEXT NOT NULL,
summary TEXT,
before_json TEXT,
after_json TEXT,
result_json TEXT,
log_lines TEXT,
FOREIGN KEY (playlist_id) REFERENCES mirrored_playlists(id) ON DELETE SET NULL
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_playlist_id ON playlist_pipeline_run_history(playlist_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_pprh_profile_id ON playlist_pipeline_run_history(profile_id)")
# Add explored_at to mirrored_playlists (migration) # Add explored_at to mirrored_playlists (migration)
self._add_mirrored_playlist_explored_column(cursor) self._add_mirrored_playlist_explored_column(cursor)
@ -12417,6 +12441,73 @@ class MusicDatabase:
logger.error(f"Error clearing automation run history: {e}") logger.error(f"Error clearing automation run history: {e}")
return 0 return 0
def insert_playlist_pipeline_run_history(self, playlist_id, playlist_name, source,
profile_id, trigger_source, started_at,
finished_at, duration_seconds, status,
summary=None, before_json=None,
after_json=None, result_json=None,
log_lines=None):
"""Insert a playlist pipeline run history entry and retain recent rows per profile."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT INTO playlist_pipeline_run_history
(playlist_id, playlist_name, source, profile_id, trigger_source,
started_at, finished_at, duration_seconds, status, summary,
before_json, after_json, result_json, log_lines)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
playlist_id, playlist_name, source, profile_id, trigger_source,
started_at, finished_at, duration_seconds, status, summary,
before_json, after_json, result_json, log_lines,
))
cursor.execute("""
DELETE FROM playlist_pipeline_run_history
WHERE profile_id = ? AND id NOT IN (
SELECT id FROM playlist_pipeline_run_history
WHERE profile_id = ?
ORDER BY id DESC LIMIT 300
)
""", (profile_id, profile_id))
conn.commit()
return True
except Exception as e:
logger.error(f"Error inserting playlist pipeline run history for {playlist_id}: {e}")
return False
def get_playlist_pipeline_run_history(self, profile_id=1, playlist_id=None, limit=50, offset=0):
"""Get playlist pipeline run history, newest first."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
where = ["profile_id = ?"]
params = [profile_id]
if playlist_id:
where.append("playlist_id = ?")
params.append(playlist_id)
where_sql = " AND ".join(where)
cursor.execute(
f"SELECT COUNT(*) FROM playlist_pipeline_run_history WHERE {where_sql}",
params,
)
total = cursor.fetchone()[0]
cursor.execute(f"""
SELECT id, playlist_id, playlist_name, source, profile_id, trigger_source,
started_at, finished_at, duration_seconds, status, summary,
before_json, after_json, result_json, log_lines
FROM playlist_pipeline_run_history
WHERE {where_sql}
ORDER BY id DESC
LIMIT ? OFFSET ?
""", [*params, limit, offset])
cols = [d[0] for d in cursor.description]
rows = [dict(zip(cols, row, strict=False)) for row in cursor.fetchall()]
return {'history': rows, 'total': total}
except Exception as e:
logger.error(f"Error getting playlist pipeline run history: {e}")
return {'history': [], 'total': 0}
def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]: def get_radio_tracks(self, track_id, limit=20, exclude_ids=None) -> Dict[str, Any]:
"""Find similar tracks for radio mode auto-play queue. """Find similar tracks for radio mode auto-play queue.

View file

@ -32479,6 +32479,37 @@ def get_mirrored_playlist_pipeline_status_endpoint(playlist_id):
logger.error(f"Error getting mirrored playlist pipeline status: {e}") logger.error(f"Error getting mirrored playlist pipeline status: {e}")
return jsonify({"error": str(e)}), 500 return jsonify({"error": str(e)}), 500
@app.route('/api/playlist-pipeline/history', methods=['GET'])
def get_playlist_pipeline_history_endpoint():
"""Return persisted run history for mirrored playlist pipeline executions."""
try:
database = get_database()
profile_id = get_current_profile_id()
limit = min(max(int(request.args.get('limit', 50)), 1), 100)
offset = max(int(request.args.get('offset', 0)), 0)
playlist_id_raw = request.args.get('playlist_id')
playlist_id = int(playlist_id_raw) if playlist_id_raw else None
data = database.get_playlist_pipeline_run_history(
profile_id=profile_id,
playlist_id=playlist_id,
limit=limit,
offset=offset,
)
for entry in data.get('history', []):
for key in ('before_json', 'after_json', 'result_json', 'log_lines'):
if entry.get(key):
try:
entry[key] = json.loads(entry[key])
except (json.JSONDecodeError, TypeError):
entry[key] = [] if key == 'log_lines' else {}
else:
entry[key] = [] if key == 'log_lines' else {}
return jsonify(data)
except Exception as e:
logger.error(f"Error getting playlist pipeline history: {e}")
return jsonify({"error": str(e)}), 500
@app.route('/api/mirrored-playlists/<int:playlist_id>', methods=['DELETE']) @app.route('/api/mirrored-playlists/<int:playlist_id>', methods=['DELETE'])
def delete_mirrored_playlist_endpoint(playlist_id): def delete_mirrored_playlist_endpoint(playlist_id):
"""Delete a mirrored playlist.""" """Delete a mirrored playlist."""

View file

@ -10,11 +10,15 @@
const mirroredPipelinePollers = {}; const mirroredPipelinePollers = {};
const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168]; const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168];
let _autoSyncStatusPoller = null;
let _autoSyncIsDragging = false;
let _autoSyncScheduleState = { let _autoSyncScheduleState = {
playlists: [], playlists: [],
automations: [], automations: [],
playlistSchedules: {}, playlistSchedules: {},
automationPipelines: [], automationPipelines: [],
runHistory: [],
runHistoryTotal: 0,
}; };
let _autoSyncActiveTab = 'schedule'; let _autoSyncActiveTab = 'schedule';
@ -103,7 +107,7 @@ function autoSyncIsScheduleOwned(auto) {
return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:'); return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:');
} }
function buildAutoSyncScheduleState(playlists, automations) { function buildAutoSyncScheduleState(playlists, automations, historyData = {}) {
const playlistSchedules = {}; const playlistSchedules = {};
const automationPipelines = []; const automationPipelines = [];
const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation); const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation);
@ -124,7 +128,14 @@ function buildAutoSyncScheduleState(playlists, automations) {
automationPipelines.push(auto); automationPipelines.push(auto);
} }
}); });
return { playlists, automations, playlistSchedules, automationPipelines }; return {
playlists,
automations,
playlistSchedules,
automationPipelines,
runHistory: historyData.history || [],
runHistoryTotal: historyData.total || 0,
};
} }
async function openAutoSyncScheduleModal() { async function openAutoSyncScheduleModal() {
@ -154,6 +165,7 @@ async function openAutoSyncScheduleModal() {
function closeAutoSyncScheduleModal() { function closeAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal'); const overlay = document.getElementById('auto-sync-schedule-modal');
stopAutoSyncStatusPolling();
if (overlay) overlay.remove(); if (overlay) overlay.remove();
} }
@ -161,16 +173,20 @@ async function refreshAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal'); const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) return; if (!overlay) return;
try { try {
const [playlistRes, automationRes] = await Promise.all([ const [playlistRes, automationRes, historyRes] = await Promise.all([
fetch('/api/mirrored-playlists'), fetch('/api/mirrored-playlists'),
fetch('/api/automations'), fetch('/api/automations'),
fetch('/api/playlist-pipeline/history?limit=50'),
]); ]);
const playlists = await playlistRes.json(); const playlists = await playlistRes.json();
const automations = await automationRes.json(); const automations = await automationRes.json();
const historyData = await historyRes.json();
if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists'); if (!playlistRes.ok || playlists.error) throw new Error(playlists.error || 'Failed to load mirrored playlists');
if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations'); if (!automationRes.ok || automations.error) throw new Error(automations.error || 'Failed to load automations');
_autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations); if (!historyRes.ok || historyData.error) throw new Error(historyData.error || 'Failed to load pipeline run history');
_autoSyncScheduleState = buildAutoSyncScheduleState(playlists, automations, historyData);
renderAutoSyncScheduleModal(); renderAutoSyncScheduleModal();
manageAutoSyncStatusPolling();
} catch (err) { } catch (err) {
overlay.innerHTML = ` overlay.innerHTML = `
<div class="auto-sync-modal"> <div class="auto-sync-modal">
@ -188,16 +204,19 @@ function renderAutoSyncScheduleModal() {
const overlay = document.getElementById('auto-sync-schedule-modal'); const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) return; if (!overlay) return;
const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState; const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
const scheduledCount = Object.keys(playlistSchedules).length; const scheduledCount = Object.keys(playlistSchedules).length;
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length; const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length;
const pipelineCount = automationPipelines.length; const pipelineCount = automationPipelines.length;
const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0); const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0);
const scheduleActive = _autoSyncActiveTab === 'schedule'; const scheduleActive = _autoSyncActiveTab === 'schedule';
const automationsActive = _autoSyncActiveTab === 'automations'; const automationsActive = _autoSyncActiveTab === 'automations';
const historyActive = _autoSyncActiveTab === 'history';
const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules); const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules);
const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists); const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists);
const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal);
const monitor = renderAutoSyncPipelineMonitor(playlists);
overlay.innerHTML = ` overlay.innerHTML = `
<div class="auto-sync-modal"> <div class="auto-sync-modal">
@ -215,18 +234,21 @@ function renderAutoSyncScheduleModal() {
<div><span>${pipelineCount}</span><small>automation pipelines</small></div> <div><span>${pipelineCount}</span><small>automation pipelines</small></div>
<div><span>${totalTracks}</span><small>mirrored tracks</small></div> <div><span>${totalTracks}</span><small>mirrored tracks</small></div>
</div> </div>
${monitor}
<div class="auto-sync-tabs"> <div class="auto-sync-tabs">
<button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button> <button class="${scheduleActive ? 'active' : ''}" onclick="setAutoSyncTab('schedule')">Schedule Board</button>
<button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button> <button class="${automationsActive ? 'active' : ''}" onclick="setAutoSyncTab('automations')">Automation Pipelines</button>
<button class="${historyActive ? 'active' : ''}" onclick="setAutoSyncTab('history')">Run History</button>
</div> </div>
<div class="auto-sync-tab-panel ${scheduleActive ? 'active' : ''}" id="auto-sync-schedule-panel">${schedulePanel}</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> <div class="auto-sync-tab-panel ${automationsActive ? 'active' : ''}" id="auto-sync-automation-panel">${automationPanel}</div>
<div class="auto-sync-tab-panel ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div>
</div> </div>
`; `;
} }
function setAutoSyncTab(tab) { function setAutoSyncTab(tab) {
_autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule'; _autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule';
renderAutoSyncScheduleModal(); renderAutoSyncScheduleModal();
} }
@ -248,7 +270,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
const schedule = playlistSchedules[p.id]; const schedule = playlistSchedules[p.id];
const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled'; const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled';
return ` return `
<div class="auto-sync-playlist ${schedule ? 'scheduled' : ''}" draggable="true" data-playlist-id="${p.id}" ondragstart="autoSyncDragStart(event)"> <div class="auto-sync-playlist ${schedule ? 'scheduled' : ''}" draggable="true" data-playlist-id="${p.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
<div class="auto-sync-playlist-name">${_esc(p.name)}</div> <div class="auto-sync-playlist-name">${_esc(p.name)}</div>
<div class="auto-sync-playlist-meta">${p.track_count || 0} tracks &middot; ${_esc(assigned)}</div> <div class="auto-sync-playlist-meta">${p.track_count || 0} tracks &middot; ${_esc(assigned)}</div>
</div> </div>
@ -302,6 +324,90 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
`; `;
} }
function getAutoSyncPipelinePlaylists(playlists) {
return playlists
.map(p => ({ playlist: p, state: p.pipeline_state || null }))
.filter(item => item.state && item.state.status && item.state.status !== 'idle')
.sort((a, b) => {
const aRunning = a.state.status === 'running' ? 1 : 0;
const bRunning = b.state.status === 'running' ? 1 : 0;
if (aRunning !== bRunning) return bRunning - aRunning;
return (b.state.finished_at || b.state.started_at || 0) - (a.state.finished_at || a.state.started_at || 0);
});
}
function autoSyncPipelineStatusLabel(status) {
if (status === 'running') return 'Running';
if (status === 'finished') return 'Completed';
if (status === 'skipped') return 'Skipped';
if (status === 'error') return 'Needs attention';
return 'Idle';
}
function autoSyncPipelineStatusClass(status) {
if (status === 'running') return 'running';
if (status === 'finished') return 'finished';
if (status === 'error' || status === 'skipped') return 'error';
return 'idle';
}
function renderAutoSyncPipelineMonitor(playlists) {
const pipelineItems = getAutoSyncPipelinePlaylists(playlists);
const running = pipelineItems.filter(item => item.state.status === 'running');
const recent = pipelineItems.filter(item => item.state.status !== 'running').slice(0, 2);
const visible = [...running, ...recent].slice(0, 4);
const title = running.length
? `${running.length} pipeline${running.length === 1 ? '' : 's'} running`
: 'No pipelines running';
const detail = running.length
? 'Live status refreshes while this modal is open.'
: 'Use Run now on a scheduled playlist when you want the pipeline immediately.';
return `
<section class="auto-sync-monitor">
<div class="auto-sync-monitor-head">
<div>
<span class="auto-sync-monitor-kicker">Live pipeline monitor</span>
<strong>${_esc(title)}</strong>
<small>${_esc(detail)}</small>
</div>
<button onclick="refreshAutoSyncScheduleModal()">Refresh</button>
</div>
<div class="auto-sync-monitor-list">
${visible.length ? visible.map(({ playlist, state }) => autoSyncPipelineMonitorCardHtml(playlist, state)).join('') : `
<div class="auto-sync-monitor-empty">
<span>Ready</span>
<small>Scheduled playlists will appear here while the all-in-one pipeline runs.</small>
</div>
`}
</div>
</section>
`;
}
function autoSyncPipelineMonitorCardHtml(playlist, state) {
const status = state.status || 'idle';
const progress = Math.max(0, Math.min(100, parseInt(state.progress, 10) || 0));
const latest = Array.isArray(state.log) && state.log.length ? state.log[state.log.length - 1].message : '';
const phase = state.phase || autoSyncPipelineStatusLabel(status);
return `
<article class="auto-sync-monitor-card ${autoSyncPipelineStatusClass(status)}">
<div class="auto-sync-monitor-card-main">
<div class="auto-sync-monitor-title-row">
<strong>${_esc(playlist.name || `Playlist #${playlist.id}`)}</strong>
<span>${_esc(autoSyncPipelineStatusLabel(status))}</span>
</div>
<div class="auto-sync-monitor-phase">${_esc(phase)}</div>
<div class="auto-sync-monitor-progress" aria-label="${progress}% complete">
<div style="width: ${progress}%"></div>
</div>
${latest ? `<small>${_esc(latest)}</small>` : ''}
</div>
<button onclick="event.stopPropagation(); openMirroredPlaylistModal(${playlist.id})">Details</button>
</article>
`;
}
function renderAutoSyncAutomationPanel(automationPipelines, playlists) { function renderAutoSyncAutomationPanel(automationPipelines, playlists) {
if (!automationPipelines.length) { if (!automationPipelines.length) {
return '<div class="auto-sync-automation-empty">No Automations-page playlist pipelines found.</div>'; return '<div class="auto-sync-automation-empty">No Automations-page playlist pipelines found.</div>';
@ -317,6 +423,118 @@ function renderAutoSyncAutomationPanel(automationPipelines, playlists) {
`; `;
} }
function renderAutoSyncHistoryPanel(history, total) {
if (!history.length) {
return `
<div class="auto-sync-history-empty">
<strong>No playlist pipeline runs yet</strong>
<span>Future Auto-Sync and playlist pipeline runs will record before/after playlist snapshots here.</span>
</div>
`;
}
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>
<div class="auto-sync-history-list">
${history.map(autoSyncHistoryEntryHtml).join('')}
${total > history.length ? `<div class="auto-sync-history-total">Showing ${history.length} of ${total} runs</div>` : ''}
</div>
`;
}
function autoSyncHistoryEntryHtml(entry) {
const status = entry.status || 'completed';
const before = entry.before_json || {};
const after = entry.after_json || {};
const result = entry.result_json || {};
const started = entry.started_at ? _autoTimeAgo(entry.started_at) : '';
const duration = entry.duration_seconds ? autoSyncDurationLabel(entry.duration_seconds) : '';
const trackDelta = autoSyncDelta(after.track_count, before.track_count);
const discoveredDelta = autoSyncDelta(after.discovered_count, before.discovered_count);
const wishlistDelta = autoSyncDelta(after.wishlisted_count, before.wishlisted_count);
const libraryDelta = autoSyncDelta(after.in_library_count, before.in_library_count);
const entryId = `auto-sync-history-${entry.id}`;
return `
<article class="auto-sync-history-entry">
<div class="auto-sync-history-row" onclick="autoSyncToggleHistoryEntry('${entryId}')">
<span class="auto-sync-history-status ${_escAttr(status)}">${_esc(autoSyncHistoryStatusLabel(status))}</span>
<div class="auto-sync-history-main">
<strong>${_esc(entry.playlist_name || 'Mirrored playlist')}</strong>
<small>${_esc(entry.summary || '')}</small>
</div>
<div class="auto-sync-history-meta">
${started ? `<span>${_esc(started)}</span>` : ''}
${duration ? `<span>${_esc(duration)}</span>` : ''}
<span>${_esc(entry.trigger_source || 'pipeline')}</span>
</div>
</div>
<div id="${entryId}" class="auto-sync-history-detail">
<div class="auto-sync-history-stats">
${autoSyncHistoryStatHtml('Tracks', before.track_count, after.track_count, trackDelta)}
${autoSyncHistoryStatHtml('Discovered', before.discovered_count, after.discovered_count, discoveredDelta)}
${autoSyncHistoryStatHtml('Wishlisted', before.wishlisted_count, after.wishlisted_count, wishlistDelta)}
${autoSyncHistoryStatHtml('In library', before.in_library_count, after.in_library_count, libraryDelta)}
</div>
<div class="auto-sync-history-result">
${autoSyncHistoryResultPill('Refreshed', result.playlists_refreshed)}
${autoSyncHistoryResultPill('Synced', result.tracks_synced)}
${autoSyncHistoryResultPill('Skipped', result.sync_skipped)}
${autoSyncHistoryResultPill('Queued', result.wishlist_queued)}
${result.error ? `<span class="error">${_esc(result.error)}</span>` : ''}
</div>
</div>
</article>
`;
}
function autoSyncToggleHistoryEntry(entryId) {
const el = document.getElementById(entryId);
if (el) el.classList.toggle('expanded');
}
function autoSyncHistoryStatusLabel(status) {
if (status === 'completed' || status === 'finished') return 'Completed';
if (status === 'error') return 'Error';
if (status === 'skipped') return 'Skipped';
return status || 'Run';
}
function autoSyncDurationLabel(seconds) {
const total = Math.max(0, Math.round(parseFloat(seconds) || 0));
if (total < 60) return `${total}s`;
const mins = Math.floor(total / 60);
const secs = total % 60;
return `${mins}m ${secs}s`;
}
function autoSyncDelta(after, before) {
const a = parseInt(after, 10) || 0;
const b = parseInt(before, 10) || 0;
return a - b;
}
function autoSyncHistoryStatHtml(label, before, after, delta) {
const beforeValue = parseInt(before, 10) || 0;
const afterValue = parseInt(after, 10) || 0;
const deltaText = delta ? ` (${delta > 0 ? '+' : ''}${delta})` : '';
return `
<div>
<span>${_esc(label)}</span>
<strong>${beforeValue} -> ${afterValue}${_esc(deltaText)}</strong>
</div>
`;
}
function autoSyncHistoryResultPill(label, value) {
if (value === undefined || value === null || value === '') return '';
return `<span>${_esc(label)}: ${_esc(String(value))}</span>`;
}
function autoSyncAutomationCardHtml(auto, playlists) { function autoSyncAutomationCardHtml(auto, playlists) {
const cfg = auto.action_config || {}; const cfg = auto.action_config || {};
const playlistId = autoSyncPlaylistIdFromAutomation(auto); const playlistId = autoSyncPlaylistIdFromAutomation(auto);
@ -348,8 +566,9 @@ function autoSyncAutomationCardHtml(auto, playlists) {
function autoSyncScheduledCardHtml(playlist, schedule) { function autoSyncScheduledCardHtml(playlist, schedule) {
const enabled = schedule?.enabled !== false; const enabled = schedule?.enabled !== false;
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
const isRunning = playlist.pipeline_state?.status === 'running';
return ` return `
<div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)"> <div class="auto-sync-scheduled-card ${enabled ? '' : 'disabled'}" draggable="true" data-playlist-id="${playlist.id}" ondragstart="autoSyncDragStart(event)" ondragend="autoSyncDragEnd()">
<div> <div>
<div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div> <div class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div> <div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} &middot; ${playlist.track_count || 0} tracks</div>
@ -358,7 +577,10 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''} ${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
</div> </div>
</div> </div>
<button onclick="event.stopPropagation(); unscheduleAutoSyncPlaylist(${playlist.id})" title="Remove this Auto-Sync schedule">&times;</button> <div class="auto-sync-scheduled-actions">
<button class="run" onclick="event.stopPropagation(); runAutoSyncScheduledPlaylist(${playlist.id})" title="Run the playlist pipeline now" ${isRunning ? 'disabled' : ''}>${isRunning ? 'Running' : 'Run now'}</button>
<button onclick="event.stopPropagation(); unscheduleAutoSyncPlaylist(${playlist.id})" title="Remove this Auto-Sync schedule">&times;</button>
</div>
</div> </div>
`; `;
} }
@ -379,6 +601,7 @@ function autoSyncNextRunLabel(nextRun) {
function autoSyncDragStart(event) { function autoSyncDragStart(event) {
const playlistId = event.currentTarget?.dataset?.playlistId; const playlistId = event.currentTarget?.dataset?.playlistId;
if (!playlistId) return; if (!playlistId) return;
_autoSyncIsDragging = true;
event.dataTransfer.setData('text/plain', playlistId); event.dataTransfer.setData('text/plain', playlistId);
event.dataTransfer.effectAllowed = 'move'; event.dataTransfer.effectAllowed = 'move';
} }
@ -401,6 +624,7 @@ function autoSyncDragLeave(event) {
async function autoSyncDrop(event, hours) { async function autoSyncDrop(event, hours) {
event.preventDefault(); event.preventDefault();
_autoSyncIsDragging = false;
const col = event.currentTarget; const col = event.currentTarget;
if (col) col.classList.remove('drag-over'); if (col) col.classList.remove('drag-over');
const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10); const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10);
@ -408,6 +632,10 @@ async function autoSyncDrop(event, hours) {
await saveAutoSyncPlaylistSchedule(playlistId, hours); await saveAutoSyncPlaylistSchedule(playlistId, hours);
} }
function autoSyncDragEnd() {
_autoSyncIsDragging = false;
}
async function saveAutoSyncPlaylistSchedule(playlistId, hours) { async function saveAutoSyncPlaylistSchedule(playlistId, hours) {
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) return; if (!playlist) return;
@ -457,6 +685,37 @@ async function unscheduleAutoSyncPlaylist(playlistId) {
} }
} }
async function runAutoSyncScheduledPlaylist(playlistId) {
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (!playlist) return;
await runMirroredPlaylistPipeline(playlistId, playlist.name || `Playlist #${playlistId}`);
await refreshAutoSyncScheduleModal();
}
function manageAutoSyncStatusPolling() {
const overlay = document.getElementById('auto-sync-schedule-modal');
if (!overlay) {
stopAutoSyncStatusPolling();
return;
}
const hasRunning = _autoSyncScheduleState.playlists.some(p => p.pipeline_state?.status === 'running');
if (!hasRunning) {
stopAutoSyncStatusPolling();
return;
}
if (_autoSyncStatusPoller) return;
_autoSyncStatusPoller = setInterval(() => {
if (_autoSyncIsDragging) return;
refreshAutoSyncScheduleModal();
}, 3000);
}
function stopAutoSyncStatusPolling() {
if (!_autoSyncStatusPoller) return;
clearInterval(_autoSyncStatusPoller);
_autoSyncStatusPoller = null;
}
async function parseMirroredPipelineResponse(res, fallbackMessage) { async function parseMirroredPipelineResponse(res, fallbackMessage) {
const text = await res.text(); const text = await res.text();
let data = {}; let data = {};
@ -543,6 +802,13 @@ async function runMirroredPlaylistPipeline(playlistId, name) {
const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync'); const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync');
applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' }); applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' });
showToast(`Auto-Sync started for ${name}`, 'success'); showToast(`Auto-Sync started for ${name}`, 'success');
_autoSyncScheduleState.playlists = _autoSyncScheduleState.playlists.map(p => (
parseInt(p.id, 10) === parseInt(playlistId, 10)
? { ...p, pipeline_state: data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' } }
: p
));
renderAutoSyncScheduleModal();
manageAutoSyncStatusPolling();
pollMirroredPipelineStatus(playlistId, name); pollMirroredPipelineStatus(playlistId, name);
} catch (err) { } catch (err) {
showToast(`Error: ${err.message}`, 'error'); showToast(`Error: ${err.message}`, 'error');
@ -564,11 +830,13 @@ function pollMirroredPipelineStatus(playlistId, name) {
delete mirroredPipelinePollers[key]; delete mirroredPipelinePollers[key];
showToast(`Auto-Sync complete for ${name}`, 'success'); showToast(`Auto-Sync complete for ${name}`, 'success');
loadMirroredPlaylists(); loadMirroredPlaylists();
refreshAutoSyncScheduleModal();
} else if (state.status === 'error' || state.status === 'skipped') { } else if (state.status === 'error' || state.status === 'skipped') {
clearInterval(mirroredPipelinePollers[key]); clearInterval(mirroredPipelinePollers[key]);
delete mirroredPipelinePollers[key]; delete mirroredPipelinePollers[key];
showToast(state.error || `Pipeline stopped for ${name}`, 'error'); showToast(state.error || `Pipeline stopped for ${name}`, 'error');
loadMirroredPlaylists(); loadMirroredPlaylists();
refreshAutoSyncScheduleModal();
} else if (state.status === 'idle') { } else if (state.status === 'idle') {
clearInterval(mirroredPipelinePollers[key]); clearInterval(mirroredPipelinePollers[key]);
delete mirroredPipelinePollers[key]; delete mirroredPipelinePollers[key];

View file

@ -11203,7 +11203,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-modal { .auto-sync-modal {
width: min(1500px, calc(100vw - 40px)); width: min(1500px, calc(100vw - 40px));
height: min(860px, calc(100vh - 40px)); height: min(920px, calc(100vh - 32px));
background: background:
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%), radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%),
rgba(17, 19, 27, 0.98); rgba(17, 19, 27, 0.98);
@ -11296,6 +11296,214 @@ body.helper-mode-active #dashboard-activity-feed:hover {
text-transform: uppercase; text-transform: uppercase;
} }
.auto-sync-monitor {
flex-shrink: 0;
padding: 14px 18px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
background:
radial-gradient(circle at 14% 0%, rgba(var(--accent-rgb), 0.14), transparent 34%),
rgba(255, 255, 255, 0.018);
}
.auto-sync-monitor-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 10px;
}
.auto-sync-monitor-head strong,
.auto-sync-monitor-head small,
.auto-sync-monitor-kicker {
display: block;
}
.auto-sync-monitor-kicker {
margin-bottom: 3px;
color: rgb(var(--accent-light-rgb));
font-size: 10px;
font-weight: 800;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.auto-sync-monitor-head strong {
color: rgba(255, 255, 255, 0.9);
font-size: 14px;
}
.auto-sync-monitor-head small {
margin-top: 2px;
color: rgba(255, 255, 255, 0.44);
font-size: 12px;
}
.auto-sync-monitor-head button,
.auto-sync-board-intro button,
.auto-sync-history-intro button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.auto-sync-monitor-head button:hover,
.auto-sync-board-intro button:hover,
.auto-sync-history-intro button:hover {
background: rgba(var(--accent-rgb), 0.14);
border-color: rgba(var(--accent-rgb), 0.32);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-monitor-list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.auto-sync-monitor-card,
.auto-sync-monitor-empty {
min-width: 0;
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
}
.auto-sync-monitor-card {
display: flex;
align-items: stretch;
justify-content: space-between;
gap: 10px;
padding: 11px;
}
.auto-sync-monitor-card.running {
border-color: rgba(var(--accent-rgb), 0.34);
background: rgba(var(--accent-rgb), 0.08);
box-shadow: 0 0 0 1px rgba(var(--accent-rgb), 0.08), 0 8px 24px rgba(var(--accent-rgb), 0.08);
}
.auto-sync-monitor-card.error {
border-color: rgba(239, 68, 68, 0.28);
background: rgba(239, 68, 68, 0.07);
}
.auto-sync-monitor-card.finished {
border-color: rgba(34, 197, 94, 0.22);
background: rgba(34, 197, 94, 0.055);
}
.auto-sync-monitor-card-main {
min-width: 0;
flex: 1;
}
.auto-sync-monitor-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
.auto-sync-monitor-title-row strong {
min-width: 0;
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-monitor-title-row span {
flex-shrink: 0;
color: rgb(var(--accent-light-rgb));
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.auto-sync-monitor-phase {
margin-top: 5px;
color: rgba(255, 255, 255, 0.52);
font-size: 11px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-monitor-progress {
height: 5px;
margin-top: 8px;
overflow: hidden;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
}
.auto-sync-monitor-progress div {
height: 100%;
border-radius: inherit;
background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb)));
transition: width 0.25s ease;
}
.auto-sync-monitor-card small {
display: block;
margin-top: 6px;
color: rgba(255, 255, 255, 0.38);
font-size: 10px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-monitor-card button {
align-self: center;
height: 28px;
padding: 0 9px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.055);
color: rgba(255, 255, 255, 0.64);
cursor: pointer;
font-size: 11px;
font-weight: 800;
}
.auto-sync-monitor-card button:hover {
border-color: rgba(var(--accent-rgb), 0.36);
background: rgba(var(--accent-rgb), 0.14);
color: rgb(var(--accent-light-rgb));
}
.auto-sync-monitor-empty {
grid-column: 1 / -1;
padding: 16px;
}
.auto-sync-monitor-empty span,
.auto-sync-monitor-empty small {
display: block;
}
.auto-sync-monitor-empty span {
color: rgba(255, 255, 255, 0.72);
font-size: 13px;
font-weight: 800;
}
.auto-sync-monitor-empty small {
margin-top: 3px;
color: rgba(255, 255, 255, 0.38);
font-size: 12px;
}
.auto-sync-tabs { .auto-sync-tabs {
display: flex; display: flex;
gap: 8px; gap: 8px;
@ -11337,7 +11545,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
} }
.auto-sync-board-intro, .auto-sync-board-intro,
.auto-sync-automation-intro { .auto-sync-automation-intro,
.auto-sync-history-intro {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@ -11349,37 +11558,22 @@ body.helper-mode-active #dashboard-activity-feed:hover {
} }
.auto-sync-board-intro strong, .auto-sync-board-intro strong,
.auto-sync-automation-intro strong { .auto-sync-automation-intro strong,
.auto-sync-history-intro strong {
display: block; display: block;
color: rgba(255, 255, 255, 0.86); color: rgba(255, 255, 255, 0.86);
font-size: 13px; font-size: 13px;
} }
.auto-sync-board-intro span, .auto-sync-board-intro span,
.auto-sync-automation-intro span { .auto-sync-automation-intro span,
.auto-sync-history-intro span {
display: block; display: block;
margin-top: 2px; margin-top: 2px;
color: rgba(255, 255, 255, 0.46); color: rgba(255, 255, 255, 0.46);
font-size: 12px; font-size: 12px;
} }
.auto-sync-board-intro button {
height: 30px;
padding: 0 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.auto-sync-board-intro button:hover {
background: rgba(255, 255, 255, 0.1);
color: #fff;
}
.auto-sync-body { .auto-sync-body {
min-height: 0; min-height: 0;
flex: 1; flex: 1;
@ -11561,7 +11755,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-column-list::-webkit-scrollbar, .auto-sync-column-list::-webkit-scrollbar,
.auto-sync-board::-webkit-scrollbar, .auto-sync-board::-webkit-scrollbar,
.auto-sync-source-list::-webkit-scrollbar, .auto-sync-source-list::-webkit-scrollbar,
.auto-sync-automation-list::-webkit-scrollbar { .auto-sync-automation-list::-webkit-scrollbar,
.auto-sync-history-list::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
@ -11569,7 +11764,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-column-list::-webkit-scrollbar-thumb, .auto-sync-column-list::-webkit-scrollbar-thumb,
.auto-sync-board::-webkit-scrollbar-thumb, .auto-sync-board::-webkit-scrollbar-thumb,
.auto-sync-source-list::-webkit-scrollbar-thumb, .auto-sync-source-list::-webkit-scrollbar-thumb,
.auto-sync-automation-list::-webkit-scrollbar-thumb { .auto-sync-automation-list::-webkit-scrollbar-thumb,
.auto-sync-history-list::-webkit-scrollbar-thumb {
background: rgba(var(--accent-rgb), 0.3); background: rgba(var(--accent-rgb), 0.3);
border-radius: 999px; border-radius: 999px;
} }
@ -11577,7 +11773,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
.auto-sync-column-list::-webkit-scrollbar-thumb:hover, .auto-sync-column-list::-webkit-scrollbar-thumb:hover,
.auto-sync-board::-webkit-scrollbar-thumb:hover, .auto-sync-board::-webkit-scrollbar-thumb:hover,
.auto-sync-source-list::-webkit-scrollbar-thumb:hover, .auto-sync-source-list::-webkit-scrollbar-thumb:hover,
.auto-sync-automation-list::-webkit-scrollbar-thumb:hover { .auto-sync-automation-list::-webkit-scrollbar-thumb:hover,
.auto-sync-history-list::-webkit-scrollbar-thumb:hover {
background: rgba(var(--accent-rgb), 0.5); background: rgba(var(--accent-rgb), 0.5);
} }
@ -11629,6 +11826,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
opacity: 0.52; opacity: 0.52;
} }
.auto-sync-scheduled-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}
.auto-sync-scheduled-card button { .auto-sync-scheduled-card button {
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -11640,7 +11845,29 @@ body.helper-mode-active #dashboard-activity-feed:hover {
flex-shrink: 0; flex-shrink: 0;
} }
.auto-sync-scheduled-card button:hover { .auto-sync-scheduled-card button.run {
width: auto;
min-width: 64px;
padding: 0 8px;
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.12);
border: 1px solid rgba(var(--accent-rgb), 0.22);
font-size: 10px;
font-weight: 800;
}
.auto-sync-scheduled-card button.run:disabled {
cursor: default;
opacity: 0.62;
}
.auto-sync-scheduled-card button.run:not(:disabled):hover {
color: rgb(var(--accent-neon-rgb));
background: rgba(var(--accent-rgb), 0.2);
border-color: rgba(var(--accent-rgb), 0.4);
}
.auto-sync-scheduled-card button:not(.run):hover {
color: #ef4444; color: #ef4444;
background: rgba(239, 68, 68, 0.12); background: rgba(239, 68, 68, 0.12);
} }
@ -11744,6 +11971,191 @@ body.helper-mode-active #dashboard-activity-feed:hover {
text-align: center; text-align: center;
} }
.auto-sync-history-list {
min-height: 0;
overflow-y: auto;
padding: 18px;
display: flex;
flex-direction: column;
gap: 10px;
}
.auto-sync-history-entry {
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 8px;
background: rgba(255, 255, 255, 0.035);
overflow: hidden;
}
.auto-sync-history-entry:hover {
border-color: rgba(var(--accent-rgb), 0.22);
}
.auto-sync-history-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 12px;
padding: 13px 14px;
cursor: pointer;
}
.auto-sync-history-status {
padding: 4px 8px;
border-radius: 999px;
background: rgba(148, 163, 184, 0.14);
color: #94a3b8;
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.auto-sync-history-status.completed,
.auto-sync-history-status.finished {
background: rgba(74, 222, 128, 0.14);
color: #4ade80;
}
.auto-sync-history-status.error {
background: rgba(239, 68, 68, 0.14);
color: #ef4444;
}
.auto-sync-history-status.skipped {
background: rgba(250, 204, 21, 0.14);
color: #facc15;
}
.auto-sync-history-main {
min-width: 0;
}
.auto-sync-history-main strong,
.auto-sync-history-main small {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.auto-sync-history-main strong {
color: rgba(255, 255, 255, 0.88);
font-size: 13px;
}
.auto-sync-history-main small {
margin-top: 3px;
color: rgba(255, 255, 255, 0.42);
font-size: 11px;
}
.auto-sync-history-meta {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.auto-sync-history-meta span,
.auto-sync-history-result span {
padding: 4px 7px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.055);
color: rgba(255, 255, 255, 0.48);
font-size: 10px;
font-weight: 700;
}
.auto-sync-history-detail {
display: none;
padding: 0 14px 14px;
}
.auto-sync-history-detail.expanded {
display: block;
}
.auto-sync-history-stats {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
padding-top: 2px;
}
.auto-sync-history-stats div {
padding: 10px;
border-radius: 7px;
background: rgba(255, 255, 255, 0.04);
}
.auto-sync-history-stats span,
.auto-sync-history-stats strong {
display: block;
}
.auto-sync-history-stats span {
color: rgba(255, 255, 255, 0.38);
font-size: 10px;
font-weight: 800;
text-transform: uppercase;
}
.auto-sync-history-stats strong {
margin-top: 4px;
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
}
.auto-sync-history-result {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-top: 9px;
}
.auto-sync-history-result span {
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.18);
}
.auto-sync-history-result span.error {
color: #ef4444;
background: rgba(239, 68, 68, 0.1);
border-color: rgba(239, 68, 68, 0.18);
}
.auto-sync-history-empty {
margin: 24px;
padding: 44px;
border: 1px dashed rgba(255, 255, 255, 0.12);
border-radius: 8px;
color: rgba(255, 255, 255, 0.42);
text-align: center;
}
.auto-sync-history-empty strong,
.auto-sync-history-empty span {
display: block;
}
.auto-sync-history-empty strong {
color: rgba(255, 255, 255, 0.72);
font-size: 14px;
}
.auto-sync-history-empty span {
margin-top: 6px;
font-size: 12px;
}
.auto-sync-history-total {
padding: 12px;
color: rgba(255, 255, 255, 0.38);
font-size: 12px;
text-align: center;
}
@media (max-width: 1100px) { @media (max-width: 1100px) {
.auto-sync-modal { .auto-sync-modal {
width: calc(100vw - 18px); width: calc(100vw - 18px);
@ -11754,6 +12166,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.auto-sync-monitor-list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.auto-sync-body { .auto-sync-body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
grid-template-rows: minmax(150px, 30%) 1fr; grid-template-rows: minmax(150px, 30%) 1fr;
@ -11795,16 +12211,58 @@ body.helper-mode-active #dashboard-activity-feed:hover {
padding: 9px 16px; padding: 9px 16px;
} }
.auto-sync-monitor {
padding: 10px 14px;
}
.auto-sync-monitor-empty,
.auto-sync-monitor-card {
padding: 9px;
}
.auto-sync-tabs { .auto-sync-tabs {
padding: 9px 14px; padding: 9px 14px;
} }
.auto-sync-board-intro, .auto-sync-board-intro,
.auto-sync-automation-intro { .auto-sync-automation-intro,
.auto-sync-history-intro {
padding: 9px 14px; padding: 9px 14px;
} }
} }
@media (max-width: 720px) {
.auto-sync-monitor-list {
grid-template-columns: 1fr;
}
.auto-sync-monitor-head {
align-items: flex-start;
flex-direction: column;
}
.auto-sync-monitor-card {
flex-direction: column;
}
.auto-sync-monitor-card button {
align-self: flex-start;
}
.auto-sync-history-row {
grid-template-columns: 1fr;
align-items: flex-start;
}
.auto-sync-history-meta {
justify-content: flex-start;
}
.auto-sync-history-stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
/* Enhanced Progress Bar Animation */ /* Enhanced Progress Bar Animation */
.progress-bar-fill { .progress-bar-fill {
height: 100%; height: 100%;