keep a history of automation runs
This commit is contained in:
parent
ba6aea5435
commit
b34e348937
5 changed files with 349 additions and 3 deletions
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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\'">×</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">▼</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; }
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in a new issue