Compare commits
3 Commits
76b0a6d0d3
...
c619add705
| Author | SHA1 | Date | |
|---|---|---|---|
| c619add705 | |||
| e6a6b7e359 | |||
| d25ba27f24 |
@@ -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,22 +1029,32 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadExecutions() {
|
let executionOffset = 0;
|
||||||
try {
|
const executionLimit = 50;
|
||||||
const response = await fetch('/api/executions');
|
|
||||||
const executions = await response.json();
|
|
||||||
|
|
||||||
const dashHtml = executions.length === 0 ?
|
|
||||||
'<div class="empty">No executions yet</div>' :
|
|
||||||
executions.slice(0, 5).map(e => `
|
|
||||||
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
|
||||||
<span class="status ${e.status}">${e.status}</span>
|
|
||||||
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
|
||||||
<div class="timestamp">by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}</div>
|
|
||||||
</div>
|
|
||||||
`).join('');
|
|
||||||
document.getElementById('dashExecutions').innerHTML = dashHtml;
|
|
||||||
|
|
||||||
|
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 ?
|
||||||
|
'<div class="empty">No executions yet</div>' :
|
||||||
|
executions.slice(0, 5).map(e => `
|
||||||
|
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
||||||
|
<span class="status ${e.status}">${e.status}</span>
|
||||||
|
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
||||||
|
<div class="timestamp">by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
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('');
|
||||||
document.getElementById('executionList').innerHTML = fullHtml;
|
|
||||||
|
if (append) {
|
||||||
|
document.getElementById('executionList').innerHTML += fullHtml;
|
||||||
|
} else {
|
||||||
|
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;
|
||||||
|
|
||||||
@@ -1085,7 +1172,21 @@
|
|||||||
html += formatLogEntry(log);
|
html += formatLogEntry(log);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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')) {
|
||||||
|
|||||||
40
server.js
40
server.js
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user