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
|
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()
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
|
|
@ -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 · ${_esc(assigned)}</div>
|
<div class="auto-sync-playlist-meta">${p.track_count || 0} tracks · ${_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))} · ${playlist.track_count || 0} tracks</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>` : ''}
|
${nextLabel ? `<small>${_esc(nextLabel)}</small>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</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];
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue