diff --git a/core/automation_engine.py b/core/automation_engine.py index c4019daa..e2a01f80 100644 --- a/core/automation_engine.py +++ b/core/automation_engine.py @@ -68,6 +68,7 @@ class AutomationEngine: self._progress_init_fn = None self._progress_finish_fn = None self._progress_update_fn = None + self._history_record_fn = None # Event trigger cache: trigger_type → [automation_id, ...] self._event_automations = {} @@ -98,11 +99,12 @@ class AutomationEngine: } logger.debug(f"Registered action handler: {action_type}") - def register_progress_callbacks(self, init_fn, finish_fn, update_fn=None): + def register_progress_callbacks(self, init_fn, finish_fn, update_fn=None, history_fn=None): """Register callbacks for live progress tracking from web_server.py.""" self._progress_init_fn = init_fn self._progress_finish_fn = finish_fn self._progress_update_fn = update_fn + self._history_record_fn = history_fn @staticmethod def _sanitize_signal_name(name): @@ -421,6 +423,12 @@ class AutomationEngine: error = result.get('error') if result.get('status') == 'error' else None self.db.update_automation_run(automation_id, error=error, last_result=last_result) + if self._history_record_fn: + try: + self._history_record_fn(automation_id, result) + except Exception: + pass + # --- Schedule Execution (timer-based) --- def run_automation(self, automation_id, skip_delay=False): @@ -551,6 +559,12 @@ class AutomationEngine: last_result = json.dumps(result) if result else None self.db.update_automation_run(automation_id, next_run=next_run_str, error=error, last_result=last_result) + if self._history_record_fn: + try: + self._history_record_fn(automation_id, result) + except Exception: + pass + if self._running: self.schedule_automation(automation_id) diff --git a/database/music_database.py b/database/music_database.py index 21862129..95d896cf 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -397,6 +397,23 @@ class MusicDatabase: cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_profile ON automations (profile_id)") cursor.execute("CREATE INDEX IF NOT EXISTS idx_automations_enabled ON automations (enabled)") + # Automation run history table + cursor.execute(""" + CREATE TABLE IF NOT EXISTS automation_run_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + automation_id INTEGER NOT NULL, + started_at TIMESTAMP, + finished_at TIMESTAMP, + duration_seconds REAL, + status TEXT NOT NULL, + summary TEXT, + result_json TEXT, + log_lines TEXT, + FOREIGN KEY (automation_id) REFERENCES automations(id) ON DELETE CASCADE + ) + """) + cursor.execute("CREATE INDEX IF NOT EXISTS idx_arh_automation_id ON automation_run_history(automation_id)") + # Add notification columns to automations (migration) self._add_automation_notify_columns(cursor) self._add_automation_system_column(cursor) @@ -6970,6 +6987,74 @@ class MusicDatabase: logger.error(f"Error updating automation run {automation_id}: {e}") return False + def insert_automation_run_history(self, automation_id, started_at, finished_at, + duration_seconds, status, summary=None, + result_json=None, log_lines=None): + """Insert a run history entry and enforce 100-row retention cap per automation.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute(""" + INSERT INTO automation_run_history + (automation_id, started_at, finished_at, duration_seconds, status, summary, result_json, log_lines) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, (automation_id, started_at, finished_at, duration_seconds, + status, summary, result_json, log_lines)) + # Retention: keep only the newest 100 rows per automation + cursor.execute(""" + DELETE FROM automation_run_history + WHERE automation_id = ? AND id NOT IN ( + SELECT id FROM automation_run_history + WHERE automation_id = ? + ORDER BY id DESC LIMIT 100 + ) + """, (automation_id, automation_id)) + conn.commit() + return True + except Exception as e: + logger.error(f"Error inserting automation run history for {automation_id}: {e}") + return False + + def get_automation_run_history(self, automation_id, limit=50, offset=0): + """Get run history for an automation, newest first.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + "SELECT COUNT(*) FROM automation_run_history WHERE automation_id = ?", + (automation_id,)) + total = cursor.fetchone()[0] + cursor.execute(""" + SELECT id, automation_id, started_at, finished_at, duration_seconds, + status, summary, result_json, log_lines + FROM automation_run_history + WHERE automation_id = ? + ORDER BY id DESC + LIMIT ? OFFSET ? + """, (automation_id, limit, offset)) + cols = [d[0] for d in cursor.description] + rows = [dict(zip(cols, row)) for row in cursor.fetchall()] + return {'history': rows, 'total': total} + except Exception as e: + logger.error(f"Error getting automation run history for {automation_id}: {e}") + return {'history': [], 'total': 0} + + def clear_automation_run_history(self, automation_id=None): + """Clear run history for a specific automation or all automations.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + if automation_id: + cursor.execute("DELETE FROM automation_run_history WHERE automation_id = ?", + (automation_id,)) + else: + cursor.execute("DELETE FROM automation_run_history") + conn.commit() + return cursor.rowcount + except Exception as e: + logger.error(f"Error clearing automation run history: {e}") + return 0 + # Thread-safe singleton pattern for database access _database_instances: Dict[int, MusicDatabase] = {} # Thread ID -> Database instance _database_lock = threading.Lock() diff --git a/web_server.py b/web_server.py index 8bbe46d3..cf96241d 100644 --- a/web_server.py +++ b/web_server.py @@ -860,7 +860,70 @@ def _register_automation_handlers(): phase='Error' if status == 'error' else 'Complete', log_line=msg, log_type='error' if status == 'error' else 'success') - automation_engine.register_progress_callbacks(_progress_init, _progress_finish, _update_automation_progress) + def _record_automation_history(aid, result): + """Capture progress state into run history before cleanup clears it.""" + try: + with automation_progress_lock: + state = automation_progress_states.get(aid) + if state: + started_at = state.get('started_at') + finished_at = state.get('finished_at') or datetime.now().isoformat() + log_entries = list(state.get('log', [])) + else: + started_at = datetime.now().isoformat() + finished_at = datetime.now().isoformat() + log_entries = [] + + # Compute duration + duration = None + if started_at and finished_at: + try: + t0 = datetime.fromisoformat(started_at) + t1 = datetime.fromisoformat(finished_at) + duration = (t1 - t0).total_seconds() + except Exception: + pass + + # Determine status + r_status = result.get('status', 'completed') if result else 'completed' + if r_status == 'error': + status = 'error' + elif r_status == 'skipped': + status = 'skipped' + elif r_status == 'timeout': + status = 'timeout' + else: + status = 'completed' + + # Extract summary from the last success/error log line + summary = None + for entry in reversed(log_entries): + if entry.get('type') in ('success', 'error'): + summary = entry.get('text', '') + break + if not summary and log_entries: + summary = log_entries[-1].get('text', '') + # Fallback: use reason or error from result when no log entries captured + if not summary and result: + summary = result.get('reason') or result.get('error') or result.get('status', '') + + result_json = json.dumps({k: v for k, v in result.items() if not k.startswith('_')}) if result else None + log_json = json.dumps(log_entries) if log_entries else None + + get_database().insert_automation_run_history( + automation_id=aid, + started_at=started_at, + finished_at=finished_at, + duration_seconds=duration, + status=status, + summary=summary, + result_json=result_json, + log_lines=log_json + ) + except Exception as e: + logger.error(f"Error recording automation history for {aid}: {e}") + + automation_engine.register_progress_callbacks(_progress_init, _progress_finish, _update_automation_progress, _record_automation_history) # Register permanent callback: when any scan completes, emit library_scan_completed event # This replaces the hardcoded scan_completion_callback → trigger_automatic_database_update chain @@ -4044,6 +4107,34 @@ def get_automation_progress(): except Exception as e: return jsonify({"error": str(e)}), 500 +@app.route('/api/automations//history', methods=['GET']) +def get_automation_history(automation_id): + """Get run history for a specific automation.""" + try: + limit = request.args.get('limit', 50, type=int) + offset = request.args.get('offset', 0, type=int) + db = get_database() + data = db.get_automation_run_history(automation_id, limit=limit, offset=offset) + # Parse log_lines JSON strings for the frontend + for entry in data.get('history', []): + if entry.get('log_lines'): + try: + entry['log_lines'] = json.loads(entry['log_lines']) + except (json.JSONDecodeError, TypeError): + entry['log_lines'] = [] + else: + entry['log_lines'] = [] + if entry.get('result_json'): + try: + entry['result_json'] = json.loads(entry['result_json']) + except (json.JSONDecodeError, TypeError): + pass + data['automation_id'] = automation_id + return jsonify(data) + except Exception as e: + logger.error(f"Error getting automation history: {e}") + return jsonify({"error": str(e)}), 500 + def _collect_known_signals(): """Collect all signal names used across automations (for autocomplete).""" signals = set() diff --git a/webui/static/script.js b/webui/static/script.js index c7e82a13..9e964ab4 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -43394,7 +43394,7 @@ function renderAutomationCard(a) { const _timerTriggers = ['schedule', 'daily_time', 'weekly_time']; if (a.next_run && a.enabled && _timerTriggers.includes(a.trigger_type)) metaParts.push('Next: ' + _autoTimeUntil(a.next_run)); if (!_timerTriggers.includes(a.trigger_type) && a.enabled) metaParts.push('Listening'); - if (a.run_count) metaParts.push('Runs: ' + a.run_count); + if (a.run_count) metaParts.push('Runs: ' + a.run_count + ''); if (a.last_error) metaParts.push('Error: ' + _esc(a.last_error)); const deleteBtn = a.is_system ? '' : @@ -43621,6 +43621,76 @@ async function runAutomation(id) { } catch (err) { showToast('Error: ' + err.message, 'error'); } } +async function showAutomationHistory(automationId, automationName) { + let modal = document.getElementById('automation-history-modal'); + if (!modal) { + modal = document.createElement('div'); + modal.id = 'automation-history-modal'; + modal.className = 'modal-overlay'; + document.body.appendChild(modal); + } + modal.innerHTML = ''; + modal.style.display = 'flex'; + modal.onclick = function(e) { if (e.target === modal) modal.style.display = 'none'; }; + + try { + const res = await fetch('/api/automations/' + automationId + '/history?limit=50'); + const data = await res.json(); + if (data.error) throw new Error(data.error); + const body = modal.querySelector('.history-modal-body'); + if (!data.history || data.history.length === 0) { + body.innerHTML = '
No run history yet. History will be recorded on future runs.
'; + return; + } + let html = '
'; + data.history.forEach(function(entry) { + const statusClass = 'history-status-' + (entry.status || 'completed'); + const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1); + const timeAgo = _autoTimeAgo(entry.started_at); + const duration = entry.duration_seconds != null ? _formatDuration(entry.duration_seconds) : ''; + const summary = entry.summary ? _esc(entry.summary) : ''; + const hasLogs = entry.log_lines && entry.log_lines.length > 0; + const entryId = 'history-entry-' + entry.id; + + html += '
'; + html += '
'; + html += '' + statusLabel + ''; + html += '' + timeAgo + ''; + if (duration) html += '' + duration + ''; + if (hasLogs) html += ''; + html += '
'; + if (summary) html += '
' + summary + '
'; + if (hasLogs) { + html += '
'; + entry.log_lines.forEach(function(log) { + html += '
' + _esc(log.text || '') + '
'; + }); + html += '
'; + } + html += '
'; + }); + html += '
'; + if (data.total > data.history.length) { + html += '
Showing ' + data.history.length + ' of ' + data.total + ' runs
'; + } + body.innerHTML = html; + } catch (err) { + const body = modal.querySelector('.history-modal-body'); + if (body) body.innerHTML = '
Error loading history: ' + _esc(err.message) + '
'; + } +} + +function _formatDuration(seconds) { + if (seconds < 1) return '<1s'; + if (seconds < 60) return Math.round(seconds) + 's'; + var m = Math.floor(seconds / 60); + var s = Math.round(seconds % 60); + if (m < 60) return m + 'm ' + s + 's'; + var h = Math.floor(m / 60); + m = m % 60; + return h + 'h ' + m + 'm'; +} + async function saveAutomation() { const name = document.getElementById('builder-name').value.trim(); if (!name) { showToast('Name is required', 'error'); return; } diff --git a/webui/static/style.css b/webui/static/style.css index fa88076e..e257f3cf 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -29247,4 +29247,90 @@ body { /* Mirrored playlist select */ .placed-block-config input[type="checkbox"] { margin-right: 6px; accent-color: rgb(var(--accent-rgb)); +} + +/* --- Automation Run History Modal --- */ +.auto-runs-link { + cursor: pointer; text-decoration: underline; text-decoration-style: dotted; + text-underline-offset: 2px; color: rgba(255,255,255,0.5); + transition: color 0.2s ease; +} +.auto-runs-link:hover { color: rgb(var(--accent-light-rgb)); } + +.automation-history-modal { + max-width: 560px; width: 90%; max-height: 75vh; display: flex; flex-direction: column; +} +.history-modal-header { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.08); +} +.history-modal-header h3 { + margin: 0; font-size: 15px; font-weight: 600; color: #fff; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.history-close-btn { + background: none; border: none; color: rgba(255,255,255,0.4); font-size: 22px; + cursor: pointer; padding: 0 4px; line-height: 1; transition: color 0.2s; +} +.history-close-btn:hover { color: #fff; } + +.history-modal-body { + padding: 12px 20px 20px; overflow-y: auto; flex: 1; +} +.history-loading, .history-empty { + text-align: center; color: rgba(255,255,255,0.4); padding: 40px 0; font-size: 13px; +} +.history-entries { display: flex; flex-direction: column; gap: 8px; } + +.history-entry { + background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); + border-radius: 8px; overflow: hidden; transition: border-color 0.2s; +} +.history-entry:hover { border-color: rgba(255,255,255,0.12); } + +.history-entry-header { + display: flex; align-items: center; gap: 10px; padding: 10px 14px; + cursor: pointer; user-select: none; +} +.history-status-badge { + font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; + padding: 2px 8px; border-radius: 10px; flex-shrink: 0; +} +.history-status-completed { background: rgba(74,222,128,0.15); color: #4ade80; } +.history-status-error { background: rgba(239,68,68,0.15); color: #ef4444; } +.history-status-skipped { background: rgba(250,204,21,0.15); color: #facc15; } +.history-status-timeout { background: rgba(251,146,60,0.15); color: #fb923c; } + +.history-time { + font-size: 11px; color: rgba(255,255,255,0.4); flex: 1; +} +.history-duration { + font-size: 10px; color: rgba(255,255,255,0.3); + background: rgba(255,255,255,0.05); padding: 2px 6px; border-radius: 6px; +} +.history-expand-icon { + font-size: 8px; color: rgba(255,255,255,0.25); transition: transform 0.2s; +} +.history-summary { + padding: 0 14px 8px; font-size: 12px; color: rgba(255,255,255,0.55); line-height: 1.4; +} + +.history-log-section { + max-height: 0; overflow: hidden; transition: max-height 0.3s ease; + border-top: 1px solid rgba(255,255,255,0.04); +} +.history-log-section.expanded { + max-height: 400px; overflow-y: auto; +} +.history-log-line { + padding: 4px 14px; font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace; + line-height: 1.5; border-bottom: 1px solid rgba(255,255,255,0.02); +} +.history-log-info { color: rgba(255,255,255,0.45); } +.history-log-success { color: #4ade80; } +.history-log-error { color: #ef4444; } +.history-log-warning { color: #facc15; } + +.history-total { + text-align: center; padding: 12px 0 4px; font-size: 11px; color: rgba(255,255,255,0.3); } \ No newline at end of file