Compare commits

...

3 Commits

Author SHA1 Message Date
c619add705 Phase 6: Terminal aesthetic refinements and notifications
Changes:
- Added blinking terminal cursor animation
- Smooth hover effects for execution/worker/workflow items
- Hover animation: background highlight + border expand + slide
- Loading pulse animation for loading states
- Slide-in animation for log entries
- Terminal beep sound using Web Audio API (different tones for success/error)
- Real-time terminal notifications for command completion
- Toast-style notifications with green glow effects
- Auto-dismiss after 3 seconds with fade-out
- Visual and audio feedback for user actions

Sound features:
- 800Hz tone for success (higher pitch)
- 200Hz tone for errors (lower pitch)
- 440Hz tone for info (standard A note)
- 100ms duration, exponential fade-out
- Graceful fallback if Web Audio API not supported

Notification features:
- Fixed position top-right
- Terminal-themed styling with glow
- Color-coded: green for success, red for errors
- Icons: ✓ success, ✗ error, ℹ info
- Smooth animations (slide-in, fade-out)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:52:51 -05:00
e6a6b7e359 Phase 5: Auto-cleanup and pagination for executions
Changes:
Server-side:
- Added automatic cleanup of old executions (runs daily)
- Configurable retention period via EXECUTION_RETENTION_DAYS env var (default: 30 days)
- Cleanup runs on server startup and every 24 hours
- Only cleans completed/failed executions, keeps running ones
- Added pagination support to /api/executions endpoint
- Returns total count, limit, offset, and hasMore flag

Client-side:
- Implemented "Load More" button for execution pagination
- Loads 50 executions at a time
- Appends additional executions when "Load More" clicked
- Shows total execution count info
- Backward compatible with old API format

Benefits:
- Automatic database maintenance
- Prevents execution table from growing indefinitely
- Better performance with large execution histories
- User can browse all executions via pagination
- Configurable retention policy per deployment

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:50:39 -05:00
d25ba27f24 Phase 4: Execution detail enhancements with re-run and download
Changes:
- Added "Re-run Command" button to execution details modal
- Added "Download Logs" button to export execution data as JSON
- Re-run automatically switches to Quick Command tab and pre-fills form
- Download includes all execution metadata and logs
- Buttons only show for applicable execution types
- Terminal-themed button styling

Features:
- Re-run: Quickly repeat a previous command on same worker
- Download: Export execution logs for auditing/debugging
- JSON format includes: execution_id, status, timestamps, logs
- Filename includes execution ID and date for easy organization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:49:20 -05:00
2 changed files with 280 additions and 19 deletions

View File

