Compare commits

..

20 Commits

Author SHA1 Message Date
84edea8027 Phase 7: Multi-Worker Command Execution
Added ability to execute commands on multiple workers simultaneously:

- Added execution mode selector (Single/Multiple Workers)
- Multi-worker mode with checkbox list for worker selection
- Helper buttons: Select All, Online Only, Clear All
- Sequential execution across selected workers
- Results summary showing success/fail count per worker
- Updated command history to track multi-worker executions
- Terminal beep feedback based on overall success/failure
- Maintained backward compatibility with single worker mode

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 23:03:45 -05:00
095bfb65ab Fix clearCompletedExecutions for new pagination API format
Changes:
- Handle new pagination response format (data.executions vs data)
- Request up to 1000 executions to ensure all are checked
- Track successful deletions count
- Use terminal notification instead of alert
- Better error handling for individual delete failures

Fixes regression from Phase 5 pagination changes.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:55:13 -05:00
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
76b0a6d0d3 Phase 3: Quick command enhancements with templates and history
Changes:
- Added command templates modal with 12 common system commands
- Added command history tracking (stored in localStorage)
- History saves last 50 commands with timestamp and worker name
- Template categories: system info, disk/memory, network, Docker, logs
- Click templates to auto-fill command field
- Click history items to reuse previous commands
- Terminal-themed modals with green/amber styling
- History persists across browser sessions

Templates included:
- System: uname, uptime, CPU info, processes
- Resources: df -h, free -h, memory usage
- Network: ip addr, active connections
- Docker: container list
- Logs: syslog tail, who is logged in, last logins

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:45:40 -05:00
adbbec2631 Phase 2: Enhanced worker status display with metadata
Changes:
- Show worker system metrics in dashboard and worker list
- Display CPU cores, memory usage, load average, uptime
- Added formatBytes() to display memory in human-readable format
- Added formatUptime() to show uptime as days/hours/minutes
- Added getTimeAgo() to show relative last-seen time
- Improved worker list with detailed metadata panel
- Show active tasks vs max concurrent tasks
- Terminal-themed styling for metadata display
- Amber labels for metadata fields

Benefits:
- See worker health at a glance
- Monitor resource usage (CPU, RAM, load)
- Track worker activity (active tasks)
- Better operational visibility

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:43:13 -05:00
8b8d2c6312 Phase 1: Improve log display formatting
Changes:
- Added formatLogEntry() function to parse and format log entries
- Replaced raw JSON display with readable formatted logs
- Added specific formatting for command_sent and command_result logs
- Show timestamp, status, duration, stdout/stderr in organized layout
- Color-coded success (green) and failure (red) states
- Added scrollable output sections with max-height
- Syntax highlighting for command code blocks
- Terminal-themed styling with green/amber colors

Benefits:
- Much easier to read execution logs
- Clear visual distinction between sent/result logs
- Professional terminal aesthetic maintained
- Better UX for debugging command execution

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:41:29 -05:00
1f5c84f327 Add execution cleanup functionality
Changes:
- Added DELETE /api/executions/:id endpoint
- Added "Clear Completed" button to Executions tab
- Deletes all completed and failed executions
- Broadcasts execution_deleted event to update all clients
- Shows count of deleted executions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:36:51 -05:00
e03f8d6287 Fix worker ID mapping - use database ID for command routing
Problem:
- Workers generate random UUID on startup (runtime ID)
- Database stores workers with persistent IDs (database ID)
- UI sends commands using database ID
- Server couldn't find worker connection (stored by runtime ID)
- Result: 400 Bad Request "Worker not connected"

Solution:
- When worker connects, look up database ID by worker name
- Store WebSocket connection in Map using BOTH IDs:
  * Runtime ID (from worker_connect message)
  * Database ID (from database lookup by name)
- Commands from UI use database ID → finds correct WebSocket
- Cleanup both IDs when worker disconnects

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:29:23 -05:00
2097b73404 Fix worker command execution and execution status updates
Changes:
- Removed duplicate /api/executions/:id endpoint that didn't parse logs
- Added workers Map to track worker_id -> WebSocket connection
- Store worker connections when they send worker_connect message
- Send commands to specific worker instead of broadcasting to all clients
- Clean up workers Map when worker disconnects
- Update execution status to completed/failed when command results arrive
- Add proper error handling when worker is not connected

Fixes:
- execution.logs.forEach is not a function (logs now properly parsed)
- Commands stuck in "running" status (now update to completed/failed)
- Commands not reaching workers (now sent to specific worker WebSocket)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:23:02 -05:00
6d15e4d240 Remove database migrations after direct schema fixes
Changes:
- Removed all migration code from server.js
- Database schema fixed directly via MySQL:
  * Dropped users.role column (SSO only)
  * Dropped users.password column (SSO only)
  * Added executions.started_by column
  * Added workflows.created_by column
  * All tables now match expected schema
- Server startup will be faster without migrations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:11:07 -05:00
7896b40d91 Remove password column from users table
Changes:
- Drop password column from users table (SSO authentication only)
- PULSE uses Authelia SSO, not password-based authentication
- Fixes 500 error: Field 'password' doesn't have a default value

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:01:58 -05:00
e2dc371bfe Add last_login column to users table migration
Changes:
- Add last_login TIMESTAMP column to existing users table
- Complete the users table migration with all required columns
- Fixes 500 error: Unknown column 'last_login' in 'INSERT INTO'

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 20:33:04 -05:00
df0184facf Add migration to update users table schema
Changes:
- Add display_name, email, and groups columns to existing users table
- Handle MariaDB lack of IF NOT EXISTS in ALTER TABLE
- Gracefully skip columns that already exist
- Fixes 500 error when authenticating users

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 20:28:47 -05:00
a8be111e04 Allow NULL workflow_id in executions table for quick commands
Changes:
- Modified executions table schema to allow NULL workflow_id
- Removed foreign key constraint that prevented NULL values
- Added migration to update existing table structure
- Quick commands can now be stored without a workflow reference

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 20:27:02 -05:00
b3806545bd Fix quick command executions not appearing in execution tab
Changes:
- Create execution record in database when quick command is sent
- Store initial log entry with command details
- Broadcast execution_started event to update UI
- Display quick commands as "[Quick Command]" in execution list
- Fix worker communication to properly track all executions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 20:24:11 -05:00
2767087e27 Updated websocket handler 2026-01-07 20:20:18 -05:00
a1cf8ac90b updates aesthetic 2026-01-07 20:12:16 -05:00
9e842624e1 Claude md file 2026-01-07 19:57:16 -05:00
3 changed files with 1716 additions and 1249 deletions

