keep a history of automation runs

This commit is contained in:
Broque Thomas 2026-03-07 09:39:08 -08:00
parent ba6aea5435
commit b34e348937
5 changed files with 349 additions and 3 deletions

View file

@ -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)

View file

@ -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()

View file

@ -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/<int:automation_id>/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()

View file

@ -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('<span class="auto-runs-link" onclick="event.stopPropagation(); showAutomationHistory(' + a.id + ', \'' + _escAttr(a.name) + '\')" title="View run history">Runs: ' + a.run_count + '</span>');
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 = '<div class="modal-container automation-history-modal"><div class="history-modal-header"><h3>Run History: ' + _esc(automationName) + '</h3><button class="history-close-btn" onclick="document.getElementById(\'automation-history-modal\').style.display=\'none\'">&times;</button></div><div class="history-modal-body"><div class="history-loading">Loading...</div></div></div>';
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 = '<div class="history-empty">No run history yet. History will be recorded on future runs.</div>';
return;
}
let html = '<div class="history-entries">';
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 += '<div class="history-entry">';
html += '<div class="history-entry-header" onclick="var el=document.getElementById(\'' + entryId + '\'); if(el) el.classList.toggle(\'expanded\')">';
html += '<span class="history-status-badge ' + statusClass + '">' + statusLabel + '</span>';
html += '<span class="history-time">' + timeAgo + '</span>';
if (duration) html += '<span class="history-duration">' + duration + '</span>';
if (hasLogs) html += '<span class="history-expand-icon">&#9660;</span>';
html += '</div>';
if (summary) html += '<div class="history-summary">' + summary + '</div>';
if (hasLogs) {
html += '<div id="' + entryId + '" class="history-log-section">';
entry.log_lines.forEach(function(log) {
html += '<div class="history-log-line history-log-' + (log.type || 'info') + '">' + _esc(log.text || '') + '</div>';
});
html += '</div>';
}
html += '</div>';
});
html += '</div>';
if (data.total > data.history.length) {
html += '<div class="history-total">Showing ' + data.history.length + ' of ' + data.total + ' runs</div>';
}
body.innerHTML = html;
} catch (err) {
const body = modal.querySelector('.history-modal-body');
if (body) body.innerHTML = '<div class="history-empty">Error loading history: ' + _esc(err.message) + '</div>';
}
}
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; }

View file

@ -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);
}