Workflow & Command system
This commit is contained in:
@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PULSE</title>
|
||||
<title>PULSE - Workflow Orchestration</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body {
|
||||
@ -12,10 +12,7 @@
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
}
|
||||
.container {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.container { max-width: 1600px; margin: 0 auto; }
|
||||
.header {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
@ -26,27 +23,11 @@
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
.header-left h1 {
|
||||
color: #667eea;
|
||||
font-size: 2.5em;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.header-left p {
|
||||
color: #666;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.user-info {
|
||||
text-align: right;
|
||||
}
|
||||
.user-info .name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
.user-info .email {
|
||||
color: #666;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.header-left h1 { color: #667eea; font-size: 2.5em; margin-bottom: 5px; }
|
||||
.header-left p { color: #666; font-size: 1.1em; }
|
||||
.user-info { text-align: right; }
|
||||
.user-info .name { font-weight: 600; color: #333; font-size: 1.1em; }
|
||||
.user-info .email { color: #666; font-size: 0.9em; }
|
||||
.user-info .badge {
|
||||
display: inline-block;
|
||||
background: #667eea;
|
||||
@ -57,9 +38,34 @@
|
||||
margin-top: 5px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
.tabs {
|
||||
background: white;
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
.tab {
|
||||
padding: 12px 24px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
font-weight: 600;
|
||||
color: #666;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
.tab.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
.tab:hover { background: #f0f0f0; }
|
||||
.tab.active:hover { background: #5568d3; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
|
||||
gap: 20px;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
@ -69,11 +75,7 @@
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
|
||||
}
|
||||
.card h3 {
|
||||
color: #333;
|
||||
margin-bottom: 15px;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
.card h3 { color: #333; margin-bottom: 15px; font-size: 1.3em; }
|
||||
.status {
|
||||
display: inline-block;
|
||||
padding: 5px 15px;
|
||||
@ -87,6 +89,7 @@
|
||||
.status.running { background: #3b82f6; color: white; }
|
||||
.status.completed { background: #10b981; color: white; }
|
||||
.status.failed { background: #ef4444; color: white; }
|
||||
.status.waiting { background: #f59e0b; color: white; }
|
||||
button {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
@ -105,54 +108,82 @@
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
button.danger {
|
||||
background: #ef4444;
|
||||
button.danger { background: #ef4444; }
|
||||
button.danger:hover { background: #dc2626; }
|
||||
button.small {
|
||||
padding: 6px 12px;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
button.danger:hover {
|
||||
background: #dc2626;
|
||||
}
|
||||
.workflow-item, .execution-item, .worker-item {
|
||||
.worker-item, .execution-item, .workflow-item {
|
||||
padding: 15px;
|
||||
border: 1px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
margin-bottom: 10px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
.workflow-item:hover, .execution-item:hover, .worker-item:hover {
|
||||
.worker-item:hover, .execution-item:hover, .workflow-item:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
.workflow-name {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
font-size: 1.1em;
|
||||
margin-bottom: 5px;
|
||||
.workflow-name { font-weight: 600; color: #333; font-size: 1.1em; margin-bottom: 5px; }
|
||||
.workflow-desc { color: #666; font-size: 0.9em; margin-bottom: 10px; }
|
||||
.loading { text-align: center; padding: 20px; color: #666; }
|
||||
.empty { text-align: center; padding: 30px; color: #999; }
|
||||
.timestamp { font-size: 0.85em; color: #999; }
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
z-index: 1000;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.workflow-desc {
|
||||
color: #666;
|
||||
.modal.show { display: flex; }
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
max-width: 600px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.modal-content h2 { margin-bottom: 20px; color: #333; }
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
margin-bottom: 15px;
|
||||
border: 2px solid #e0e0e0;
|
||||
border-radius: 5px;
|
||||
font-size: 1em;
|
||||
font-family: inherit;
|
||||
}
|
||||
input:focus, textarea:focus, select:focus {
|
||||
outline: none;
|
||||
border-color: #667eea;
|
||||
}
|
||||
textarea { min-height: 100px; font-family: monospace; }
|
||||
.tab-content { display: none; }
|
||||
.tab-content.active { display: block; }
|
||||
.log-entry {
|
||||
padding: 10px;
|
||||
background: #f9f9f9;
|
||||
border-left: 3px solid #667eea;
|
||||
margin-bottom: 10px;
|
||||
font-family: monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 20px;
|
||||
color: #666;
|
||||
}
|
||||
.empty {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #999;
|
||||
}
|
||||
.timestamp {
|
||||
font-size: 0.85em;
|
||||
color: #999;
|
||||
}
|
||||
.error {
|
||||
background: #fee2e2;
|
||||
border: 2px solid #ef4444;
|
||||
color: #991b1b;
|
||||
.prompt-box {
|
||||
background: #fef3c7;
|
||||
border: 2px solid #f59e0b;
|
||||
padding: 20px;
|
||||
border-radius: 10px;
|
||||
margin-bottom: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
.prompt-box h3 { color: #92400e; margin-bottom: 15px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@ -167,54 +198,117 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="authError" class="error" style="display: none;">
|
||||
<h3>⚠️ Authentication Required</h3>
|
||||
<p>Please access PULSE through <strong>https://pulse.lotusguild.org</strong> to authenticate via Authelia.</p>
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('dashboard')">📊 Dashboard</button>
|
||||
<button class="tab" onclick="switchTab('workers')">👥 Workers</button>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div id="dashboard" class="tab-content active">
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>👥 Workers</h3>
|
||||
<div id="workerStatus">
|
||||
<div class="loading">Loading...</div>
|
||||
<h3>👥 Active Workers</h3>
|
||||
<div id="dashWorkers"><div class="loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>📋 Workflows</h3>
|
||||
<div id="workflowList">
|
||||
<div class="loading">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>🚀 Recent Executions</h3>
|
||||
<div id="executionList">
|
||||
<div class="loading">Loading...</div>
|
||||
<div id="dashExecutions"><div class="loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workers Tab -->
|
||||
<div id="workers" class="tab-content">
|
||||
<div class="card">
|
||||
<h3>⚡ Quick Actions</h3>
|
||||
<button onclick="refreshData()">🔄 Refresh Status</button>
|
||||
<button onclick="alert('Workflow creation UI coming soon!')">➕ Create Workflow</button>
|
||||
<h3>Worker Management</h3>
|
||||
<button onclick="refreshData()">🔄 Refresh</button>
|
||||
<div id="workerList"><div class="loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Workflows Tab -->
|
||||
<div id="workflows" class="tab-content">
|
||||
<div class="card">
|
||||
<h3>Workflow Management</h3>
|
||||
<button onclick="showCreateWorkflow()">➕ Create Workflow</button>
|
||||
<button onclick="refreshData()">🔄 Refresh</button>
|
||||
<div id="workflowList"><div class="loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Executions Tab -->
|
||||
<div id="executions" class="tab-content">
|
||||
<div class="card">
|
||||
<h3>Execution History</h3>
|
||||
<button onclick="refreshData()">🔄 Refresh</button>
|
||||
<div id="executionList"><div class="loading">Loading...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Command Tab -->
|
||||
<div id="quickcommand" class="tab-content">
|
||||
<div class="card">
|
||||
<h3>⚡ Quick Command Execution</h3>
|
||||
<p style="color: #666; margin-bottom: 20px;">Execute a command on selected workers instantly</p>
|
||||
|
||||
<label style="display: block; margin-bottom: 10px; font-weight: 600;">Select Worker:</label>
|
||||
<select id="quickWorkerSelect">
|
||||
<option value="">Loading workers...</option>
|
||||
</select>
|
||||
|
||||
<label style="display: block; margin-bottom: 10px; margin-top: 20px; font-weight: 600;">Command:</label>
|
||||
<textarea id="quickCommand" placeholder="Enter command to execute (e.g., 'uptime' or 'df -h')"></textarea>
|
||||
|
||||
<button onclick="executeQuickCommand()">▶️ Execute Command</button>
|
||||
|
||||
<div id="quickCommandResult" style="margin-top: 20px;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Workflow Modal -->
|
||||
<div id="createWorkflowModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Create New Workflow</h2>
|
||||
<input type="text" id="workflowName" placeholder="Workflow Name">
|
||||
<textarea id="workflowDescription" placeholder="Description"></textarea>
|
||||
<label>Workflow Definition (JSON):</label>
|
||||
<textarea id="workflowDefinition" style="min-height: 200px;">{
|
||||
"steps": [
|
||||
{
|
||||
"name": "Example Step",
|
||||
"type": "execute",
|
||||
"targets": ["all"],
|
||||
"command": "echo 'Hello from PULSE'"
|
||||
}
|
||||
]
|
||||
}</textarea>
|
||||
<button onclick="createWorkflow()">Create</button>
|
||||
<button onclick="closeModal('createWorkflowModal')">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- View Execution Modal -->
|
||||
<div id="viewExecutionModal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Execution Details</h2>
|
||||
<div id="executionDetails"></div>
|
||||
<button onclick="closeModal('viewExecutionModal')">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let ws = null;
|
||||
let workers = [];
|
||||
|
||||
async function loadUser() {
|
||||
try {
|
||||
const response = await fetch('/api/user');
|
||||
if (!response.ok) {
|
||||
document.getElementById('authError').style.display = 'block';
|
||||
document.getElementById('userInfo').innerHTML =
|
||||
'<div style="color: #ef4444;">⚠️ Not authenticated</div>';
|
||||
return false;
|
||||
}
|
||||
if (!response.ok) return false;
|
||||
|
||||
currentUser = await response.json();
|
||||
document.getElementById('userInfo').innerHTML = `
|
||||
@ -227,9 +321,6 @@
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error loading user:', error);
|
||||
document.getElementById('authError').style.display = 'block';
|
||||
document.getElementById('userInfo').innerHTML =
|
||||
'<div style="color: #ef4444;">Error loading user</div>';
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -237,25 +328,59 @@
|
||||
async function loadWorkers() {
|
||||
try {
|
||||
const response = await fetch('/api/workers');
|
||||
const workers = await response.json();
|
||||
workers = await response.json();
|
||||
|
||||
if (workers.length === 0) {
|
||||
document.getElementById('workerStatus').innerHTML =
|
||||
'<div class="empty">No workers connected</div>';
|
||||
return;
|
||||
// Update worker select in quick command
|
||||
const select = document.getElementById('quickWorkerSelect');
|
||||
if (select) {
|
||||
select.innerHTML = workers.map(w =>
|
||||
`<option value="${w.id}">${w.name} (${w.status})</option>`
|
||||
).join('');
|
||||
}
|
||||
|
||||
document.getElementById('workerStatus').innerHTML = workers.map(w => `
|
||||
// Dashboard view
|
||||
const dashHtml = workers.length === 0 ?
|
||||
'<div class="empty">No workers connected</div>' :
|
||||
workers.map(w => `
|
||||
<div class="worker-item">
|
||||
<span class="status ${w.status}">${w.status}</span>
|
||||
<strong>${w.name}</strong>
|
||||
<div class="timestamp">Last seen: ${new Date(w.last_heartbeat).toLocaleString()}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('dashWorkers').innerHTML = dashHtml;
|
||||
|
||||
// Full worker list
|
||||
const fullHtml = workers.length === 0 ?
|
||||
'<div class="empty">No workers connected</div>' :
|
||||
workers.map(w => {
|
||||
const meta = typeof w.metadata === 'string' ? JSON.parse(w.metadata) : w.metadata;
|
||||
return `
|
||||
<div class="worker-item">
|
||||
<div style="display: flex; justify-content: space-between; align-items: start;">
|
||||
<div>
|
||||
<span class="status ${w.status}">${w.status}</span>
|
||||
<strong>${w.name}</strong>
|
||||
<div class="timestamp">ID: ${w.id}</div>
|
||||
<div class="timestamp">Last heartbeat: ${new Date(w.last_heartbeat).toLocaleString()}</div>
|
||||
${meta ? `
|
||||
<div style="margin-top: 10px; font-size: 0.85em; color: #666;">
|
||||
<div>CPUs: ${meta.cpus || 'N/A'} | RAM: ${meta.totalMem ? (meta.totalMem / 1024 / 1024 / 1024).toFixed(1) + 'GB' : 'N/A'}</div>
|
||||
<div>Platform: ${meta.platform || 'N/A'} | Arch: ${meta.arch || 'N/A'}</div>
|
||||
<div>Active Tasks: ${meta.activeTasks || 0}/${meta.maxConcurrentTasks || 0}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
${currentUser && currentUser.isAdmin ? `
|
||||
<button class="danger small" onclick="deleteWorker('${w.id}', '${w.name}')">🗑️ Delete</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
document.getElementById('workerList').innerHTML = fullHtml;
|
||||
} catch (error) {
|
||||
console.error('Error loading workers:', error);
|
||||
document.getElementById('workerStatus').innerHTML =
|
||||
'<div class="empty">Error loading workers</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@ -264,28 +389,24 @@
|
||||
const response = await fetch('/api/workflows');
|
||||
const workflows = await response.json();
|
||||
|
||||
if (workflows.length === 0) {
|
||||
document.getElementById('workflowList').innerHTML =
|
||||
'<div class="empty">No workflows defined yet</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
document.getElementById('workflowList').innerHTML = workflows.map(w => `
|
||||
const html = workflows.length === 0 ?
|
||||
'<div class="empty">No workflows defined yet</div>' :
|
||||
workflows.map(w => `
|
||||
<div class="workflow-item">
|
||||
<div class="workflow-name">${w.name}</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 style="margin-top: 10px;">
|
||||
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
|
||||
<button onclick="executeWorkflow('${w.id}', '${w.name}')">▶️ Execute</button>
|
||||
${currentUser && currentUser.isAdmin ?
|
||||
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('workflowList').innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error('Error loading workflows:', error);
|
||||
document.getElementById('workflowList').innerHTML =
|
||||
'<div class="empty">Error loading workflows</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@ -294,30 +415,37 @@
|
||||
const response = await fetch('/api/executions');
|
||||
const executions = await response.json();
|
||||
|
||||
if (executions.length === 0) {
|
||||
document.getElementById('executionList').innerHTML =
|
||||
'<div class="empty">No executions yet</div>';
|
||||
return;
|
||||
}
|
||||
const dashHtml = executions.length === 0 ?
|
||||
'<div class="empty">No executions yet</div>' :
|
||||
executions.slice(0, 5).map(e => `
|
||||
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
||||
<span class="status ${e.status}">${e.status}</span>
|
||||
<strong>${e.workflow_name || 'Unknown Workflow'}</strong>
|
||||
<div class="timestamp">by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
document.getElementById('dashExecutions').innerHTML = dashHtml;
|
||||
|
||||
document.getElementById('executionList').innerHTML = executions.slice(0, 10).map(e => `
|
||||
<div class="execution-item">
|
||||
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 || 'Unknown Workflow'}</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;
|
||||
} catch (error) {
|
||||
console.error('Error loading executions:', error);
|
||||
document.getElementById('executionList').innerHTML =
|
||||
'<div class="empty">Error loading executions</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function executeWorkflow(workflowId) {
|
||||
if (!confirm('Execute this workflow?')) return;
|
||||
async function executeWorkflow(workflowId, name) {
|
||||
if (!confirm(`Execute workflow: ${name}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/executions', {
|
||||
@ -327,8 +455,10 @@
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
alert('Workflow execution started!');
|
||||
loadExecutions();
|
||||
switchTab('executions');
|
||||
refreshData();
|
||||
} else {
|
||||
alert('Failed to start workflow');
|
||||
}
|
||||
@ -338,8 +468,91 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function viewExecution(executionId) {
|
||||
try {
|
||||
const response = await fetch(`/api/executions/${executionId}`);
|
||||
const execution = await response.json();
|
||||
|
||||
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 by:</strong> ${execution.started_by}</div>
|
||||
`;
|
||||
|
||||
if (execution.waiting_for_input && execution.prompt) {
|
||||
html += `
|
||||
<div class="prompt-box">
|
||||
<h3>⏳ Waiting for Input</h3>
|
||||
<p>${execution.prompt.message}</p>
|
||||
<div style="margin-top: 15px;">
|
||||
${execution.prompt.options.map(opt =>
|
||||
`<button onclick="respondToPrompt('${executionId}', '${opt}')">${opt}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (execution.logs && execution.logs.length > 0) {
|
||||
html += '<h3 style="margin-top: 20px; margin-bottom: 10px;">Execution Logs:</h3>';
|
||||
execution.logs.forEach(log => {
|
||||
html += `<div class="log-entry">${JSON.stringify(log, null, 2)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
document.getElementById('executionDetails').innerHTML = html;
|
||||
document.getElementById('viewExecutionModal').classList.add('show');
|
||||
} catch (error) {
|
||||
console.error('Error viewing execution:', error);
|
||||
alert('Error loading execution details');
|
||||
}
|
||||
}
|
||||
|
||||
async function respondToPrompt(executionId, response) {
|
||||
try {
|
||||
const res = await fetch(`/api/executions/${executionId}/respond`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ response })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
closeModal('viewExecutionModal');
|
||||
alert('Response submitted!');
|
||||
refreshData();
|
||||
} else {
|
||||
alert('Failed to submit response');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error responding to prompt:', error);
|
||||
alert('Error submitting response');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorker(workerId, name) {
|
||||
if (!confirm(`Delete worker: ${name}?`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workers/${workerId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Worker deleted');
|
||||
refreshData();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error || 'Failed to delete worker');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting worker:', error);
|
||||
alert('Error deleting worker');
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkflow(workflowId, name) {
|
||||
if (!confirm(`Delete workflow "${name}"? This cannot be undone.`)) return;
|
||||
if (!confirm(`Delete workflow: ${name}? This cannot be undone.`)) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workflows/${workflowId}`, {
|
||||
@ -348,7 +561,7 @@
|
||||
|
||||
if (response.ok) {
|
||||
alert('Workflow deleted');
|
||||
loadWorkflows();
|
||||
refreshData();
|
||||
} else {
|
||||
const data = await response.json();
|
||||
alert(data.error || 'Failed to delete workflow');
|
||||
@ -359,6 +572,95 @@
|
||||
}
|
||||
}
|
||||
|
||||
function showCreateWorkflow() {
|
||||
document.getElementById('createWorkflowModal').classList.add('show');
|
||||
}
|
||||
|
||||
async function createWorkflow() {
|
||||
const name = document.getElementById('workflowName').value;
|
||||
const description = document.getElementById('workflowDescription').value;
|
||||
const definitionText = document.getElementById('workflowDefinition').value;
|
||||
|
||||
if (!name || !definitionText) {
|
||||
alert('Name and definition are required');
|
||||
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');
|
||||
refreshData();
|
||||
} else {
|
||||
alert('Failed to create workflow');
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Invalid JSON definition: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeQuickCommand() {
|
||||
const workerId = document.getElementById('quickWorkerSelect').value;
|
||||
const command = document.getElementById('quickCommand').value;
|
||||
|
||||
if (!workerId || !command) {
|
||||
alert('Please select a worker and enter a command');
|
||||
return;
|
||||
}
|
||||
|
||||
const resultDiv = document.getElementById('quickCommandResult');
|
||||
resultDiv.innerHTML = '<div class="loading">Executing command...</div>';
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/workers/${workerId}/command`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ command })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
resultDiv.innerHTML = `
|
||||
<div style="background: #f0fdf4; border: 2px solid #86efac; padding: 15px; border-radius: 5px;">
|
||||
<strong style="color: #166534;">✓ Command sent successfully!</strong>
|
||||
<div style="margin-top: 10px; font-family: monospace; font-size: 0.9em;">
|
||||
Execution ID: ${data.execution_id}
|
||||
</div>
|
||||
<div style="margin-top: 10px; color: #166534;">
|
||||
Check the Executions tab to see the results
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
resultDiv.innerHTML = '<div style="color: #ef4444;">Failed to execute command</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing command:', error);
|
||||
resultDiv.innerHTML = '<div style="color: #ef4444;">Error: ' + error.message + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
document.getElementById(modalId).classList.remove('show');
|
||||
}
|
||||
|
||||
function switchTab(tabName) {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
event.target.classList.add('active');
|
||||
document.getElementById(tabName).classList.add('active');
|
||||
}
|
||||
|
||||
function refreshData() {
|
||||
loadWorkers();
|
||||
loadWorkflows();
|
||||
@ -386,7 +688,7 @@
|
||||
if (success) {
|
||||
refreshData();
|
||||
connectWebSocket();
|
||||
setInterval(refreshData, 30000); // Refresh every 30 seconds
|
||||
setInterval(refreshData, 30000);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
430
server.js
430
server.js
@ -326,3 +326,433 @@ initDatabase().then(() => {
|
||||
console.error('Failed to start server:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
// ============================================
|
||||
// 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) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM executions WHERE id = ?', [req.params.id]);
|
||||
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 || '[]'),
|
||||
waiting_for_input: state?.waitingForInput || false,
|
||||
prompt: state?.promptData || null
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete worker (admin only)
|
||||
app.delete('/api/workers/:id', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
if (!req.user.isAdmin) {
|
||||
return res.status(403).json({ error: 'Admin access required' });
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM workers WHERE id = ?', [req.params.id]);
|
||||
res.json({ success: true });
|
||||
broadcast({ type: 'worker_deleted', worker_id: req.params.id });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Send direct command to specific worker (for testing)
|
||||
app.post('/api/workers/:id/command', authenticateSSO, async (req, res) => {
|
||||
try {
|
||||
const { command } = req.body;
|
||||
const executionId = generateUUID();
|
||||
|
||||
// Send command via WebSocket
|
||||
const commandMessage = {
|
||||
type: 'execute_command',
|
||||
execution_id: executionId,
|
||||
command: command,
|
||||
worker_id: req.params.id,
|
||||
timeout: 60000
|
||||
};
|
||||
|
||||
clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(JSON.stringify(commandMessage));
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ success: true, execution_id: executionId });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// EXAMPLE WORKFLOW DEFINITIONS
|
||||
// ============================================
|
||||
|
||||
// Example 1: Simple command execution
|
||||
const simpleWorkflow = {
|
||||
name: "Update System Packages",
|
||||
description: "Update all packages on target servers",
|
||||
steps: [
|
||||
{
|
||||
name: "Update package list",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "apt update"
|
||||
},
|
||||
{
|
||||
name: "User Approval",
|
||||
type: "prompt",
|
||||
message: "Packages updated. Proceed with upgrade?",
|
||||
options: ["Yes", "No"]
|
||||
},
|
||||
{
|
||||
name: "Upgrade packages",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "apt upgrade -y",
|
||||
condition: "promptResponse === 'Yes'"
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Example 2: Complex workflow with conditions
|
||||
const backupWorkflow = {
|
||||
name: "Backup and Verify",
|
||||
description: "Create backup and verify integrity",
|
||||
steps: [
|
||||
{
|
||||
name: "Create backup",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "tar -czf /tmp/backup-$(date +%Y%m%d).tar.gz /opt/pulse-worker"
|
||||
},
|
||||
{
|
||||
name: "Wait for backup",
|
||||
type: "wait",
|
||||
duration: 5000
|
||||
},
|
||||
{
|
||||
name: "Verify backup",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "tar -tzf /tmp/backup-*.tar.gz > /dev/null && echo 'Backup OK' || echo 'Backup FAILED'"
|
||||
},
|
||||
{
|
||||
name: "Cleanup decision",
|
||||
type: "prompt",
|
||||
message: "Backup complete. Delete old backups?",
|
||||
options: ["Yes", "No", "Cancel"]
|
||||
},
|
||||
{
|
||||
name: "Cleanup old backups",
|
||||
type: "execute",
|
||||
targets: ["all"],
|
||||
command: "find /tmp -name 'backup-*.tar.gz' -mtime +7 -delete",
|
||||
condition: "promptResponse === 'Yes'"
|
||||
}
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user