1372
Claude.md Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -770,7 +770,6 @@
<button class="tab" onclick="switchTab('workflows')">📋 Workflows</button>
<button class="tab" onclick="switchTab('executions')">🚀 Executions</button>
<button class="tab" onclick="switchTab('quickcommand')">⚡ Quick Command</button>
<button class="tab" onclick="switchTab('scheduler')">⏰ Scheduler</button>
</div>
<!-- Dashboard Tab -->
@@ -810,46 +809,8 @@
<div id="executions" class="tab-content">
<div class="card">
<h3>Execution History</h3>
<!-- Search and Filter Section -->
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 20px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<!-- Search Box -->
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--terminal-amber);">🔍 Search:</label>
<input type="text" id="executionSearch" placeholder="Search by command, execution ID, or workflow name..."
oninput="filterExecutions()"
style="width: 100%; padding: 10px; margin: 0;">
</div>
<!-- Status Filter -->
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--terminal-amber);">📊 Status Filter:</label>
<select id="statusFilter" onchange="filterExecutions()" style="width: 100%; padding: 10px; margin: 0;">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="waiting">Waiting</option>
</select>
</div>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button onclick="clearFilters()" class="small">[ Clear Filters ]</button>
<span id="filterStats" style="color: var(--terminal-green); font-size: 0.9em; font-family: var(--font-mono);"></span>
</div>
</div>
<button onclick="refreshData()">[ 🔄 Refresh ]</button>
<button onclick="clearCompletedExecutions()" style="margin-left: 10px;">[ 🗑️ Clear Completed ]</button>
<button onclick="toggleCompareMode()" id="compareModeBtn" style="margin-left: 10px;">[ 📊 Compare Mode ]</button>
<button onclick="compareSelectedExecutions()" id="compareBtn" style="margin-left: 10px; display: none;">[ ⚖️ Compare Selected ]</button>
<div id="compareInstructions" style="display: none; background: rgba(255, 176, 0, 0.1); border: 2px solid var(--terminal-amber); padding: 12px; margin: 15px 0; color: var(--terminal-amber);">
Select 2-5 executions to compare their outputs. Click executions to toggle selection.
</div>
<div id="executionList"><div class="loading">Loading...</div></div>
</div>
</div>
@@ -904,19 +865,6 @@
<div id="quickCommandResult" style="margin-top: 20px;"></div>
</div>
</div>
<!-- Scheduler Tab -->
<div id="scheduler" class="tab-content">
<div class="card">
<h3>⏰ Scheduled Commands</h3>
<p style="color: var(--terminal-green); margin-bottom: 20px;">Automate command execution with flexible scheduling</p>
<button onclick="showCreateSchedule()">[ Create Schedule ]</button>
<button onclick="refreshData()" style="margin-left: 10px;">[ 🔄 Refresh ]</button>
<div id="scheduleList" style="margin-top: 20px;"><div class="loading">Loading...</div></div>
</div>
</div>
</div>
<!-- Create Workflow Modal -->
@@ -968,57 +916,10 @@
</div>
</div>
<!-- Compare Executions Modal -->
<div id="compareExecutionsModal" class="modal">
<div class="modal-content" style="max-width: 90%; max-height: 90vh;">
<h2>⚖️ Execution Comparison</h2>
<div id="compareContent" style="max-height: 70vh; overflow-y: auto; padding: 20px;"></div>
<button onclick="closeModal('compareExecutionsModal')">[ Close ]</button>
</div>
</div>
<!-- Create Schedule Modal -->
<div id="createScheduleModal" class="modal">
<div class="modal-content">
<h2>Create Scheduled Command</h2>
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Schedule Name:</label>
<input type="text" id="scheduleName" placeholder="e.g., Daily System Check">
<label style="display: block; margin-bottom: 8px; margin-top: 15px; font-weight: 600;">Command:</label>
<textarea id="scheduleCommand" placeholder="Enter command to execute"></textarea>
<label style="display: block; margin-bottom: 8px; margin-top: 15px; font-weight: 600;">Target Workers:</label>
<div id="scheduleWorkerList" style="background: var(--bg-primary); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 15px; max-height: 150px; overflow-y: auto;">
<div class="loading">Loading workers...</div>
</div>
<label style="display: block; margin-bottom: 8px; margin-top: 15px; font-weight: 600;">Schedule Type:</label>
<select id="scheduleType" onchange="updateScheduleInput()">
<option value="interval">Every X Minutes</option>
<option value="hourly">Every X Hours</option>
<option value="daily">Daily at Time</option>
</select>
<div id="scheduleInputContainer" style="margin-top: 15px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Interval (minutes):</label>
<input type="number" id="scheduleValue" placeholder="e.g., 30" min="1">
</div>
<div style="margin-top: 20px;">
<button onclick="createSchedule()">[ Create Schedule ]</button>
<button onclick="closeModal('createScheduleModal')" style="margin-left: 10px;">[ Cancel ]</button>
</div>
</div>
</div>
<script>
let currentUser = null;
let ws = null;
let workers = [];
let allExecutions = []; // Store all loaded executions for filtering
let compareMode = false;
let selectedExecutions = [];
async function loadUser() {
try {
@@ -1146,7 +1047,7 @@
try {
const response = await fetch('/api/workflows');
const workflows = await response.json();
const html = workflows.length === 0 ?
'<div class="empty">No workflows defined yet</div>' :
workflows.map(w => `
@@ -1156,8 +1057,8 @@
<div class="timestamp">Created by ${w.created_by || 'Unknown'} on ${new Date(w.created_at).toLocaleString()}</div>
<div style="margin-top: 10px;">
<button onclick="executeWorkflow('${w.id}', '${w.name}')">▶️ Execute</button>
${currentUser && currentUser.isAdmin ?
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
${currentUser && currentUser.isAdmin ?
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
: ''}
</div>
</div>
@@ -1168,194 +1069,6 @@
}
}
async function loadSchedules() {
try {
const response = await fetch('/api/scheduled-commands');
const schedules = await response.json();
const html = schedules.length === 0 ?
'<div class="empty">No scheduled commands yet</div>' :
schedules.map(s => {
const workerIds = JSON.parse(s.worker_ids);
const workerNames = workerIds.map(id => {
const w = workers.find(worker => worker.id === id);
return w ? w.name : id.substring(0, 8);
}).join(', ');
let scheduleDesc = '';
if (s.schedule_type === 'interval') {
scheduleDesc = `Every ${s.schedule_value} minutes`;
} else if (s.schedule_type === 'hourly') {
scheduleDesc = `Every ${s.schedule_value} hour(s)`;
} else if (s.schedule_type === 'daily') {
scheduleDesc = `Daily at ${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';
return `
<div class="workflow-item" style="opacity: ${s.enabled ? 1 : 0.6};">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<div class="workflow-name">${s.name}</div>
<div style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9em; margin: 8px 0;">
Command: <code>${escapeHtml(s.command)}</code>
</div>
<div style="color: var(--terminal-amber); font-size: 0.9em; margin-bottom: 5px;">
📅 ${scheduleDesc}
</div>
<div style="color: var(--text-muted); font-size: 0.85em;">
Workers: ${workerNames}
</div>
<div class="timestamp">
Last run: ${lastRun} | Next run: ${nextRun}
</div>
</div>
<div style="margin-left: 15px;">
<span class="status ${s.enabled ? 'online' : 'offline'}" style="font-size: 0.85em;">
${s.enabled ? 'ENABLED' : 'DISABLED'}
</span>
</div>
</div>
<div style="margin-top: 10px;">
<button onclick="toggleSchedule('${s.id}')" class="small">
${s.enabled ? '⏸️ Disable' : '▶️ Enable'}
</button>
<button class="danger small" onclick="deleteSchedule('${s.id}', '${s.name}')">🗑️ Delete</button>
</div>
</div>
`;
}).join('');
document.getElementById('scheduleList').innerHTML = html;
} catch (error) {
console.error('Error loading schedules:', error);
}
}
function showCreateSchedule() {
// Populate worker checkboxes
const workerList = document.getElementById('scheduleWorkerList');
workerList.innerHTML = workers.length === 0 ?
'<div class="empty">No workers available</div>' :
workers.map(w => `
<label style="display: block; margin-bottom: 10px; cursor: pointer; padding: 8px; border: 1px solid var(--terminal-green);">
<input type="checkbox" name="scheduleWorkerCheckbox" value="${w.id}" style="width: auto; margin-right: 8px;">
<span class="status ${w.status}" style="padding: 2px 8px; font-size: 0.8em;">[${w.status === 'online' ? '●' : '○'}]</span>
<strong>${w.name}</strong>
</label>
`).join('');
document.getElementById('createScheduleModal').classList.add('show');
}
function updateScheduleInput() {
const scheduleType = document.getElementById('scheduleType').value;
const container = document.getElementById('scheduleInputContainer');
if (scheduleType === 'interval') {
container.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Interval (minutes):</label>
<input type="number" id="scheduleValue" placeholder="e.g., 30" min="1">
`;
} else if (scheduleType === 'hourly') {
container.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Every X Hours:</label>
<input type="number" id="scheduleValue" placeholder="e.g., 2" min="1" max="24">
`;
} else if (scheduleType === 'daily') {
container.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Time (HH:MM):</label>
<input type="time" id="scheduleValue">
`;
}
}
async function createSchedule() {
const name = document.getElementById('scheduleName').value;
const command = document.getElementById('scheduleCommand').value;
const scheduleType = document.getElementById('scheduleType').value;
const scheduleValue = document.getElementById('scheduleValue').value;
const selectedWorkers = Array.from(document.querySelectorAll('input[name="scheduleWorkerCheckbox"]:checked')).map(cb => cb.value);
if (!name || !command || !scheduleValue || selectedWorkers.length === 0) {
alert('Please fill in all fields and select at least one worker');
return;
}
try {
const response = await fetch('/api/scheduled-commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
command,
worker_ids: selectedWorkers,
schedule_type: scheduleType,
schedule_value: scheduleValue
})
});
if (response.ok) {
closeModal('createScheduleModal');
showTerminalNotification('Schedule created successfully', 'success');
loadSchedules();
// Clear form
document.getElementById('scheduleName').value = '';
document.getElementById('scheduleCommand').value = '';
document.getElementById('scheduleValue').value = '';
} else {
const error = await response.json();
showTerminalNotification('Failed to create schedule: ' + error.error, 'error');
}
} catch (error) {
console.error('Error creating schedule:', error);
showTerminalNotification('Error creating schedule', 'error');
}
}
async function toggleSchedule(scheduleId) {
try {
const response = await fetch(`/api/scheduled-commands/${scheduleId}/toggle`, {
method: 'PUT'
});
if (response.ok) {
const data = await response.json();
showTerminalNotification(`Schedule ${data.enabled ? 'enabled' : 'disabled'}`, 'success');
loadSchedules();
} else {
showTerminalNotification('Failed to toggle schedule', 'error');
}
} catch (error) {
console.error('Error toggling schedule:', error);
showTerminalNotification('Error toggling schedule', 'error');
}
}
async function deleteSchedule(scheduleId, name) {
if (!confirm(`Delete scheduled command: ${name}?`)) return;
try {
const response = await fetch(`/api/scheduled-commands/${scheduleId}`, {
method: 'DELETE'
});
if (response.ok) {
showTerminalNotification('Schedule deleted', 'success');
loadSchedules();
} else {
showTerminalNotification('Failed to delete schedule', 'error');
}
} catch (error) {
console.error('Error deleting schedule:', error);
showTerminalNotification('Error deleting schedule', 'error');
}
}
let executionOffset = 0;
const executionLimit = 50;
@@ -1367,13 +1080,6 @@
const data = await response.json();
const executions = data.executions || data; // Handle old and new API format
// Store executions for filtering
if (append) {
allExecutions = allExecutions.concat(executions);
} else {
allExecutions = executions;
}
// Dashboard view (always first 5)
if (!append) {
const dashHtml = executions.length === 0 ?
@@ -1388,8 +1094,25 @@
document.getElementById('dashExecutions').innerHTML = dashHtml;
}
// Apply filters and render
renderFilteredExecutions();
// Full execution list
const fullHtml = executions.length === 0 ?
'<div class="empty">No executions yet</div>' :
executions.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">
Started by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}
${e.completed_at ? ` • Completed at ${new Date(e.completed_at).toLocaleString()}` : ''}
</div>
</div>
`).join('');
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) {
@@ -1402,290 +1125,6 @@
}
}
function renderFilteredExecutions() {
const searchTerm = (document.getElementById('executionSearch')?.value || '').toLowerCase();
const statusFilter = document.getElementById('statusFilter')?.value || '';
// Filter executions
let filtered = allExecutions.filter(e => {
// Status filter
if (statusFilter && e.status !== statusFilter) return false;
// Search filter (search in workflow name, execution ID, and logs)
if (searchTerm) {
const workflowName = (e.workflow_name || '[Quick Command]').toLowerCase();
const executionId = e.id.toLowerCase();
// Try to extract command from logs if it's a quick command
let commandText = '';
try {
const logs = typeof e.logs === 'string' ? JSON.parse(e.logs) : e.logs;
if (logs && logs.length > 0 && logs[0].command) {
commandText = logs[0].command.toLowerCase();
}
} catch (err) {
// Ignore parsing errors
}
const matchFound = workflowName.includes(searchTerm) ||
executionId.includes(searchTerm) ||
commandText.includes(searchTerm);
if (!matchFound) return false;
}
return true;
});
// Update filter stats
const statsEl = document.getElementById('filterStats');
if (statsEl) {
if (searchTerm || statusFilter) {
statsEl.textContent = `Showing ${filtered.length} of ${allExecutions.length} executions`;
} else {
statsEl.textContent = '';
}
}
// Render filtered results
const fullHtml = filtered.length === 0 ?
'<div class="empty">No executions match your filters</div>' :
filtered.map(e => {
const isSelected = selectedExecutions.includes(e.id);
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);' : '';
return `
<div class="execution-item" 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()}` : ''}
</div>
</div>
`;
}).join('');
document.getElementById('executionList').innerHTML = fullHtml;
}
function filterExecutions() {
renderFilteredExecutions();
}
function clearFilters() {
document.getElementById('executionSearch').value = '';
document.getElementById('statusFilter').value = '';
renderFilteredExecutions();
}
function toggleCompareMode() {
compareMode = !compareMode;
selectedExecutions = [];
const btn = document.getElementById('compareModeBtn');
const compareBtn = document.getElementById('compareBtn');
const instructions = document.getElementById('compareInstructions');
if (compareMode) {
btn.textContent = '[ ✗ Exit Compare Mode ]';
btn.style.borderColor = 'var(--terminal-amber)';
btn.style.color = 'var(--terminal-amber)';
compareBtn.style.display = 'inline-block';
instructions.style.display = 'block';
} else {
btn.textContent = '[ 📊 Compare Mode ]';
btn.style.borderColor = '';
btn.style.color = '';
compareBtn.style.display = 'none';
instructions.style.display = 'none';
}
renderFilteredExecutions();
}
function toggleExecutionSelection(executionId) {
const index = selectedExecutions.indexOf(executionId);
if (index > -1) {
selectedExecutions.splice(index, 1);
} else {
if (selectedExecutions.length >= 5) {
showTerminalNotification('Maximum 5 executions can be compared', 'error');
return;
}
selectedExecutions.push(executionId);
}
renderFilteredExecutions();
// Update compare button text
const compareBtn = document.getElementById('compareBtn');
if (selectedExecutions.length >= 2) {
compareBtn.textContent = `[ ⚖️ Compare Selected (${selectedExecutions.length}) ]`;
} else {
compareBtn.textContent = '[ ⚖️ Compare Selected ]';
}
}
async function compareSelectedExecutions() {
if (selectedExecutions.length < 2) {
showTerminalNotification('Please select at least 2 executions to compare', 'error');
return;
}
// Fetch detailed data for all selected executions
const executionDetails = [];
for (const execId of selectedExecutions) {
try {
const response = await fetch(`/api/executions/${execId}`);
if (response.ok) {
executionDetails.push(await response.json());
}
} catch (error) {
console.error('Error fetching execution:', error);
}
}
if (executionDetails.length < 2) {
showTerminalNotification('Failed to load execution details', 'error');
return;
}
// Generate comparison view
let comparisonHtml = '<div style="display: grid; gap: 20px;">';
// Summary table
comparisonHtml += `
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px;">
<h3 style="margin-top: 0; color: var(--terminal-amber);">Comparison Summary</h3>
<table style="width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 0.9em;">
<thead>
<tr style="border-bottom: 2px solid var(--terminal-green);">
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Execution</th>
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Status</th>
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Started</th>
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Duration</th>
</tr>
</thead>
<tbody>
${executionDetails.map((exec, idx) => {
const duration = exec.completed_at ?
Math.round((new Date(exec.completed_at) - new Date(exec.started_at)) / 1000) + 's' :
'Running...';
return `
<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);">${duration}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Side-by-side output comparison
comparisonHtml += `
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px;">
<h3 style="margin-top: 0; color: var(--terminal-amber);">Output Comparison</h3>
<div style="display: grid; grid-template-columns: repeat(${executionDetails.length}, 1fr); gap: 15px;">
${executionDetails.map((exec, idx) => {
const logs = typeof exec.logs === 'string' ? JSON.parse(exec.logs) : exec.logs;
const resultLog = logs.find(l => l.action === 'command_result');
const stdout = resultLog?.stdout || 'No output';
const stderr = resultLog?.stderr || '';
return `
<div style="border: 2px solid var(--terminal-green); background: #000;">
<div style="background: var(--bg-secondary); padding: 10px; border-bottom: 2px solid var(--terminal-green);">
<strong style="color: var(--terminal-amber);">Execution ${idx + 1}</strong>
<div style="font-size: 0.85em; color: var(--terminal-green);">
${exec.workflow_name || '[Quick Command]'}
</div>
</div>
<div style="padding: 12px;">
${stdout ? `
<div style="margin-bottom: 10px;">
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 5px;">STDOUT:</div>
<pre style="margin: 0; color: var(--terminal-green); font-size: 0.85em; max-height: 400px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(stdout)}</pre>
</div>
` : ''}
${stderr ? `
<div>
<div style="color: #ff4444; font-weight: bold; margin-bottom: 5px;">STDERR:</div>
<pre style="margin: 0; color: #ff4444; font-size: 0.85em; max-height: 200px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(stderr)}</pre>
</div>
` : ''}
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
// Diff analysis (simple line-by-line comparison for 2 executions)
if (executionDetails.length === 2) {
const logs1 = typeof executionDetails[0].logs === 'string' ? JSON.parse(executionDetails[0].logs) : executionDetails[0].logs;
const logs2 = typeof executionDetails[1].logs === 'string' ? JSON.parse(executionDetails[1].logs) : executionDetails[1].logs;
const result1 = logs1.find(l => l.action === 'command_result');
const result2 = logs2.find(l => l.action === 'command_result');
const stdout1 = result1?.stdout || '';
const stdout2 = result2?.stdout || '';
const lines1 = stdout1.split('\n');
const lines2 = stdout2.split('\n');
const maxLines = Math.max(lines1.length, lines2.length);
let diffLines = [];
let identicalCount = 0;
let differentCount = 0;
for (let i = 0; i < maxLines; i++) {
const line1 = lines1[i] || '';
const line2 = lines2[i] || '';
if (line1 === line2) {
identicalCount++;
diffLines.push(`<div style="color: #666; padding: 2px;">${i+1}: ${escapeHtml(line1) || '(empty)'}</div>`);
} else {
differentCount++;
diffLines.push(`
<div style="background: rgba(255, 176, 0, 0.1); border-left: 3px solid var(--terminal-amber); padding: 2px; margin: 2px 0;">
<div style="color: var(--terminal-green);">${i+1} [Exec 1]: ${escapeHtml(line1) || '(empty)'}</div>
<div style="color: var(--terminal-amber);">${i+1} [Exec 2]: ${escapeHtml(line2) || '(empty)'}</div>
</div>
`);
}
}
comparisonHtml += `
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px;">
<h3 style="margin-top: 0; color: var(--terminal-amber);">Diff Analysis</h3>
<div style="margin-bottom: 10px; font-family: var(--font-mono); font-size: 0.9em;">
<span style="color: var(--terminal-green);">✓ Identical lines: ${identicalCount}</span> |
<span style="color: var(--terminal-amber);">≠ Different lines: ${differentCount}</span>
</div>
<div style="background: #000; border: 2px solid var(--terminal-green); padding: 10px; max-height: 400px; overflow-y: auto; font-family: var(--font-mono); font-size: 0.85em;">
${diffLines.join('')}
</div>
</div>
`;
}
comparisonHtml += '</div>';
document.getElementById('compareContent').innerHTML = comparisonHtml;
document.getElementById('compareExecutionsModal').classList.add('show');
}
async function loadMoreExecutions() {
executionOffset += executionLimit;
await loadExecutions(true);
@@ -1780,11 +1219,6 @@
// Add action buttons
html += '<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--terminal-green); display: flex; gap: 10px;">';
// Abort button (only for running executions)
if (execution.status === 'running') {
html += `<button onclick="abortExecution('${executionId}')" style="background-color: var(--terminal-red); border-color: var(--terminal-red);">[ ⛔ Abort Execution ]</button>`;
}
// 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) {
@@ -1840,82 +1274,6 @@
`;
}
// Workflow step logs
if (log.action === 'step_started') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-amber);">▶️ Step ${log.step}: ${log.step_name}</div>
</div>
`;
}
if (log.action === 'step_completed') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-green);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-green);">✓ Step ${log.step} Completed: ${log.step_name}</div>
</div>
`;
}
if (log.action === 'waiting') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-amber);">⏳ Waiting ${log.duration} seconds...</div>
</div>
`;
}
if (log.action === 'no_workers') {
return `
<div class="log-entry failed">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title">✗ Step ${log.step}: No Workers Available</div>
<div class="log-details">
<div class="log-field">${escapeHtml(log.message)}</div>
</div>
</div>
`;
}
if (log.action === 'worker_offline') {
return `
<div class="log-entry" style="border-left-color: #ff4444;">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: #ff4444;">⚠️ Worker Offline</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Worker ID:</span> ${log.worker_id}</div>
</div>
</div>
`;
}
if (log.action === 'workflow_error') {
return `
<div class="log-entry failed">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title">✗ Workflow Error</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Error:</span> ${escapeHtml(log.error)}</div>
</div>
</div>
`;
}
if (log.action === 'execution_aborted') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-red);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-red);">⛔ Execution Aborted</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Aborted by:</span> ${escapeHtml(log.aborted_by)}</div>
</div>
</div>
`;
}
// Fallback for unknown log types
return `<div class="log-entry"><pre>${JSON.stringify(log, null, 2)}</pre></div>`;
}
@@ -1969,28 +1327,6 @@
document.getElementById('quickCommand').scrollIntoView({ behavior: 'smooth' });
}
async function abortExecution(executionId) {
if (!confirm('Are you sure you want to abort this execution? This will mark it as failed.')) return;
try {
const response = await fetch(`/api/executions/${executionId}/abort`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
showTerminalNotification('Execution aborted', 'success');
closeModal('viewExecutionModal');
refreshData();
} else {
alert('Failed to abort execution');
}
} catch (error) {
console.error('Error aborting execution:', error);
alert('Error aborting execution');
}
}
async function downloadExecutionLogs(executionId) {
try {
const response = await fetch(`/api/executions/${executionId}`);
@@ -2208,31 +1544,25 @@
return;
}
let definition;
try {
definition = JSON.parse(definitionText);
} catch (error) {
alert('Invalid JSON definition: ' + error.message);
return;
}
try {
const definition = JSON.parse(definitionText);
const response = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, definition })
});
if (response.ok) {
alert('Workflow created!');
closeModal('createWorkflowModal');
switchTab('workflows');
showTerminalNotification('Workflow created successfully!', 'success');
refreshData();
} else {
alert('Failed to create workflow');
}
} catch (error) {
alert('Error creating workflow: ' + error.message);
alert('Invalid JSON definition: ' + error.message);
}
}
@@ -2395,30 +1725,10 @@
document.getElementById(tabName).classList.add('active');
}
async function refreshData() {
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);
}
function refreshData() {
loadWorkers();
loadWorkflows();
loadExecutions();
}
// Terminal beep sound (Web Audio API)
@@ -2497,9 +1807,8 @@
ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
console.log('WebSocket message:', data);
const data = JSON.parse(event.data);
console.log('WebSocket message:', data);
// Handle specific message types
if (data.type === 'command_result') {
@@ -2561,13 +1870,9 @@
loadWorkflows();
}
// Generic refresh for other message types
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted'].includes(data.type)) {
refreshData();
}
} catch (error) {
console.error('Error handling WebSocket message:', error);
console.error('Stack trace:', error.stack);
// Generic refresh for other message types
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted'].includes(data.type)) {
refreshData();
}
};

