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:
parent
5f62152acb
commit
efdcde1892
5 changed files with 999 additions and 37 deletions
|
|
@ -11,8 +11,10 @@ future playlist-card "Run Pipeline" button can call the same command.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
|
||||
|
|
@ -48,7 +50,12 @@ def run_mirrored_playlist_pipeline(
|
|||
else:
|
||||
deps.state.set_pipeline_running(True)
|
||||
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()
|
||||
history_playlists: List[Dict[str, Any]] = []
|
||||
before_snapshots: Dict[int, Dict[str, Any]] = {}
|
||||
|
||||
try:
|
||||
db = deps.get_database()
|
||||
|
|
@ -65,6 +72,12 @@ def run_mirrored_playlist_pipeline(
|
|||
if not playlists:
|
||||
deps.state.set_pipeline_running(False)
|
||||
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(
|
||||
automation_id,
|
||||
|
|
@ -117,8 +130,7 @@ def run_mirrored_playlist_pipeline(
|
|||
log_type='success',
|
||||
)
|
||||
|
||||
deps.state.set_pipeline_running(False)
|
||||
return {
|
||||
result = {
|
||||
'status': 'completed',
|
||||
'_manages_own_progress': True,
|
||||
'playlists_refreshed': str(refreshed),
|
||||
|
|
@ -128,9 +140,38 @@ def run_mirrored_playlist_pipeline(
|
|||
'wishlist_queued': str(sync_summary['wishlist_queued']),
|
||||
'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
|
||||
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(
|
||||
automation_id,
|
||||
status='error',
|
||||
|
|
@ -142,6 +183,79 @@ def run_mirrored_playlist_pipeline(
|
|||
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:
|
||||
if process_all:
|
||||
return db.get_mirrored_playlists()
|
||||
|
|
|
|||
|
|
@ -531,6 +531,30 @@ class MusicDatabase:
|
|||
""")
|
||||
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)
|
||||
self._add_mirrored_playlist_explored_column(cursor)
|
||||
|
||||
|
|
@ -12417,6 +12441,73 @@ class MusicDatabase:
|
|||
logger.error(f"Error clearing automation run history: {e}")
|
||||
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]:
|
||||
"""Find similar tracks for radio mode auto-play queue.
|
||||
|
||||
|
|
|
|||
|
|
@ -32479,6 +32479,37 @@ def get_mirrored_playlist_pipeline_status_endpoint(playlist_id):
|
|||
logger.error(f"Error getting mirrored playlist pipeline status: {e}")
|
||||
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'])
|
||||
def delete_mirrored_playlist_endpoint(playlist_id):
|
||||
"""Delete a mirrored playlist."""
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@
|
|||
|
||||
const mirroredPipelinePollers = {};
|
||||
const AUTO_SYNC_BUCKETS = [1, 2, 4, 8, 12, 16, 24, 48, 72, 168];
|
||||
let _autoSyncStatusPoller = null;
|
||||
let _autoSyncIsDragging = false;
|
||||
let _autoSyncScheduleState = {
|
||||
playlists: [],
|
||||
automations: [],
|
||||
playlistSchedules: {},
|
||||
automationPipelines: [],
|
||||
runHistory: [],
|
||||
runHistoryTotal: 0,
|
||||
};
|
||||
let _autoSyncActiveTab = 'schedule';
|
||||
|
||||
|
|
@ -103,7 +107,7 @@ function autoSyncIsScheduleOwned(auto) {
|
|||
return group === 'Playlist Auto-Sync' || name.startsWith('Auto-Sync:');
|
||||
}
|
||||
|
||||
function buildAutoSyncScheduleState(playlists, automations) {
|
||||
function buildAutoSyncScheduleState(playlists, automations, historyData = {}) {
|
||||
const playlistSchedules = {};
|
||||
const automationPipelines = [];
|
||||
const pipelineAutomations = automations.filter(autoSyncIsPipelineAutomation);
|
||||
|
|
@ -124,7 +128,14 @@ function buildAutoSyncScheduleState(playlists, automations) {
|
|||
automationPipelines.push(auto);
|
||||
}
|
||||
});
|
||||
return { playlists, automations, playlistSchedules, automationPipelines };
|
||||
return {
|
||||
playlists,
|
||||
automations,
|
||||
playlistSchedules,
|
||||
automationPipelines,
|
||||
runHistory: historyData.history || [],
|
||||
runHistoryTotal: historyData.total || 0,
|
||||
};
|
||||
}
|
||||
|
||||
async function openAutoSyncScheduleModal() {
|
||||
|
|
@ -154,6 +165,7 @@ async function openAutoSyncScheduleModal() {
|
|||
|
||||
function closeAutoSyncScheduleModal() {
|
||||
const overlay = document.getElementById('auto-sync-schedule-modal');
|
||||
stopAutoSyncStatusPolling();
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
|
||||
|
|
@ -161,16 +173,20 @@ async function refreshAutoSyncScheduleModal() {
|
|||
const overlay = document.getElementById('auto-sync-schedule-modal');
|
||||
if (!overlay) return;
|
||||
try {
|
||||
const [playlistRes, automationRes] = await Promise.all([
|
||||
const [playlistRes, automationRes, historyRes] = await Promise.all([
|
||||
fetch('/api/mirrored-playlists'),
|
||||
fetch('/api/automations'),
|
||||
fetch('/api/playlist-pipeline/history?limit=50'),
|
||||
]);
|
||||
const playlists = await playlistRes.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 (!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();
|
||||
manageAutoSyncStatusPolling();
|
||||
} catch (err) {
|
||||
overlay.innerHTML = `
|
||||
<div class="auto-sync-modal">
|
||||
|
|
@ -188,16 +204,19 @@ function renderAutoSyncScheduleModal() {
|
|||
const overlay = document.getElementById('auto-sync-schedule-modal');
|
||||
if (!overlay) return;
|
||||
|
||||
const { playlists, playlistSchedules, automationPipelines } = _autoSyncScheduleState;
|
||||
const { playlists, playlistSchedules, automationPipelines, runHistory, runHistoryTotal } = _autoSyncScheduleState;
|
||||
const scheduledCount = Object.keys(playlistSchedules).length;
|
||||
const enabledCount = Object.values(playlistSchedules).filter(s => s.enabled).length;
|
||||
const pipelineCount = automationPipelines.length;
|
||||
const totalTracks = playlists.reduce((sum, p) => sum + (parseInt(p.track_count, 10) || 0), 0);
|
||||
const scheduleActive = _autoSyncActiveTab === 'schedule';
|
||||
const automationsActive = _autoSyncActiveTab === 'automations';
|
||||
const historyActive = _autoSyncActiveTab === 'history';
|
||||
|
||||
const schedulePanel = renderAutoSyncSchedulePanel(playlists, playlistSchedules);
|
||||
const automationPanel = renderAutoSyncAutomationPanel(automationPipelines, playlists);
|
||||
const historyPanel = renderAutoSyncHistoryPanel(runHistory, runHistoryTotal);
|
||||
const monitor = renderAutoSyncPipelineMonitor(playlists);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="auto-sync-modal">
|
||||
|
|
@ -215,18 +234,21 @@ function renderAutoSyncScheduleModal() {
|
|||
<div><span>${pipelineCount}</span><small>automation pipelines</small></div>
|
||||
<div><span>${totalTracks}</span><small>mirrored tracks</small></div>
|
||||
</div>
|
||||
${monitor}
|
||||
<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>
|
||||
</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 ${historyActive ? 'active' : ''}" id="auto-sync-history-panel">${historyPanel}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function setAutoSyncTab(tab) {
|
||||
_autoSyncActiveTab = tab === 'automations' ? 'automations' : 'schedule';
|
||||
_autoSyncActiveTab = ['automations', 'history'].includes(tab) ? tab : 'schedule';
|
||||
renderAutoSyncScheduleModal();
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +270,7 @@ function renderAutoSyncSchedulePanel(playlists, playlistSchedules) {
|
|||
const schedule = playlistSchedules[p.id];
|
||||
const assigned = schedule ? autoSyncIntervalLabel(schedule.hours) : 'Unscheduled';
|
||||
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-meta">${p.track_count || 0} tracks · ${_esc(assigned)}</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) {
|
||||
if (!automationPipelines.length) {
|
||||
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) {
|
||||
const cfg = auto.action_config || {};
|
||||
const playlistId = autoSyncPlaylistIdFromAutomation(auto);
|
||||
|
|
@ -348,8 +566,9 @@ function autoSyncAutomationCardHtml(auto, playlists) {
|
|||
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';
|
||||
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 class="auto-sync-scheduled-name">${_esc(playlist.name)}</div>
|
||||
<div class="auto-sync-scheduled-meta">${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks</div>
|
||||
|
|
@ -358,7 +577,10 @@ function autoSyncScheduledCardHtml(playlist, schedule) {
|
|||
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="event.stopPropagation(); unscheduleAutoSyncPlaylist(${playlist.id})" title="Remove this Auto-Sync schedule">×</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">×</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
|
@ -379,6 +601,7 @@ function autoSyncNextRunLabel(nextRun) {
|
|||
function autoSyncDragStart(event) {
|
||||
const playlistId = event.currentTarget?.dataset?.playlistId;
|
||||
if (!playlistId) return;
|
||||
_autoSyncIsDragging = true;
|
||||
event.dataTransfer.setData('text/plain', playlistId);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
|
@ -401,6 +624,7 @@ function autoSyncDragLeave(event) {
|
|||
|
||||
async function autoSyncDrop(event, hours) {
|
||||
event.preventDefault();
|
||||
_autoSyncIsDragging = false;
|
||||
const col = event.currentTarget;
|
||||
if (col) col.classList.remove('drag-over');
|
||||
const playlistId = parseInt(event.dataTransfer.getData('text/plain'), 10);
|
||||
|
|
@ -408,6 +632,10 @@ async function autoSyncDrop(event, hours) {
|
|||
await saveAutoSyncPlaylistSchedule(playlistId, hours);
|
||||
}
|
||||
|
||||
function autoSyncDragEnd() {
|
||||
_autoSyncIsDragging = false;
|
||||
}
|
||||
|
||||
async function saveAutoSyncPlaylistSchedule(playlistId, hours) {
|
||||
const playlist = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
|
||||
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) {
|
||||
const text = await res.text();
|
||||
let data = {};
|
||||
|
|
@ -543,6 +802,13 @@ async function runMirroredPlaylistPipeline(playlistId, name) {
|
|||
const data = await parseMirroredPipelineResponse(res, 'Failed to start Auto-Sync');
|
||||
applyMirroredPipelineState(playlistId, data.state || { status: 'running', progress: 0, phase: 'Starting pipeline...' });
|
||||
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);
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
|
|
@ -564,11 +830,13 @@ function pollMirroredPipelineStatus(playlistId, name) {
|
|||
delete mirroredPipelinePollers[key];
|
||||
showToast(`Auto-Sync complete for ${name}`, 'success');
|
||||
loadMirroredPlaylists();
|
||||
refreshAutoSyncScheduleModal();
|
||||
} else if (state.status === 'error' || state.status === 'skipped') {
|
||||
clearInterval(mirroredPipelinePollers[key]);
|
||||
delete mirroredPipelinePollers[key];
|
||||
showToast(state.error || `Pipeline stopped for ${name}`, 'error');
|
||||
loadMirroredPlaylists();
|
||||
refreshAutoSyncScheduleModal();
|
||||
} else if (state.status === 'idle') {
|
||||
clearInterval(mirroredPipelinePollers[key]);
|
||||
delete mirroredPipelinePollers[key];
|
||||
|
|
|
|||
|
|
@ -11203,7 +11203,7 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
|
||||
.auto-sync-modal {
|
||||
width: min(1500px, calc(100vw - 40px));
|
||||
height: min(860px, calc(100vh - 40px));
|
||||
height: min(920px, calc(100vh - 32px));
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.08) 0%, transparent 60%),
|
||||
rgba(17, 19, 27, 0.98);
|
||||
|
|
@ -11296,6 +11296,214 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
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 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
|
@ -11337,7 +11545,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
|
||||
.auto-sync-board-intro,
|
||||
.auto-sync-automation-intro {
|
||||
.auto-sync-automation-intro,
|
||||
.auto-sync-history-intro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
|
@ -11349,37 +11558,22 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
|
||||
.auto-sync-board-intro strong,
|
||||
.auto-sync-automation-intro strong {
|
||||
.auto-sync-automation-intro strong,
|
||||
.auto-sync-history-intro strong {
|
||||
display: block;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.auto-sync-board-intro span,
|
||||
.auto-sync-automation-intro span {
|
||||
.auto-sync-automation-intro span,
|
||||
.auto-sync-history-intro span {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
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 {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
|
|
@ -11561,7 +11755,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.auto-sync-column-list::-webkit-scrollbar,
|
||||
.auto-sync-board::-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;
|
||||
height: 6px;
|
||||
}
|
||||
|
|
@ -11569,7 +11764,8 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
.auto-sync-column-list::-webkit-scrollbar-thumb,
|
||||
.auto-sync-board::-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);
|
||||
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-board::-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);
|
||||
}
|
||||
|
||||
|
|
@ -11629,6 +11826,14 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
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 {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
|
@ -11640,7 +11845,29 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
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;
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
}
|
||||
|
|
@ -11744,6 +11971,191 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
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) {
|
||||
.auto-sync-modal {
|
||||
width: calc(100vw - 18px);
|
||||
|
|
@ -11754,6 +12166,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auto-sync-monitor-list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.auto-sync-body {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: minmax(150px, 30%) 1fr;
|
||||
|
|
@ -11795,16 +12211,58 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
padding: 9px 16px;
|
||||
}
|
||||
|
||||
.auto-sync-monitor {
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
||||
.auto-sync-monitor-empty,
|
||||
.auto-sync-monitor-card {
|
||||
padding: 9px;
|
||||
}
|
||||
|
||||
.auto-sync-tabs {
|
||||
padding: 9px 14px;
|
||||
}
|
||||
|
||||
.auto-sync-board-intro,
|
||||
.auto-sync-automation-intro {
|
||||
.auto-sync-automation-intro,
|
||||
.auto-sync-history-intro {
|
||||
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 */
|
||||
.progress-bar-fill {
|
||||
height: 100%;
|
||||
|
|
|
|||
Loading…
Reference in a new issue