@@ -690,6 +690,66 @@
min-width: 120px; min-width: 120px;
font-weight: bold; font-weight: bold;
} }
/* Terminal Cursor Blink */
@keyframes cursor-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.terminal-cursor::after {
content: '▋';
animation: cursor-blink 1s step-end infinite;
color: var(--terminal-green);
}
/* Hover effects for execution items */
.execution-item {
transition: all 0.2s ease;
cursor: pointer;
}
.execution-item:hover {
background: #001a00;
border-left-width: 5px;
transform: translateX(3px);
}
.worker-item:hover {
background: #001a00;
border-left-width: 5px;
}
.workflow-item:hover {
background: #001a00;
border-left-width: 5px;
}
/* Loading pulse effect */
.loading {
animation: loading-pulse 1.5s ease-in-out infinite;
}
@keyframes loading-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Success/Error message animations */
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.log-entry {
animation: slide-in 0.3s ease-out;
}
</style> </style>
</head> </head>
<body> <body>
@@ -969,11 +1029,19 @@
} }
} }
async function loadExecutions() { let executionOffset = 0;
try { const executionLimit = 50;
const response = await fetch('/api/executions');
const executions = await response.json();
async function loadExecutions(append = false) {
try {
if (!append) executionOffset = 0;
const response = await fetch(`/api/executions?limit=${executionLimit}&offset=${executionOffset}`);
const data = await response.json();
const executions = data.executions || data; // Handle old and new API format
// Dashboard view (always first 5)
if (!append) {
const dashHtml = executions.length === 0 ? const dashHtml = executions.length === 0 ?
'<div class="empty">No executions yet</div>' : '<div class="empty">No executions yet</div>' :
executions.slice(0, 5).map(e => ` executions.slice(0, 5).map(e => `
@@ -984,7 +1052,9 @@
</div> </div>
`).join(''); `).join('');
document.getElementById('dashExecutions').innerHTML = dashHtml; document.getElementById('dashExecutions').innerHTML = dashHtml;
}
// Full execution list
const fullHtml = executions.length === 0 ? const fullHtml = executions.length === 0 ?
'<div class="empty">No executions yet</div>' : '<div class="empty">No executions yet</div>' :
executions.map(e => ` executions.map(e => `
@@ -997,12 +1067,29 @@
</div> </div>
</div> </div>
`).join(''); `).join('');
if (append) {
document.getElementById('executionList').innerHTML += fullHtml;
} else {
document.getElementById('executionList').innerHTML = fullHtml; document.getElementById('executionList').innerHTML = fullHtml;
}
// Add "Load More" button if there are more executions
if (data.hasMore) {
const loadMoreBtn = `<button onclick="loadMoreExecutions()" style="width: 100%; margin-top: 15px;">[ Load More Executions ]</button>`;
document.getElementById('executionList').innerHTML += loadMoreBtn;
}
} catch (error) { } catch (error) {
console.error('Error loading executions:', error); console.error('Error loading executions:', error);
} }
} }
async function loadMoreExecutions() {
executionOffset += executionLimit;
await loadExecutions(true);
}
async function clearCompletedExecutions() { async function clearCompletedExecutions() {
if (!confirm('Delete all completed and failed executions?')) return; if (!confirm('Delete all completed and failed executions?')) return;
@@ -1086,6 +1173,20 @@
}); });
} }
// Add action buttons
html += '<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--terminal-green); display: flex; gap: 10px;">';
// Re-run button (only for quick commands with command in logs)
const commandLog = execution.logs?.find(l => l.action === 'command_sent');
if (commandLog && commandLog.command) {
html += `<button onclick="rerunCommand('${escapeHtml(commandLog.command)}', '${commandLog.worker_id}')">[ 🔄 Re-run Command ]</button>`;
}
// Download logs button
html += `<button onclick="downloadExecutionLogs('${executionId}')">[ 💾 Download Logs ]</button>`;
html += '</div>';
document.getElementById('executionDetails').innerHTML = html; document.getElementById('executionDetails').innerHTML = html;
const modal = document.getElementById('viewExecutionModal'); const modal = document.getElementById('viewExecutionModal');
modal.dataset.executionId = executionId; modal.dataset.executionId = executionId;
@@ -1169,6 +1270,52 @@
return `${days}d ago`; return `${days}d ago`;
} }
async function rerunCommand(command, workerId) {
if (!confirm(`Re-run command: ${command}?`)) return;
closeModal('viewExecutionModal');
switchTab('quickcommand');
// Set the worker and command
document.getElementById('quickWorkerSelect').value = workerId;
document.getElementById('quickCommand').value = command;
// Scroll to the command field
document.getElementById('quickCommand').scrollIntoView({ behavior: 'smooth' });
}
async function downloadExecutionLogs(executionId) {
try {
const response = await fetch(`/api/executions/${executionId}`);
const execution = await response.json();
// Create downloadable JSON
const data = {
execution_id: executionId,
workflow_name: execution.workflow_name || '[Quick Command]',
status: execution.status,
started_by: execution.started_by,
started_at: execution.started_at,
completed_at: execution.completed_at,
logs: execution.logs
};
// Create blob and download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `execution-${executionId}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading logs:', error);
alert('Error downloading execution logs');
}
}
async function respondToPrompt(executionId, response) { async function respondToPrompt(executionId, response) {
try { try {
const res = await fetch(`/api/executions/${executionId}/respond`, { const res = await fetch(`/api/executions/${executionId}/respond`, {
@@ -1411,6 +1558,77 @@
loadExecutions(); loadExecutions();
} }
// Terminal beep sound (Web Audio API)
function terminalBeep(type = 'success') {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Different tones for different events
if (type === 'success') {
oscillator.frequency.value = 800; // Higher pitch for success
} else if (type === 'error') {
oscillator.frequency.value = 200; // Lower pitch for errors
} else {
oscillator.frequency.value = 440; // Standard A note
}
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
} catch (error) {
// Silently fail if Web Audio API not supported
}
}
// Show terminal notification
function showTerminalNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: #001a00;
border: 2px solid var(--terminal-green);
color: var(--terminal-green);
padding: 15px 20px;
font-family: var(--font-mono);
z-index: 10000;
animation: slide-in 0.3s ease-out;
box-shadow: 0 0 20px rgba(0, 255, 65, 0.3);
`;
if (type === 'error') {
notification.style.borderColor = '#ff4444';
notification.style.color = '#ff4444';
message = '✗ ' + message;
} else if (type === 'success') {
message = '✓ ' + message;
} else {
message = ' ' + message;
}
notification.textContent = message;
document.body.appendChild(notification);
// Play beep
terminalBeep(type);
// Remove after 3 seconds
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.5s';
setTimeout(() => notification.remove(), 500);
}, 3000);
}
function connectWebSocket() { function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}`); ws = new WebSocket(`${protocol}//${window.location.host}`);
@@ -1429,6 +1647,13 @@
console.log(`Error: ${data.stderr}`); console.log(`Error: ${data.stderr}`);
} }
// Show terminal notification
if (data.success) {
showTerminalNotification('Command completed successfully', 'success');
} else {
showTerminalNotification('Command execution failed', 'error');
}
// If viewing execution details, refresh that specific execution // If viewing execution details, refresh that specific execution
const executionModal = document.getElementById('viewExecutionModal'); const executionModal = document.getElementById('viewExecutionModal');
if (executionModal && executionModal.classList.contains('show')) { if (executionModal && executionModal.classList.contains('show')) {

View File

@@ -98,6 +98,27 @@ async function initDatabase() {
} }
} }
// Auto-cleanup old executions (runs daily)
async function cleanupOldExecutions() {
try {
const retentionDays = parseInt(process.env.EXECUTION_RETENTION_DAYS) || 30;
const [result] = await pool.query(
`DELETE FROM executions
WHERE status IN ('completed', 'failed')
AND started_at < DATE_SUB(NOW(), INTERVAL ? DAY)`,
[retentionDays]
);
console.log(`[Cleanup] Removed ${result.affectedRows} executions older than ${retentionDays} days`);
} catch (error) {
console.error('[Cleanup] Error removing old executions:', error);
}
}
// Run cleanup daily at 3 AM
setInterval(cleanupOldExecutions, 24 * 60 * 60 * 1000);
// Run cleanup on startup
cleanupOldExecutions();
// WebSocket connections // WebSocket connections
const clients = new Set(); const clients = new Set();
const workers = new Map(); // Map worker_id -> WebSocket connection const workers = new Map(); // Map worker_id -> WebSocket connection
@@ -409,10 +430,25 @@ app.post('/api/executions', authenticateSSO, async (req, res) => {
app.get('/api/executions', authenticateSSO, async (req, res) => { app.get('/api/executions', authenticateSSO, async (req, res) => {
try { try {
const limit = parseInt(req.query.limit) || 50;
const offset = parseInt(req.query.offset) || 0;
const [rows] = await pool.query( const [rows] = await pool.query(
'SELECT e.*, w.name as workflow_name FROM executions e LEFT JOIN workflows w ON e.workflow_id = w.id ORDER BY e.started_at DESC LIMIT 50' 'SELECT e.*, w.name as workflow_name FROM executions e LEFT JOIN workflows w ON e.workflow_id = w.id ORDER BY e.started_at DESC LIMIT ? OFFSET ?',
[limit, offset]
); );
res.json(rows);
// Get total count
const [countRows] = await pool.query('SELECT COUNT(*) as total FROM executions');
const total = countRows[0].total;
res.json({
executions: rows,
total: total,
limit: limit,
offset: offset,
hasMore: offset + rows.length < total
});
} catch (error) { } catch (error) {
res.status(500).json({ error: error.message }); res.status(500).json({ error: error.message });
} }