Compare commits
2 Commits
658caa9f7e
...
95f6554cc2
| Author | SHA1 | Date | |
|---|---|---|---|
| 95f6554cc2 | |||
| ba75a61bd2 |
@@ -15,6 +15,9 @@
|
||||
--terminal-green: #00ff41;
|
||||
--terminal-amber: #ffb000;
|
||||
--terminal-cyan: #00ffff;
|
||||
--terminal-red: #ff4444;
|
||||
--bg-terminal: #001a00;
|
||||
--bg-terminal-border: #003300;
|
||||
--text-primary: #00ff41;
|
||||
--text-secondary: #00cc33;
|
||||
--text-muted: #008822;
|
||||
@@ -40,6 +43,7 @@
|
||||
--glow-green-intense: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, 0 0 32px rgba(0, 255, 65, 0.5);
|
||||
--glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
|
||||
--glow-amber-intense: 0 0 8px #ffb000, 0 0 16px #ffb000, 0 0 24px #ffb000;
|
||||
--glow-red: 0 0 5px #ff4444, 0 0 10px #ff4444;
|
||||
}
|
||||
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
@@ -72,6 +76,7 @@
|
||||
pointer-events: none;
|
||||
z-index: 9999;
|
||||
animation: scanline 8s linear infinite;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
@@ -361,7 +366,12 @@
|
||||
button::after { content: ' ]'; flex-shrink: 0; }
|
||||
/* Suppress bracket pseudo-elements for tab/nav buttons and inline-styled sub-tabs */
|
||||
button.tab::before, button.tab::after,
|
||||
button[style*="border:none"]::before, button[style*="border:none"]::after { content: none; }
|
||||
button[style*="border:none"]::before, button[style*="border:none"]::after,
|
||||
button[style*="border: none"]::before, button[style*="border: none"]::after,
|
||||
button[style*="flex:0"]::before, button[style*="flex:0"]::after,
|
||||
button[style*="flex: 0"]::before, button[style*="flex: 0"]::after,
|
||||
button[style*="flex:1"]::before, button[style*="flex:1"]::after,
|
||||
button[style*="flex: 1"]::before, button[style*="flex: 1"]::after { content: none; }
|
||||
button:hover {
|
||||
background: rgba(0, 255, 65, 0.15);
|
||||
color: var(--terminal-amber);
|
||||
@@ -466,7 +476,7 @@
|
||||
border-radius: 0;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 65, 0.3);
|
||||
position: relative;
|
||||
@@ -624,7 +634,7 @@
|
||||
}
|
||||
|
||||
.log-entry.failed {
|
||||
border-left-color: #ff4444;
|
||||
border-left-color: var(--terminal-red);
|
||||
}
|
||||
|
||||
.log-timestamp {
|
||||
@@ -642,8 +652,8 @@
|
||||
}
|
||||
|
||||
.log-entry.failed .log-title {
|
||||
color: #ff4444;
|
||||
text-shadow: 0 0 5px #ff4444;
|
||||
color: var(--terminal-red);
|
||||
text-shadow: var(--glow-red);
|
||||
}
|
||||
|
||||
.log-details {
|
||||
@@ -662,8 +672,8 @@
|
||||
}
|
||||
|
||||
.log-output {
|
||||
background: #0a0a0a;
|
||||
border: 1px solid #003300;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--bg-terminal-border);
|
||||
padding: 10px;
|
||||
margin: 6px 0;
|
||||
color: var(--terminal-green);
|
||||
@@ -675,14 +685,14 @@
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: #ff6666;
|
||||
color: var(--terminal-red);
|
||||
border-color: #330000;
|
||||
}
|
||||
|
||||
.log-entry code {
|
||||
background: #001a00;
|
||||
background: var(--bg-terminal);
|
||||
padding: 2px 6px;
|
||||
border: 1px solid #003300;
|
||||
border: 1px solid var(--bg-terminal-border);
|
||||
color: var(--terminal-green);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
@@ -699,15 +709,15 @@
|
||||
|
||||
.worker-stats span {
|
||||
padding: 2px 6px;
|
||||
background: #001a00;
|
||||
border: 1px solid #003300;
|
||||
background: var(--bg-terminal);
|
||||
border: 1px solid var(--bg-terminal-border);
|
||||
}
|
||||
|
||||
.worker-metadata {
|
||||
margin-top: 12px;
|
||||
padding: 10px;
|
||||
background: #001a00;
|
||||
border: 1px solid #003300;
|
||||
background: var(--bg-terminal);
|
||||
border: 1px solid var(--bg-terminal-border);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -743,18 +753,18 @@
|
||||
}
|
||||
|
||||
.execution-item:hover {
|
||||
background: #001a00;
|
||||
background: var(--bg-terminal);
|
||||
border-left-width: 5px;
|
||||
transform: translateX(3px);
|
||||
}
|
||||
|
||||
.worker-item:hover {
|
||||
background: #001a00;
|
||||
background: var(--bg-terminal);
|
||||
border-left-width: 5px;
|
||||
}
|
||||
|
||||
.workflow-item:hover {
|
||||
background: #001a00;
|
||||
background: var(--bg-terminal);
|
||||
border-left-width: 5px;
|
||||
}
|
||||
|
||||
@@ -768,6 +778,15 @@
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Running execution pulse */
|
||||
.execution-item.status-running {
|
||||
animation: exec-running-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
@keyframes exec-running-pulse {
|
||||
0%, 100% { border-color: var(--terminal-green); }
|
||||
50% { border-color: var(--status-running); box-shadow: 0 0 8px rgba(255,193,7,0.35); }
|
||||
}
|
||||
|
||||
/* Success/Error message animations */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
@@ -792,9 +811,12 @@
|
||||
<h1>⚡ PULSE</h1>
|
||||
<p>Pipelined Unified Logic & Server Engine</p>
|
||||
</div>
|
||||
<div style="text-align:right;">
|
||||
<div class="user-info" id="userInfo">
|
||||
<div class="loading">Loading user...</div>
|
||||
</div>
|
||||
<div id="lastRefreshed" style="font-size:0.72em;color:var(--text-muted);font-family:var(--font-mono);margin-top:4px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
@@ -1263,7 +1285,7 @@
|
||||
<div class="workflow-item">
|
||||
<div class="workflow-name">${w.name}${paramBadge(def)}</div>
|
||||
<div class="workflow-desc">${w.description || 'No description'}</div>
|
||||
<div class="timestamp">Created by ${w.created_by || 'Unknown'} on ${new Date(w.created_at).toLocaleString()}</div>
|
||||
<div class="timestamp">Created by ${w.created_by || 'Unknown'} on ${safeDate(w.created_at)?.toLocaleString() ?? 'N/A'}</div>
|
||||
<div style="margin-top: 10px;">
|
||||
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
|
||||
${currentUser && currentUser.isAdmin ?
|
||||
@@ -1304,8 +1326,8 @@
|
||||
scheduleDesc = `Cron: ${s.schedule_value}`;
|
||||
}
|
||||
|
||||
const nextRun = s.next_run ? new Date(s.next_run).toLocaleString() : 'Not scheduled';
|
||||
const lastRun = s.last_run ? new Date(s.last_run).toLocaleString() : 'Never';
|
||||
const nextRun = safeDate(s.next_run)?.toLocaleString() ?? 'Not scheduled';
|
||||
const lastRun = safeDate(s.last_run)?.toLocaleString() ?? 'Never';
|
||||
|
||||
return `
|
||||
<div class="workflow-item" style="opacity: ${s.enabled ? 1 : 0.6};">
|
||||
@@ -1497,7 +1519,7 @@
|
||||
<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 class="timestamp">by ${e.started_by} at ${safeDate(e.started_at)?.toLocaleString() ?? 'N/A'}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('dashExecutions').innerHTML = dashHtml;
|
||||
@@ -1615,14 +1637,15 @@
|
||||
const clickHandler = compareMode ? `toggleExecutionSelection('${e.id}')` : `viewExecution('${e.id}')`;
|
||||
const selectedStyle = isSelected ? 'background: rgba(255, 176, 0, 0.2); border-left-width: 5px; border-left-color: var(--terminal-amber);' : '';
|
||||
|
||||
const elapsed = e.status === 'running' ? ` • ${formatElapsed(e.started_at)}` : '';
|
||||
return `
|
||||
<div class="execution-item" onclick="${clickHandler}" style="${selectedStyle} cursor: pointer;">
|
||||
<div class="execution-item${e.status === 'running' ? ' status-running' : ''}" onclick="${clickHandler}" style="${selectedStyle} cursor: pointer;">
|
||||
${compareMode && isSelected ? '<span style="color: var(--terminal-amber); margin-right: 8px;">✓</span>' : ''}
|
||||
<span class="status ${e.status}">${e.status}</span>
|
||||
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
||||
<div class="timestamp">
|
||||
Started by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}
|
||||
${e.completed_at ? ` • Completed at ${new Date(e.completed_at).toLocaleString()}` : ''}
|
||||
Started by ${e.started_by} at ${safeDate(e.started_at)?.toLocaleString() ?? 'N/A'}
|
||||
${e.completed_at ? ` • Completed at ${safeDate(e.completed_at)?.toLocaleString() ?? 'N/A'}` : elapsed}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -1737,7 +1760,7 @@
|
||||
<tr style="border-bottom: 1px solid #003300;">
|
||||
<td style="padding: 8px; color: var(--terminal-green);">Execution ${idx + 1}</td>
|
||||
<td style="padding: 8px;"><span class="status ${exec.status}" style="font-size: 0.85em;">${exec.status}</span></td>
|
||||
<td style="padding: 8px; color: var(--terminal-green);">${new Date(exec.started_at).toLocaleString()}</td>
|
||||
<td style="padding: 8px; color: var(--terminal-green);">${safeDate(exec.started_at)?.toLocaleString() ?? 'N/A'}</td>
|
||||
<td style="padding: 8px; color: var(--terminal-green);">${duration}</td>
|
||||
</tr>
|
||||
`;
|
||||
@@ -1853,24 +1876,14 @@
|
||||
if (!confirm('Delete all completed and failed executions?')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/executions?limit=9999'); // Get all executions
|
||||
const data = await response.json();
|
||||
const executions = data.executions || data; // Handle new pagination format
|
||||
|
||||
const toDelete = executions.filter(e => e.status === 'completed' || e.status === 'failed');
|
||||
|
||||
if (toDelete.length === 0) {
|
||||
alert('No completed or failed executions to delete');
|
||||
const response = await fetch('/api/executions/completed', { method: 'DELETE' });
|
||||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({}));
|
||||
showTerminalNotification(err.error || 'Failed to delete executions', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
let deleted = 0;
|
||||
for (const execution of toDelete) {
|
||||
const deleteResponse = await fetch(`/api/executions/${execution.id}`, { method: 'DELETE' });
|
||||
if (deleteResponse.ok) deleted++;
|
||||
}
|
||||
|
||||
showTerminalNotification(`Deleted ${deleted} execution(s)`, 'success');
|
||||
const data = await response.json();
|
||||
showTerminalNotification(`Deleted ${data.deleted} execution(s)`, 'success');
|
||||
refreshData();
|
||||
} catch (error) {
|
||||
console.error('Error clearing executions:', error);
|
||||
@@ -1970,8 +1983,8 @@
|
||||
|
||||
let html = `
|
||||
<div><strong>Status:</strong> <span class="status ${execution.status}">${execution.status}</span></div>
|
||||
<div><strong>Started:</strong> ${new Date(execution.started_at).toLocaleString()}</div>
|
||||
${execution.completed_at ? `<div><strong>Completed:</strong> ${new Date(execution.completed_at).toLocaleString()}</div>` : ''}
|
||||
<div><strong>Started:</strong> ${safeDate(execution.started_at)?.toLocaleString() ?? 'N/A'}</div>
|
||||
${execution.completed_at ? `<div><strong>Completed:</strong> ${safeDate(execution.completed_at)?.toLocaleString() ?? 'N/A'}</div>` : ''}
|
||||
<div><strong>Started by:</strong> ${execution.started_by}</div>
|
||||
`;
|
||||
|
||||
@@ -2015,7 +2028,7 @@
|
||||
// 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>`;
|
||||
html += `<button onclick="rerunCommand('${escapeHtml(commandLog.command)}', '${escapeHtml(commandLog.worker_id || '')}')">🔄 Re-run Command</button>`;
|
||||
}
|
||||
|
||||
// Download logs button
|
||||
@@ -2264,8 +2277,9 @@
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
if (text == null) return '';
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
div.textContent = String(text);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
@@ -2288,7 +2302,9 @@
|
||||
}
|
||||
|
||||
function getTimeAgo(date) {
|
||||
if (!date || isNaN(date)) return 'N/A';
|
||||
const seconds = Math.floor((new Date() - date) / 1000);
|
||||
if (seconds < 0) return 'just now';
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
@@ -2298,6 +2314,22 @@
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
function safeDate(val) {
|
||||
if (!val) return null;
|
||||
const d = new Date(val);
|
||||
return isNaN(d) ? null : d;
|
||||
}
|
||||
|
||||
function formatElapsed(startedAt) {
|
||||
const start = safeDate(startedAt);
|
||||
if (!start) return '';
|
||||
const secs = Math.floor((Date.now() - start) / 1000);
|
||||
if (secs < 60) return `${secs}s`;
|
||||
const mins = Math.floor(secs / 60);
|
||||
if (mins < 60) return `${mins}m ${secs % 60}s`;
|
||||
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||
}
|
||||
|
||||
async function rerunCommand(command, workerId) {
|
||||
if (!confirm(`Re-run command: ${command}?`)) return;
|
||||
|
||||
@@ -2431,7 +2463,7 @@
|
||||
const html = history.map((item, index) => `
|
||||
<div class="history-item" onclick="useHistoryCommand(${index})" style="cursor: pointer; padding: 12px; margin: 8px 0; background: #001a00; border: 1px solid #003300; border-left: 3px solid var(--terminal-amber);">
|
||||
<div style="color: var(--terminal-green); font-family: var(--font-mono); margin-bottom: 4px;"><code>${escapeHtml(item.command)}</code></div>
|
||||
<div style="color: #666; font-size: 0.85em;">${new Date(item.timestamp).toLocaleString()} - ${item.worker}</div>
|
||||
<div style="color: #666; font-size: 0.85em;">${safeDate(item.timestamp)?.toLocaleString() ?? 'N/A'} - ${item.worker}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('historyList').innerHTML = html;
|
||||
@@ -2800,37 +2832,24 @@
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
// Find the button by its onclick attribute rather than relying on bare `event`
|
||||
const tabBtn = document.querySelector(`.tab[onclick*="'${tabName}'"]`);
|
||||
if (tabBtn) tabBtn.classList.add('active');
|
||||
const tabContent = document.getElementById(tabName);
|
||||
if (tabContent) tabContent.classList.add('active');
|
||||
|
||||
// Persist active tab across page loads
|
||||
try { localStorage.setItem('pulse_activeTab', tabName); } catch {}
|
||||
}
|
||||
|
||||
async function refreshData() {
|
||||
try {
|
||||
await loadWorkers();
|
||||
} catch (e) {
|
||||
console.error('Error loading workers:', e);
|
||||
}
|
||||
try { await loadWorkers(); } catch (e) { console.error('Error loading workers:', e); }
|
||||
try { await loadWorkflows(); } catch (e) { console.error('Error loading workflows:', e); }
|
||||
try { await loadExecutions(); } catch (e) { console.error('Error loading executions:', e); }
|
||||
try { await loadSchedules(); } catch (e) { console.error('Error loading schedules:', e); }
|
||||
|
||||
try {
|
||||
await loadWorkflows();
|
||||
} catch (e) {
|
||||
console.error('Error loading workflows:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await loadExecutions();
|
||||
} catch (e) {
|
||||
console.error('Error loading executions:', e);
|
||||
}
|
||||
|
||||
try {
|
||||
await loadSchedules();
|
||||
} catch (e) {
|
||||
console.error('Error loading schedules:', e);
|
||||
}
|
||||
// Update "last refreshed" indicator
|
||||
const el = document.getElementById('lastRefreshed');
|
||||
if (el) el.textContent = `Refreshed: ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// Terminal beep sound (Web Audio API)
|
||||
@@ -2992,8 +3011,12 @@
|
||||
loadExecutions();
|
||||
}
|
||||
|
||||
if (data.type === 'executions_bulk_deleted') {
|
||||
loadExecutions();
|
||||
}
|
||||
|
||||
// Generic refresh for other message types
|
||||
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated', 'execution_prompt'].includes(data.type)) {
|
||||
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated', 'execution_prompt', 'executions_bulk_deleted'].includes(data.type)) {
|
||||
refreshData();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -3026,12 +3049,29 @@
|
||||
// Initialize
|
||||
loadUser().then((success) => {
|
||||
if (success) {
|
||||
// Restore last-active tab
|
||||
try {
|
||||
const saved = localStorage.getItem('pulse_activeTab');
|
||||
if (saved) switchTab(saved);
|
||||
} catch {}
|
||||
|
||||
setExecutionView(executionView);
|
||||
refreshData();
|
||||
connectWebSocket();
|
||||
setInterval(refreshData, 30000);
|
||||
}
|
||||
});
|
||||
|
||||
// Ctrl+Enter submits the quick command form
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
const activeTab = document.querySelector('.tab-content.active');
|
||||
if (activeTab && activeTab.id === 'quickcommand') {
|
||||
e.preventDefault();
|
||||
executeQuickCommand();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Terminal Boot Sequence -->
|
||||
|
||||
307
server.js
307
server.js
@@ -33,6 +33,58 @@ const executionLimiter = rateLimit({
|
||||
});
|
||||
app.use('/api/', apiLimiter);
|
||||
|
||||
// Named constants for timeouts and limits
|
||||
const PROMPT_TIMEOUT_MS = 60 * 60 * 1000; // 60 min — how long a prompt waits for user input
|
||||
const COMMAND_TIMEOUT_MS = 120_000; // 2 min — workflow step command timeout
|
||||
const QUICK_CMD_TIMEOUT_MS = 60_000; // 1 min — quick/direct/gandalf command timeout
|
||||
const WEBHOOK_TIMEOUT_MS = 10_000; // 10 s — outbound webhook HTTP timeout
|
||||
const WAIT_STEP_MAX_MS = 24 * 60 * 60_000; // 24 h — cap on workflow wait step
|
||||
const GOTO_MAX_VISITS = 100; // max times a step may be revisited via goto
|
||||
const WORKER_STALE_MINUTES = 5; // minutes before a worker is marked offline
|
||||
const SERVER_VERSION = '1.0.0';
|
||||
|
||||
// Request logging middleware
|
||||
app.use((req, res, next) => {
|
||||
const start = Date.now();
|
||||
res.on('finish', () => {
|
||||
console.log(`[HTTP] ${req.method} ${req.path} ${res.statusCode} ${Date.now() - start}ms`);
|
||||
});
|
||||
next();
|
||||
});
|
||||
|
||||
// Content-Type guard for JSON endpoints
|
||||
function requireJSON(req, res, next) {
|
||||
const ct = req.headers['content-type'] || '';
|
||||
if (!ct.includes('application/json')) {
|
||||
return res.status(415).json({ error: 'Content-Type must be application/json' });
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
// Validate and parse a webhook URL; returns { ok, url, reason }
|
||||
function validateWebhookUrl(raw) {
|
||||
if (!raw) return { ok: true, url: null };
|
||||
let url;
|
||||
try { url = new URL(raw); } catch { return { ok: false, reason: 'Invalid URL format' }; }
|
||||
if (!['http:', 'https:'].includes(url.protocol)) {
|
||||
return { ok: false, reason: 'Webhook URL must use http or https' };
|
||||
}
|
||||
const host = url.hostname.toLowerCase();
|
||||
if (
|
||||
host === 'localhost' ||
|
||||
host === '::1' ||
|
||||
/^127\./.test(host) ||
|
||||
/^10\./.test(host) ||
|
||||
/^192\.168\./.test(host) ||
|
||||
/^172\.(1[6-9]|2\d|3[01])\./.test(host) ||
|
||||
/^169\.254\./.test(host) ||
|
||||
/^fe80:/i.test(host)
|
||||
) {
|
||||
return { ok: false, reason: 'Webhook URL must not point to a private/internal address' };
|
||||
}
|
||||
return { ok: true, url };
|
||||
}
|
||||
|
||||
// Database pool
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
@@ -198,9 +250,15 @@ async function processScheduledCommands() {
|
||||
console.log(`[Scheduler] Running scheduled command: ${schedule.name}`);
|
||||
|
||||
// Handle both string (raw SQL) and object (auto-parsed by MySQL2 JSON column)
|
||||
const workerIds = typeof schedule.worker_ids === 'string'
|
||||
let workerIds;
|
||||
try {
|
||||
workerIds = typeof schedule.worker_ids === 'string'
|
||||
? JSON.parse(schedule.worker_ids)
|
||||
: schedule.worker_ids;
|
||||
} catch {
|
||||
console.error(`[Scheduler] Invalid worker_ids JSON for "${schedule.name}" — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Execute command on each worker
|
||||
for (const workerId of workerIds) {
|
||||
@@ -226,7 +284,7 @@ async function processScheduledCommands() {
|
||||
execution_id: executionId,
|
||||
command: schedule.command,
|
||||
worker_id: workerId,
|
||||
timeout: 300000 // 5 minute timeout for scheduled commands
|
||||
timeout: 5 * 60_000 // SCHEDULED_CMD_TIMEOUT
|
||||
}));
|
||||
|
||||
broadcast({ type: 'execution_started', execution_id: executionId, workflow_id: null });
|
||||
@@ -286,6 +344,25 @@ setInterval(processScheduledCommands, 60 * 1000);
|
||||
// Initial run on startup
|
||||
setTimeout(processScheduledCommands, 5000);
|
||||
|
||||
// Mark workers offline when their heartbeat goes stale
|
||||
async function markStaleWorkersOffline() {
|
||||
try {
|
||||
const [stale] = await pool.query(
|
||||
`SELECT id FROM workers WHERE status = 'online'
|
||||
AND last_heartbeat < DATE_SUB(NOW(), INTERVAL ? MINUTE)`,
|
||||
[WORKER_STALE_MINUTES]
|
||||
);
|
||||
for (const w of stale) {
|
||||
await pool.query(`UPDATE workers SET status='offline' WHERE id=?`, [w.id]);
|
||||
broadcast({ type: 'worker_update', worker_id: w.id, status: 'offline' });
|
||||
console.log(`[Worker] Marked stale worker ${w.id} offline`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Worker] Stale check error:', error);
|
||||
}
|
||||
}
|
||||
setInterval(markStaleWorkersOffline, 60_000);
|
||||
|
||||
// WebSocket connections
|
||||
const browserClients = new Set(); // Browser UI connections
|
||||
const workerClients = new Set(); // Worker agent connections
|
||||
@@ -431,13 +508,15 @@ wss.on('connection', (ws) => {
|
||||
}
|
||||
|
||||
if (message.type === 'pong') {
|
||||
// Handle worker pong response
|
||||
const { worker_id } = message;
|
||||
// Use the DB worker ID stored on connect; fall back to message payload
|
||||
const dbId = ws.dbWorkerId || message.worker_id;
|
||||
if (dbId) {
|
||||
await pool.query(
|
||||
`UPDATE workers SET last_heartbeat=NOW() WHERE id=?`,
|
||||
[worker_id]
|
||||
[dbId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('WebSocket message error:', error);
|
||||
@@ -447,7 +526,6 @@ wss.on('connection', (ws) => {
|
||||
ws.on('close', () => {
|
||||
browserClients.delete(ws);
|
||||
workerClients.delete(ws);
|
||||
// Remove worker from workers map when disconnected (both runtime and db IDs)
|
||||
if (ws.workerId) {
|
||||
workers.delete(ws.workerId);
|
||||
console.log(`Worker ${ws.workerId} (runtime ID) disconnected`);
|
||||
@@ -455,6 +533,10 @@ wss.on('connection', (ws) => {
|
||||
if (ws.dbWorkerId) {
|
||||
workers.delete(ws.dbWorkerId);
|
||||
console.log(`Worker ${ws.dbWorkerId} (database ID) disconnected`);
|
||||
// Mark worker offline in DB
|
||||
pool.query(`UPDATE workers SET status='offline' WHERE id=?`, [ws.dbWorkerId])
|
||||
.then(() => broadcast({ type: 'worker_update', worker_id: ws.dbWorkerId, status: 'offline' }))
|
||||
.catch(err => console.error('[Worker] Failed to mark worker offline:', err));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -520,6 +602,12 @@ async function fireWebhook(executionId, status) {
|
||||
if (!rows.length || !rows[0].webhook_url) return;
|
||||
|
||||
const exec = rows[0];
|
||||
const { ok, url, reason } = validateWebhookUrl(exec.webhook_url);
|
||||
if (!ok) {
|
||||
console.warn(`[Webhook] Skipping invalid stored URL for execution ${executionId}: ${reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
execution_id: executionId,
|
||||
workflow_id: exec.workflow_id,
|
||||
@@ -531,15 +619,12 @@ async function fireWebhook(executionId, status) {
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
|
||||
const url = new URL(exec.webhook_url);
|
||||
const body = JSON.stringify(payload);
|
||||
const options = {
|
||||
const response = await fetch(exec.webhook_url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'PULSE-Webhook/1.0' }
|
||||
};
|
||||
|
||||
// Use native fetch (Node 18+)
|
||||
const response = await fetch(exec.webhook_url, { ...options, body, signal: AbortSignal.timeout(10000) });
|
||||
headers: { 'Content-Type': 'application/json', 'User-Agent': 'PULSE-Webhook/1.0' },
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(WEBHOOK_TIMEOUT_MS)
|
||||
});
|
||||
console.log(`[Webhook] ${url.hostname} responded ${response.status} for execution ${executionId}`);
|
||||
}
|
||||
|
||||
@@ -660,8 +745,20 @@ async function executeWorkflowSteps(executionId, workflowId, definition, usernam
|
||||
steps.forEach((step, i) => { if (step.id) stepIdMap.set(step.id, i); });
|
||||
|
||||
let currentIndex = 0;
|
||||
const stepVisits = new Array(steps.length).fill(0);
|
||||
|
||||
while (currentIndex < steps.length) {
|
||||
// Detect goto infinite loops
|
||||
stepVisits[currentIndex] = (stepVisits[currentIndex] || 0) + 1;
|
||||
if (stepVisits[currentIndex] > GOTO_MAX_VISITS) {
|
||||
await addExecutionLog(executionId, {
|
||||
action: 'workflow_error',
|
||||
error: `Infinite loop detected at step ${currentIndex + 1} (visited ${stepVisits[currentIndex]} times)`,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
await updateExecutionStatus(executionId, 'failed');
|
||||
return;
|
||||
}
|
||||
// Enforce global execution timeout
|
||||
if (Date.now() > executionDeadline) {
|
||||
await addExecutionLog(executionId, {
|
||||
@@ -723,7 +820,9 @@ async function executeWorkflowSteps(executionId, workflowId, definition, usernam
|
||||
}
|
||||
|
||||
} else if (step.type === 'wait') {
|
||||
const ms = (step.duration || 5) * 1000;
|
||||
let ms = parseFloat(step.duration || 5) * 1000;
|
||||
if (isNaN(ms) || ms < 0) ms = 5000;
|
||||
if (ms > WAIT_STEP_MAX_MS) ms = WAIT_STEP_MAX_MS;
|
||||
await addExecutionLog(executionId, {
|
||||
step: currentIndex + 1, action: 'waiting', duration_ms: ms,
|
||||
timestamp: new Date().toISOString()
|
||||
@@ -795,12 +894,15 @@ async function executePromptStep(executionId, step, stepNumber) {
|
||||
_executionPrompts.delete(executionId);
|
||||
console.warn(`[Workflow] Prompt timed out for execution ${executionId}`);
|
||||
resolve(null);
|
||||
}, 60 * 60 * 1000); // 60 minute timeout
|
||||
}, PROMPT_TIMEOUT_MS);
|
||||
|
||||
_executionPrompts.set(executionId, (response) => {
|
||||
_executionPrompts.set(executionId, {
|
||||
resolve: (response) => {
|
||||
clearTimeout(timer);
|
||||
_executionPrompts.delete(executionId);
|
||||
resolve(response);
|
||||
},
|
||||
options
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -880,11 +982,11 @@ async function executeCommandStep(executionId, step, stepNumber, params = {}) {
|
||||
command_id: commandId,
|
||||
command: command,
|
||||
worker_id: workerId,
|
||||
timeout: 120000 // 2 minute timeout
|
||||
timeout: COMMAND_TIMEOUT_MS
|
||||
}));
|
||||
|
||||
// Wait for command result (with timeout)
|
||||
const result = await waitForCommandResult(executionId, commandId, 120000);
|
||||
const result = await waitForCommandResult(executionId, commandId, COMMAND_TIMEOUT_MS);
|
||||
results.push(result);
|
||||
|
||||
if (!result.success) {
|
||||
@@ -936,7 +1038,8 @@ app.get('/api/workflows', authenticateSSO, async (req, res) => {
|
||||
const [rows] = await pool.query('SELECT * FROM workflows ORDER BY created_at DESC');
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[API] GET /api/workflows error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -945,18 +1048,24 @@ app.get('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
||||
const [rows] = await pool.query('SELECT * FROM workflows WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Not found' });
|
||||
const wf = rows[0];
|
||||
res.json({ ...wf, definition: JSON.parse(wf.definition || '{}') });
|
||||
let definition = {};
|
||||
try { definition = JSON.parse(wf.definition || '{}'); } catch { /* corrupt definition — return empty */ }
|
||||
res.json({ ...wf, definition });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[API] GET /api/workflows/:id error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/workflows', authenticateSSO, async (req, res) => {
|
||||
app.post('/api/workflows', authenticateSSO, requireJSON, async (req, res) => {
|
||||
try {
|
||||
const { name, description, definition, webhook_url } = req.body;
|
||||
const id = crypto.randomUUID();
|
||||
if (!name || !definition) return res.status(400).json({ error: 'name and definition are required' });
|
||||
|
||||
console.log('[Workflow] Creating workflow:', name);
|
||||
const webhookCheck = validateWebhookUrl(webhook_url);
|
||||
if (!webhookCheck.ok) return res.status(400).json({ error: webhookCheck.reason });
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
await pool.query(
|
||||
'INSERT INTO workflows (id, name, description, definition, webhook_url, created_by) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
@@ -964,13 +1073,10 @@ app.post('/api/workflows', authenticateSSO, async (req, res) => {
|
||||
);
|
||||
|
||||
res.json({ id, name, description, definition, webhook_url: webhook_url || null });
|
||||
|
||||
console.log('[Workflow] Broadcasting workflow_created');
|
||||
broadcast({ type: 'workflow_created', workflow_id: id });
|
||||
console.log('[Workflow] Broadcast complete');
|
||||
} catch (error) {
|
||||
console.error('[Workflow] Error creating workflow:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -985,7 +1091,7 @@ app.delete('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
||||
res.json({ success: true });
|
||||
broadcast({ type: 'workflow_deleted', workflow_id: req.params.id });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -994,7 +1100,8 @@ app.get('/api/workers', authenticateSSO, async (req, res) => {
|
||||
const [rows] = await pool.query('SELECT id, name, status, last_heartbeat, metadata FROM workers ORDER BY name');
|
||||
res.json(rows);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[API] GET /api/workers error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1003,8 +1110,8 @@ app.post('/api/workers/heartbeat', async (req, res) => {
|
||||
const { worker_id, name, metadata } = req.body;
|
||||
const apiKey = req.headers['x-api-key'];
|
||||
|
||||
// Verify API key
|
||||
if (apiKey !== process.env.WORKER_API_KEY) {
|
||||
// Verify API key — reject missing or wrong keys
|
||||
if (!apiKey || apiKey !== process.env.WORKER_API_KEY) {
|
||||
return res.status(401).json({ error: 'Invalid API key' });
|
||||
}
|
||||
|
||||
@@ -1021,13 +1128,15 @@ app.post('/api/workers/heartbeat', async (req, res) => {
|
||||
broadcast({ type: 'worker_update', worker_id, status: 'online' });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[Worker] Heartbeat error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/executions', executionLimiter, authenticateSSO, async (req, res) => {
|
||||
app.post('/api/executions', executionLimiter, authenticateSSO, requireJSON, async (req, res) => {
|
||||
try {
|
||||
const { workflow_id, params = {}, dry_run = false } = req.body;
|
||||
if (!workflow_id) return res.status(400).json({ error: 'workflow_id is required' });
|
||||
const id = crypto.randomUUID();
|
||||
|
||||
// Get workflow definition
|
||||
@@ -1037,7 +1146,12 @@ app.post('/api/executions', executionLimiter, authenticateSSO, async (req, res)
|
||||
}
|
||||
|
||||
const workflow = workflows[0];
|
||||
const definition = typeof workflow.definition === 'string' ? JSON.parse(workflow.definition) : workflow.definition;
|
||||
let definition;
|
||||
try {
|
||||
definition = typeof workflow.definition === 'string' ? JSON.parse(workflow.definition) : workflow.definition;
|
||||
} catch {
|
||||
return res.status(500).json({ error: 'Workflow definition is corrupt' });
|
||||
}
|
||||
|
||||
// Validate required params
|
||||
const paramDefs = definition.params || [];
|
||||
@@ -1069,7 +1183,8 @@ app.post('/api/executions', executionLimiter, authenticateSSO, async (req, res)
|
||||
|
||||
res.json({ id, workflow_id, status: 'running', dry_run: !!dry_run });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[Execution] Error starting execution:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1138,7 +1253,21 @@ app.get('/api/executions', authenticateSSO, async (req, res) => {
|
||||
hasMore: offset + rows.length < total
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.delete('/api/executions/completed', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin access required' });
|
||||
const [result] = await pool.query(
|
||||
"DELETE FROM executions WHERE status IN ('completed', 'failed')"
|
||||
);
|
||||
broadcast({ type: 'executions_bulk_deleted' });
|
||||
res.json({ success: true, deleted: result.affectedRows });
|
||||
} catch (error) {
|
||||
console.error('[Execution] Bulk delete error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1149,7 +1278,8 @@ app.delete('/api/executions/:id', authenticateSSO, async (req, res) => {
|
||||
broadcast({ type: 'execution_deleted', execution_id: req.params.id });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[Execution] Error deleting execution:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1180,14 +1310,14 @@ app.post('/api/executions/:id/abort', authenticateSSO, async (req, res) => {
|
||||
|
||||
// Unblock any pending prompt so the thread can exit
|
||||
const pending = _executionPrompts.get(executionId);
|
||||
if (pending) pending(null);
|
||||
if (pending) pending.resolve(null);
|
||||
|
||||
console.log(`[Execution] Execution ${executionId} aborted by ${req.user.username}`);
|
||||
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error('[Execution] Error aborting execution:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1197,11 +1327,20 @@ app.post('/api/executions/:id/respond', authenticateSSO, async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { response } = req.body;
|
||||
|
||||
if (!response) return res.status(400).json({ error: 'response is required' });
|
||||
if (!response || typeof response !== 'string') {
|
||||
return res.status(400).json({ error: 'response is required' });
|
||||
}
|
||||
|
||||
const pending = _executionPrompts.get(id);
|
||||
if (!pending) return res.status(404).json({ error: 'No pending prompt for this execution' });
|
||||
|
||||
// Validate response is one of the allowed options
|
||||
if (pending.options && !pending.options.includes(response)) {
|
||||
return res.status(400).json({
|
||||
error: `Invalid response. Allowed values: ${pending.options.join(', ')}`
|
||||
});
|
||||
}
|
||||
|
||||
await addExecutionLog(id, {
|
||||
action: 'prompt_response', response,
|
||||
responded_by: req.user.username,
|
||||
@@ -1210,15 +1349,15 @@ app.post('/api/executions/:id/respond', authenticateSSO, async (req, res) => {
|
||||
|
||||
broadcast({ type: 'prompt_response', execution_id: id, response });
|
||||
|
||||
pending(response);
|
||||
pending.resolve(response);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Edit a workflow definition (admin only)
|
||||
app.put('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
||||
app.put('/api/workflows/:id', authenticateSSO, requireJSON, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin only' });
|
||||
|
||||
@@ -1227,11 +1366,13 @@ app.put('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
||||
|
||||
if (!name || !definition) return res.status(400).json({ error: 'name and definition required' });
|
||||
|
||||
// Validate definition is parseable JSON
|
||||
const webhookCheck = validateWebhookUrl(webhook_url);
|
||||
if (!webhookCheck.ok) return res.status(400).json({ error: webhookCheck.reason });
|
||||
|
||||
let defObj;
|
||||
try {
|
||||
defObj = typeof definition === 'string' ? JSON.parse(definition) : definition;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return res.status(400).json({ error: 'Invalid JSON in definition' });
|
||||
}
|
||||
|
||||
@@ -1245,7 +1386,8 @@ app.put('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
||||
broadcast({ type: 'workflow_updated', workflow_id: id });
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[Workflow] Error updating workflow:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1257,11 +1399,11 @@ app.get('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
||||
);
|
||||
res.json(schedules);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
||||
app.post('/api/scheduled-commands', authenticateSSO, requireJSON, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) return res.status(403).json({ error: 'Admin access required' });
|
||||
const { name, command, worker_ids, schedule_type, schedule_value } = req.body;
|
||||
@@ -1270,6 +1412,14 @@ app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
// Validate schedule_value for numeric types
|
||||
if (schedule_type === 'interval' || schedule_type === 'hourly') {
|
||||
const n = parseInt(schedule_value, 10);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
return res.status(400).json({ error: `schedule_value for type "${schedule_type}" must be a positive integer` });
|
||||
}
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const nextRun = calculateNextRun(schedule_type, schedule_value);
|
||||
|
||||
@@ -1282,7 +1432,7 @@ app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
|
||||
|
||||
res.json({ success: true, id });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1301,7 +1451,7 @@ app.put('/api/scheduled-commands/:id/toggle', authenticateSSO, async (req, res)
|
||||
|
||||
res.json({ success: true, enabled: newEnabled });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1312,7 +1462,7 @@ app.delete('/api/scheduled-commands/:id', authenticateSSO, async (req, res) => {
|
||||
await pool.query('DELETE FROM scheduled_commands WHERE id = ?', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1347,12 +1497,12 @@ app.post('/api/internal/command', authenticateGandalf, async (req, res) => {
|
||||
execution_id: executionId,
|
||||
command: command,
|
||||
worker_id: worker_id,
|
||||
timeout: 60000
|
||||
timeout: QUICK_CMD_TIMEOUT_MS
|
||||
}));
|
||||
|
||||
res.json({ execution_id: executionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1363,12 +1513,32 @@ app.get('/api/internal/executions/:id', authenticateGandalf, async (req, res) =>
|
||||
return res.status(404).json({ error: 'Not found' });
|
||||
}
|
||||
const execution = rows[0];
|
||||
let logs = [];
|
||||
try { logs = JSON.parse(execution.logs || '[]'); } catch { logs = []; }
|
||||
res.json({ ...execution, logs });
|
||||
} catch (error) {
|
||||
console.error('[API] GET /api/internal/executions/:id error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Detailed health + stats (auth required)
|
||||
app.get('/api/health', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
await pool.query('SELECT 1');
|
||||
const [workerRows] = await pool.query('SELECT status, COUNT(*) as count FROM workers GROUP BY status');
|
||||
const workerCounts = Object.fromEntries(workerRows.map(r => [r.status, Number(r.count)]));
|
||||
res.json({
|
||||
...execution,
|
||||
logs: JSON.parse(execution.logs || '[]')
|
||||
status: 'ok',
|
||||
version: SERVER_VERSION,
|
||||
uptime_seconds: Math.floor(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
workers: { online: workerCounts.online || 0, offline: workerCounts.offline || 0 },
|
||||
database: 'connected'
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[Health] /api/health error:', error);
|
||||
res.status(500).json({ status: 'error', timestamp: new Date().toISOString() });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1387,7 +1557,7 @@ app.get('/health', async (req, res) => {
|
||||
status: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
database: 'disconnected',
|
||||
error: error.message
|
||||
error: 'Internal server error'
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1401,7 +1571,10 @@ app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
|
||||
}
|
||||
|
||||
const execution = rows[0];
|
||||
const parsedLogs = typeof execution.logs === 'string' ? JSON.parse(execution.logs || '[]') : (execution.logs || []);
|
||||
let parsedLogs = [];
|
||||
try {
|
||||
parsedLogs = typeof execution.logs === 'string' ? JSON.parse(execution.logs || '[]') : (execution.logs || []);
|
||||
} catch { parsedLogs = []; }
|
||||
const waitingForInput = _executionPrompts.has(req.params.id);
|
||||
let pendingPrompt = null;
|
||||
if (waitingForInput) {
|
||||
@@ -1420,7 +1593,8 @@ app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
|
||||
prompt: pendingPrompt,
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
console.error('[API] GET /api/executions/:id error:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1435,14 +1609,17 @@ app.delete('/api/workers/:id', authenticateSSO, async (req, res) => {
|
||||
res.json({ success: true });
|
||||
broadcast({ type: 'worker_deleted', worker_id: req.params.id });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Send direct command to specific worker
|
||||
app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
||||
app.post('/api/workers/:id/command', authenticateSSO, requireJSON, async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
if (!command || typeof command !== 'string' || !command.trim()) {
|
||||
return res.status(400).json({ error: 'command is required' });
|
||||
}
|
||||
const executionId = crypto.randomUUID();
|
||||
const workerId = req.params.id;
|
||||
|
||||
@@ -1464,7 +1641,7 @@ app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
||||
execution_id: executionId,
|
||||
command: command,
|
||||
worker_id: workerId,
|
||||
timeout: 60000
|
||||
timeout: QUICK_CMD_TIMEOUT_MS
|
||||
};
|
||||
|
||||
const workerWs = workers.get(workerId);
|
||||
@@ -1478,7 +1655,7 @@ app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
||||
broadcast({ type: 'execution_started', execution_id: executionId, workflow_id: null });
|
||||
res.json({ success: true, execution_id: executionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user