826
server.js
View File

@@ -89,24 +89,6 @@ async function initDatabase() {
)
`);
await connection.query(`
CREATE TABLE IF NOT EXISTS scheduled_commands (
id VARCHAR(36) PRIMARY KEY,
name VARCHAR(255) NOT NULL,
command TEXT NOT NULL,
worker_ids JSON NOT NULL,
schedule_type VARCHAR(50) NOT NULL,
schedule_value VARCHAR(255) NOT NULL,
enabled BOOLEAN DEFAULT TRUE,
created_by VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_run TIMESTAMP NULL,
next_run TIMESTAMP NULL,
INDEX idx_enabled (enabled),
INDEX idx_next_run (next_run)
)
`);
console.log('Database tables initialized successfully');
} catch (error) {
console.error('Database initialization error:', error);
@@ -137,95 +119,6 @@ setInterval(cleanupOldExecutions, 24 * 60 * 60 * 1000);
// Run cleanup on startup
cleanupOldExecutions();
// Scheduled Commands Processor
async function processScheduledCommands() {
try {
const [schedules] = await pool.query(
`SELECT * FROM scheduled_commands
WHERE enabled = TRUE
AND (next_run IS NULL OR next_run <= NOW())`
);
for (const schedule of schedules) {
console.log(`[Scheduler] Running scheduled command: ${schedule.name}`);
const workerIds = JSON.parse(schedule.worker_ids);
// Execute command on each worker
for (const workerId of workerIds) {
const workerWs = workers.get(workerId);
if (workerWs && workerWs.readyState === WebSocket.OPEN) {
const executionId = generateUUID();
// Create execution record
await pool.query(
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
[executionId, null, 'running', `scheduler:${schedule.name}`, JSON.stringify([{
step: 'scheduled_command',
action: 'command_sent',
worker_id: workerId,
command: schedule.command,
timestamp: new Date().toISOString()
}])]
);
// Send command to worker
workerWs.send(JSON.stringify({
type: 'execute_command',
execution_id: executionId,
command: schedule.command,
worker_id: workerId,
timeout: 300000 // 5 minute timeout for scheduled commands
}));
broadcast({ type: 'execution_started', execution_id: executionId, workflow_id: null });
}
}
// Update last_run and calculate next_run
const nextRun = calculateNextRun(schedule.schedule_type, schedule.schedule_value);
await pool.query(
'UPDATE scheduled_commands SET last_run = NOW(), next_run = ? WHERE id = ?',
[nextRun, schedule.id]
);
}
} catch (error) {
console.error('[Scheduler] Error processing scheduled commands:', error);
}
}
function calculateNextRun(scheduleType, scheduleValue) {
const now = new Date();
if (scheduleType === 'interval') {
// Interval in minutes
const minutes = parseInt(scheduleValue);
return new Date(now.getTime() + minutes * 60000);
} else if (scheduleType === 'daily') {
// Daily at HH:MM
const [hours, minutes] = scheduleValue.split(':').map(Number);
const next = new Date(now);
next.setHours(hours, minutes, 0, 0);
// If time has passed today, schedule for tomorrow
if (next <= now) {
next.setDate(next.getDate() + 1);
}
return next;
} else if (scheduleType === 'hourly') {
// Every N hours
const hours = parseInt(scheduleValue);
return new Date(now.getTime() + hours * 3600000);
}
return null;
}
// Run scheduler every minute
setInterval(processScheduledCommands, 60 * 1000);
// Initial run on startup
setTimeout(processScheduledCommands, 5000);
// WebSocket connections
const clients = new Set();
const workers = new Map(); // Map worker_id -> WebSocket connection
@@ -241,13 +134,12 @@ wss.on('connection', (ws) => {
if (message.type === 'command_result') {
// Handle command result from worker
const { execution_id, command_id, worker_id, success, stdout, stderr, duration, timestamp } = message;
const { execution_id, worker_id, success, stdout, stderr, duration, timestamp } = message;
// Add result to execution logs
await addExecutionLog(execution_id, {
step: 'command_execution',
action: 'command_result',
command_id: command_id, // Include command_id for workflow tracking
worker_id: worker_id,
success: success,
stdout: stdout,
@@ -256,14 +148,9 @@ wss.on('connection', (ws) => {
timestamp: timestamp || new Date().toISOString()
});
// For non-workflow executions, update status immediately
// For workflow executions, the workflow engine will update status
const [execution] = await pool.query('SELECT workflow_id FROM executions WHERE id = ?', [execution_id]);
if (execution.length > 0 && !execution[0].workflow_id) {
// Only update status for quick commands (no workflow_id)
const finalStatus = success ? 'completed' : 'failed';
await updateExecutionStatus(execution_id, finalStatus);
}
// Update execution status to completed or failed
const finalStatus = success ? 'completed' : 'failed';
await updateExecutionStatus(execution_id, finalStatus);
// Broadcast result to all connected clients
broadcast({
@@ -275,7 +162,7 @@ wss.on('connection', (ws) => {
stderr: stderr
});
console.log(`Command result received for execution ${execution_id}: ${success ? 'success' : 'failed'}`);
console.log(`Command result received for execution ${execution_id}: ${finalStatus}`);
}
if (message.type === 'workflow_result') {
@@ -386,45 +273,6 @@ function broadcast(data) {
});
}
// Helper function to add log entry to execution
async function addExecutionLog(executionId, logEntry) {
try {
const [execution] = await pool.query('SELECT logs FROM executions WHERE id = ?', [executionId]);
if (execution.length > 0) {
const logs = typeof execution[0].logs === 'string' ? JSON.parse(execution[0].logs) : execution[0].logs;
logs.push(logEntry);
await pool.query(
'UPDATE executions SET logs = ? WHERE id = ?',
[JSON.stringify(logs), executionId]
);
}
} catch (error) {
console.error(`[Workflow] Error adding execution log:`, error);
}
}
// Helper function to update execution status
async function updateExecutionStatus(executionId, status) {
try {
await pool.query(
'UPDATE executions SET status = ?, completed_at = NOW() WHERE id = ?',
[status, executionId]
);
broadcast({
type: 'execution_status',
execution_id: executionId,
status: status
});
console.log(`[Workflow] Execution ${executionId} status updated to: ${status}`);
} catch (error) {
console.error(`[Workflow] Error updating execution status:`, error);
}
}
// Authelia SSO Middleware
async function authenticateSSO(req, res, next) {
// Check for Authelia headers
@@ -481,237 +329,6 @@ async function authenticateSSO(req, res, next) {
next();
}
// Workflow Execution Engine
async function executeWorkflowSteps(executionId, workflowId, definition, username) {
try {
console.log(`[Workflow] Starting execution ${executionId} for workflow ${workflowId}`);
const steps = definition.steps || [];
let allStepsSucceeded = true;
for (let i = 0; i < steps.length; i++) {
const step = steps[i];
console.log(`[Workflow] Execution ${executionId} - Step ${i + 1}/${steps.length}: ${step.name}`);
// Add step start log
await addExecutionLog(executionId, {
step: i + 1,
step_name: step.name,
action: 'step_started',
timestamp: new Date().toISOString()
});
broadcast({
type: 'workflow_step_started',
execution_id: executionId,
step: i + 1,
step_name: step.name
});
if (step.type === 'execute') {
// Execute command step
const success = await executeCommandStep(executionId, step, i + 1);
if (!success) {
allStepsSucceeded = false;
break; // Stop workflow on failure
}
} else if (step.type === 'wait') {
// Wait step (delay in seconds)
const seconds = step.duration || 5;
await addExecutionLog(executionId, {
step: i + 1,
action: 'waiting',
duration: seconds,
timestamp: new Date().toISOString()
});
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
} else if (step.type === 'prompt') {
// Interactive prompt step (not fully implemented, would need user interaction)
await addExecutionLog(executionId, {
step: i + 1,
action: 'prompt_skipped',
message: 'Interactive prompts not yet supported',
timestamp: new Date().toISOString()
});
}
// Add step completion log
await addExecutionLog(executionId, {
step: i + 1,
step_name: step.name,
action: 'step_completed',
timestamp: new Date().toISOString()
});
broadcast({
type: 'workflow_step_completed',
execution_id: executionId,
step: i + 1,
step_name: step.name
});
}
// Mark execution as completed or failed
const finalStatus = allStepsSucceeded ? 'completed' : 'failed';
await updateExecutionStatus(executionId, finalStatus);
console.log(`[Workflow] Execution ${executionId} finished with status: ${finalStatus}`);
} catch (error) {
console.error(`[Workflow] Execution ${executionId} error:`, error);
await addExecutionLog(executionId, {
action: 'workflow_error',
error: error.message,
timestamp: new Date().toISOString()
});
await updateExecutionStatus(executionId, 'failed');
}
}
async function executeCommandStep(executionId, step, stepNumber) {
try {
const command = step.command;
const targets = step.targets || ['all'];
// Determine which workers to target
let targetWorkerIds = [];
if (targets.includes('all')) {
// Get all online workers
const [onlineWorkers] = await pool.query('SELECT id FROM workers WHERE status = ?', ['online']);
targetWorkerIds = onlineWorkers.map(w => w.id);
} else {
// Specific worker IDs or names
for (const target of targets) {
// Try to find by ID first, then by name
const [workerById] = await pool.query('SELECT id FROM workers WHERE id = ?', [target]);
if (workerById.length > 0) {
targetWorkerIds.push(workerById[0].id);
} else {
const [workerByName] = await pool.query('SELECT id FROM workers WHERE name = ?', [target]);
if (workerByName.length > 0) {
targetWorkerIds.push(workerByName[0].id);
}
}
}
}
if (targetWorkerIds.length === 0) {
await addExecutionLog(executionId, {
step: stepNumber,
action: 'no_workers',
message: 'No workers available for this step',
timestamp: new Date().toISOString()
});
return false;
}
// Execute command on each target worker and wait for results
const results = [];
for (const workerId of targetWorkerIds) {
const workerWs = workers.get(workerId);
if (!workerWs || workerWs.readyState !== WebSocket.OPEN) {
await addExecutionLog(executionId, {
step: stepNumber,
action: 'worker_offline',
worker_id: workerId,
timestamp: new Date().toISOString()
});
continue;
}
// Send command to worker
const commandId = generateUUID();
await addExecutionLog(executionId, {
step: stepNumber,
action: 'command_sent',
worker_id: workerId,
command: command,
command_id: commandId,
timestamp: new Date().toISOString()
});
workerWs.send(JSON.stringify({
type: 'execute_command',
execution_id: executionId,
command_id: commandId,
command: command,
worker_id: workerId,
timeout: 120000 // 2 minute timeout
}));
// Wait for command result (with timeout)
const result = await waitForCommandResult(executionId, commandId, 120000);
results.push(result);
if (!result.success) {
// Command failed, workflow should stop
return false;
}
}
// All commands succeeded
return results.every(r => r.success);
} catch (error) {
console.error(`[Workflow] Error executing command step:`, error);
await addExecutionLog(executionId, {
step: stepNumber,
action: 'step_error',
error: error.message,
timestamp: new Date().toISOString()
});
return false;
}
}
async function waitForCommandResult(executionId, commandId, timeout) {
return new Promise((resolve) => {
const startTime = Date.now();
const checkInterval = setInterval(async () => {
try {
// Check if we've received the command result in logs
const [execution] = await pool.query('SELECT logs FROM executions WHERE id = ?', [executionId]);
if (execution.length > 0) {
const logs = typeof execution[0].logs === 'string' ? JSON.parse(execution[0].logs) : execution[0].logs;
const resultLog = logs.find(log => log.command_id === commandId && log.action === 'command_result');
if (resultLog) {
clearInterval(checkInterval);
resolve({
success: resultLog.success,
stdout: resultLog.stdout,
stderr: resultLog.stderr
});
return;
}
}
// Check timeout
if (Date.now() - startTime > timeout) {
clearInterval(checkInterval);
resolve({
success: false,
error: 'Command timeout'
});
}
} catch (error) {
console.error('[Workflow] Error checking command result:', error);
clearInterval(checkInterval);
resolve({
success: false,
error: error.message
});
}
}, 500); // Check every 500ms
});
}
// Routes - All protected by SSO
app.get('/api/user', authenticateSSO, (req, res) => {
res.json(req.user);
@@ -730,24 +347,15 @@ app.post('/api/workflows', authenticateSSO, async (req, res) => {
try {
const { name, description, definition } = req.body;
const id = generateUUID();
console.log('[Workflow] Creating workflow:', name);
console.log('[Workflow] Definition:', JSON.stringify(definition, null, 2));
await pool.query(
'INSERT INTO workflows (id, name, description, definition, created_by) VALUES (?, ?, ?, ?, ?)',
[id, name, description, JSON.stringify(definition), req.user.username]
);
console.log('[Workflow] Successfully inserted workflow:', id);
res.json({ id, name, description, definition });
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 });
}
});
@@ -807,29 +415,13 @@ app.post('/api/executions', authenticateSSO, async (req, res) => {
try {
const { workflow_id } = req.body;
const id = generateUUID();
// Get workflow definition
const [workflows] = await pool.query('SELECT * FROM workflows WHERE id = ?', [workflow_id]);
if (workflows.length === 0) {
return res.status(404).json({ error: 'Workflow not found' });
}
const workflow = workflows[0];
const definition = typeof workflow.definition === 'string' ? JSON.parse(workflow.definition) : workflow.definition;
// Create execution record
await pool.query(
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
[id, workflow_id, 'running', req.user.username, JSON.stringify([])]
);
broadcast({ type: 'execution_started', execution_id: id, workflow_id });
// Start workflow execution asynchronously
executeWorkflowSteps(id, workflow_id, definition, req.user.username).catch(err => {
console.error(`[Workflow] Execution ${id} failed:`, err);
});
res.json({ id, workflow_id, status: 'running' });
} catch (error) {
res.status(500).json({ error: error.message });
@@ -872,104 +464,6 @@ app.delete('/api/executions/:id', authenticateSSO, async (req, res) => {
}
});
app.post('/api/executions/:id/abort', authenticateSSO, async (req, res) => {
try {
const executionId = req.params.id;
// Check if execution exists and is running
const [execution] = await pool.query('SELECT status FROM executions WHERE id = ?', [executionId]);
if (execution.length === 0) {
return res.status(404).json({ error: 'Execution not found' });
}
if (execution[0].status !== 'running') {
return res.status(400).json({ error: 'Execution is not running' });
}
// Add abort log entry
await addExecutionLog(executionId, {
action: 'execution_aborted',
aborted_by: req.user.username,
timestamp: new Date().toISOString()
});
// Update execution status to failed
await updateExecutionStatus(executionId, 'failed');
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 });
}
});
// Scheduled Commands API
app.get('/api/scheduled-commands', authenticateSSO, async (req, res) => {
try {
const [schedules] = await pool.query(
'SELECT * FROM scheduled_commands ORDER BY created_at DESC'
);
res.json(schedules);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.post('/api/scheduled-commands', authenticateSSO, async (req, res) => {
try {
const { name, command, worker_ids, schedule_type, schedule_value } = req.body;
if (!name || !command || !worker_ids || !schedule_type || !schedule_value) {
return res.status(400).json({ error: 'Missing required fields' });
}
const id = generateUUID();
const nextRun = calculateNextRun(schedule_type, schedule_value);
await pool.query(
`INSERT INTO scheduled_commands
(id, name, command, worker_ids, schedule_type, schedule_value, created_by, next_run)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
[id, name, command, JSON.stringify(worker_ids), schedule_type, schedule_value, req.user.username, nextRun]
);
res.json({ success: true, id });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.put('/api/scheduled-commands/:id/toggle', authenticateSSO, async (req, res) => {
try {
const { id } = req.params;
const [current] = await pool.query('SELECT enabled FROM scheduled_commands WHERE id = ?', [id]);
if (current.length === 0) {
return res.status(404).json({ error: 'Schedule not found' });
}
const newEnabled = !current[0].enabled;
await pool.query('UPDATE scheduled_commands SET enabled = ? WHERE id = ?', [newEnabled, id]);
res.json({ success: true, enabled: newEnabled });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
app.delete('/api/scheduled-commands/:id', authenticateSSO, async (req, res) => {
try {
const { id } = req.params;
await pool.query('DELETE FROM scheduled_commands WHERE id = ?', [id]);
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Health check (no auth required)
app.get('/health', async (req, res) => {
try {
@@ -1009,6 +503,299 @@ initDatabase().then(() => {
// ============================================
// WORKFLOW EXECUTION ENGINE
// ============================================
// Store active workflow executions in memory
const activeExecutions = new Map();
// Execute workflow step by step
async function executeWorkflow(workflowId, executionId, userId, targetWorkers = 'all') {
try {
// Get workflow definition
const [workflows] = await pool.query('SELECT * FROM workflows WHERE id = ?', [workflowId]);
if (workflows.length === 0) {
throw new Error('Workflow not found');
}
const workflow = workflows[0];
const definition = JSON.parse(workflow.definition);
// Initialize execution state
const executionState = {
id: executionId,
workflowId: workflowId,
currentStep: 0,
steps: definition.steps || [],
results: [],
status: 'running',
waitingForInput: false,
targetWorkers: targetWorkers,
userId: userId
};
activeExecutions.set(executionId, executionState);
// Start executing steps
await executeNextStep(executionId);
} catch (error) {
console.error('Workflow execution error:', error);
await updateExecutionStatus(executionId, 'failed', error.message);
}
}
// Execute the next step in a workflow
async function executeNextStep(executionId) {
const state = activeExecutions.get(executionId);
if (!state) return;
// Check if we've completed all steps
if (state.currentStep >= state.steps.length) {
await updateExecutionStatus(executionId, 'completed');
activeExecutions.delete(executionId);
return;
}
const step = state.steps[state.currentStep];
// Check if step has a condition
if (step.condition && !evaluateCondition(step.condition, state)) {
console.log(`[Workflow] Skipping step ${state.currentStep}: condition not met`);
state.currentStep++;
return executeNextStep(executionId);
}
console.log(`[Workflow] Executing step ${state.currentStep}: ${step.name}`);
try {
switch (step.type) {
case 'execute':
await executeCommandStep(executionId, step);
break;
case 'prompt':
await executePromptStep(executionId, step);
break;
case 'wait':
await executeWaitStep(executionId, step);
break;
default:
throw new Error(`Unknown step type: ${step.type}`);
}
} catch (error) {
await addExecutionLog(executionId, {
step: state.currentStep,
error: error.message,
timestamp: new Date().toISOString()
});
await updateExecutionStatus(executionId, 'failed', error.message);
activeExecutions.delete(executionId);
}
}
// Execute a command on workers
async function executeCommandStep(executionId, step) {
const state = activeExecutions.get(executionId);
// Get target workers
const [workers] = await pool.query(
'SELECT * FROM workers WHERE status = "online"'
);
if (workers.length === 0) {
throw new Error('No online workers available');
}
// Filter workers based on target
let targetWorkers = workers;
if (step.targets && step.targets[0] !== 'all') {
targetWorkers = workers.filter(w => step.targets.includes(w.name));
}
// Send command to workers via WebSocket
const commandMessage = {
type: 'execute_command',
execution_id: executionId,
step_index: state.currentStep,
command: step.command,
timeout: step.timeout || 300000
};
// Broadcast to target workers
clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify(commandMessage));
}
});
await addExecutionLog(executionId, {
step: state.currentStep,
action: 'command_sent',
command: step.command,
workers: targetWorkers.map(w => w.name),
timestamp: new Date().toISOString()
});
// For now, move to next step immediately
// In production, we'd wait for worker responses
state.currentStep++;
// Small delay to allow command to execute
setTimeout(() => executeNextStep(executionId), 1000);
}
// Execute a user prompt step
async function executePromptStep(executionId, step) {
const state = activeExecutions.get(executionId);
state.waitingForInput = true;
state.promptData = {
message: step.message,
options: step.options,
step: state.currentStep
};
await addExecutionLog(executionId, {
step: state.currentStep,
action: 'waiting_for_input',
prompt: step.message,
timestamp: new Date().toISOString()
});
// Notify frontend that input is needed
broadcast({
type: 'execution_prompt',
execution_id: executionId,
prompt: state.promptData
});
}
// Execute a wait/delay step
async function executeWaitStep(executionId, step) {
const state = activeExecutions.get(executionId);
const delay = step.duration || 1000;
await addExecutionLog(executionId, {
step: state.currentStep,
action: 'waiting',
duration: delay,
timestamp: new Date().toISOString()
});
state.currentStep++;
setTimeout(() => executeNextStep(executionId), delay);
}
// Handle user input for prompts
function handleUserInput(executionId, response) {
const state = activeExecutions.get(executionId);
if (!state || !state.waitingForInput) {
return false;
}
state.promptResponse = response;
state.waitingForInput = false;
state.currentStep++;
addExecutionLog(executionId, {
step: state.currentStep - 1,
action: 'user_response',
response: response,
timestamp: new Date().toISOString()
});
executeNextStep(executionId);
return true;
}
// Evaluate conditions
function evaluateCondition(condition, state) {
try {
// Simple condition evaluation
// In production, use a proper expression evaluator
const promptResponse = state.promptResponse;
return eval(condition);
} catch (error) {
console.error('Condition evaluation error:', error);
return false;
}
}
// Helper functions
async function updateExecutionStatus(executionId, status, error = null) {
const updates = { status };
if (status === 'completed' || status === 'failed') {
updates.completed_at = new Date();
}
if (error) {
// Add error to logs
await addExecutionLog(executionId, { error, timestamp: new Date().toISOString() });
}
await pool.query(
'UPDATE executions SET status = ?, completed_at = ? WHERE id = ?',
[status, updates.completed_at || null, executionId]
);
broadcast({
type: 'execution_status',
execution_id: executionId,
status: status
});
}
async function addExecutionLog(executionId, logEntry) {
const [rows] = await pool.query('SELECT logs FROM executions WHERE id = ?', [executionId]);
if (rows.length === 0) return;
const logs = JSON.parse(rows[0].logs || '[]');
logs.push(logEntry);
await pool.query('UPDATE executions SET logs = ? WHERE id = ?', [
JSON.stringify(logs),
executionId
]);
}
// ============================================
// API ROUTES - Add these to your server.js
// ============================================
// Start workflow execution
app.post('/api/executions', authenticateSSO, async (req, res) => {
try {
const { workflow_id, target_workers } = req.body;
const id = generateUUID();
await pool.query(
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
[id, workflow_id, 'running', req.user.username, JSON.stringify([])]
);
// Start execution
executeWorkflow(workflow_id, id, req.user.username, target_workers || 'all');
broadcast({ type: 'execution_started', execution_id: id, workflow_id });
res.json({ id, workflow_id, status: 'running' });
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Respond to workflow prompt
app.post('/api/executions/:id/respond', authenticateSSO, async (req, res) => {
try {
const { response } = req.body;
const success = handleUserInput(req.params.id, response);
if (success) {
res.json({ success: true });
} else {
res.status(400).json({ error: 'Execution not waiting for input' });
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get execution details with logs
app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
@@ -1017,12 +804,15 @@ app.get('/api/executions/:id', authenticateSSO, async (req, res) => {
if (rows.length === 0) {
return res.status(404).json({ error: 'Not found' });
}
const execution = rows[0];
const state = activeExecutions.get(req.params.id);
res.json({
...execution,
logs: JSON.parse(execution.logs || '[]')
logs: JSON.parse(execution.logs || '[]'),
waiting_for_input: state?.waitingForInput || false,
prompt: state?.promptData || null
});
} catch (error) {
res.status(500).json({ error: error.message });