Compare commits
4 Commits
753a906f25
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bb247628b0 | |||
| eafe0bbf5a | |||
| 63c9626ecf | |||
| dcca2b9e50 |
32
.gitignore
vendored
Normal file
32
.gitignore
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# Node modules
|
||||||
|
node_modules/
|
||||||
|
|
||||||
|
# Environment variables (NEVER commit these!)
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# Database files
|
||||||
|
*.db
|
||||||
|
*.sqlite
|
||||||
|
*.sqlite3
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Backups
|
||||||
|
*.backup
|
||||||
|
*.bak
|
||||||
|
|
||||||
|
# Editor files
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
1145
package-lock.json
generated
Normal file
1145
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
package.json
Normal file
23
package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "pulse-server",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"description": "",
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^3.0.3",
|
||||||
|
"body-parser": "^2.2.1",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^17.2.3",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"js-yaml": "^4.1.1",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"mysql2": "^3.15.3",
|
||||||
|
"ws": "^8.18.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
696
public/index.html
Normal file
696
public/index.html
Normal file
@ -0,0 +1,696 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PULSE - Workflow Orchestration</title>
|
||||||
|
<style>
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.container { max-width: 1600px; margin: 0 auto; }
|
||||||
|
.header {
|
||||||
|
background: white;
|
||||||
|
padding: 30px;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
|
||||||
|
margin-bottom: 30px;
|
||||||
|
display: flex;
|
||||||
|
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; }
|
||||||
|
.user-info .badge {
|
||||||
|
display: inline-block;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 0.8em;
|
||||||
|
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(400px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: white;
|
||||||
|
padding: 25px;
|
||||||
|
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; }
|
||||||
|
.status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 5px 15px;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.9em;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
.status.online { background: #10b981; color: white; }
|
||||||
|
.status.offline { background: #ef4444; color: white; }
|
||||||
|
.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;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s;
|
||||||
|
margin-right: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
button:hover {
|
||||||
|
background: #5568d3;
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.4);
|
||||||
|
}
|
||||||
|
button.danger { background: #ef4444; }
|
||||||
|
button.danger:hover { background: #dc2626; }
|
||||||
|
button.small {
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
.worker-item, .execution-item, .workflow-item {
|
||||||
|
padding: 15px;
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
border-radius: 5px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
background: #f9f9f9;
|
||||||
|
}
|
||||||
|
.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-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;
|
||||||
|
}
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
.prompt-box {
|
||||||
|
background: #fef3c7;
|
||||||
|
border: 2px solid #f59e0b;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.prompt-box h3 { color: #92400e; margin-bottom: 15px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<div class="header-left">
|
||||||
|
<h1>⚡ PULSE</h1>
|
||||||
|
<p>Pipelined Unified Logic & Server Engine</p>
|
||||||
|
</div>
|
||||||
|
<div class="user-info" id="userInfo">
|
||||||
|
<div class="loading">Loading user...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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>👥 Active Workers</h3>
|
||||||
|
<div id="dashWorkers"><div class="loading">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>🚀 Recent Executions</h3>
|
||||||
|
<div id="dashExecutions"><div class="loading">Loading...</div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Workers Tab -->
|
||||||
|
<div id="workers" class="tab-content">
|
||||||
|
<div class="card">
|
||||||
|
<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) return false;
|
||||||
|
|
||||||
|
currentUser = await response.json();
|
||||||
|
document.getElementById('userInfo').innerHTML = `
|
||||||
|
<div class="name">${currentUser.name}</div>
|
||||||
|
<div class="email">${currentUser.email}</div>
|
||||||
|
<div>${currentUser.groups.map(g =>
|
||||||
|
`<span class="badge">${g}</span>`
|
||||||
|
).join('')}</div>
|
||||||
|
`;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading user:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkers() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/workers');
|
||||||
|
workers = await response.json();
|
||||||
|
|
||||||
|
// 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('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadWorkflows() {
|
||||||
|
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 => `
|
||||||
|
<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}', '${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExecutions() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/executions');
|
||||||
|
const executions = await response.json();
|
||||||
|
|
||||||
|
const dashHtml = executions.length === 0 ?
|
||||||
|
'<div class="empty">No executions yet</div>' :
|
||||||
|
executions.slice(0, 5).map(e => `
|
||||||
|
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
||||||
|
<span class="status ${e.status}">${e.status}</span>
|
||||||
|
<strong>${e.workflow_name || '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;
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeWorkflow(workflowId, name) {
|
||||||
|
if (!confirm(`Execute workflow: ${name}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/executions', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ workflow_id: workflowId })
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
alert('Workflow execution started!');
|
||||||
|
switchTab('executions');
|
||||||
|
refreshData();
|
||||||
|
} else {
|
||||||
|
alert('Failed to start workflow');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error executing workflow:', error);
|
||||||
|
alert('Error executing workflow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/workflows/${workflowId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Workflow deleted');
|
||||||
|
refreshData();
|
||||||
|
} else {
|
||||||
|
const data = await response.json();
|
||||||
|
alert(data.error || 'Failed to delete workflow');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting workflow:', error);
|
||||||
|
alert('Error deleting workflow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
loadExecutions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectWebSocket() {
|
||||||
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
ws = new WebSocket(`${protocol}//${window.location.host}`);
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
console.log('WebSocket message:', data);
|
||||||
|
refreshData();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
console.log('WebSocket closed, reconnecting...');
|
||||||
|
setTimeout(connectWebSocket, 5000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
loadUser().then((success) => {
|
||||||
|
if (success) {
|
||||||
|
refreshData();
|
||||||
|
connectWebSocket();
|
||||||
|
setInterval(refreshData, 30000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
758
server.js
Normal file
758
server.js
Normal file
@ -0,0 +1,758 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const http = require('http');
|
||||||
|
const WebSocket = require('ws');
|
||||||
|
const mysql = require('mysql2/promise');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const server = http.createServer(app);
|
||||||
|
const wss = new WebSocket.Server({ server });
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static('public'));
|
||||||
|
|
||||||
|
// UUID generator
|
||||||
|
function generateUUID() {
|
||||||
|
return crypto.randomUUID();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database pool
|
||||||
|
const pool = mysql.createPool({
|
||||||
|
host: process.env.DB_HOST,
|
||||||
|
port: process.env.DB_PORT || 3306,
|
||||||
|
user: process.env.DB_USER,
|
||||||
|
password: process.env.DB_PASSWORD,
|
||||||
|
database: process.env.DB_NAME,
|
||||||
|
waitForConnections: true,
|
||||||
|
connectionLimit: 10,
|
||||||
|
queueLimit: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize database tables
|
||||||
|
async function initDatabase() {
|
||||||
|
const connection = await pool.getConnection();
|
||||||
|
try {
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
username VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
display_name VARCHAR(255),
|
||||||
|
email VARCHAR(255),
|
||||||
|
groups TEXT,
|
||||||
|
last_login TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workers (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
last_heartbeat TIMESTAMP NULL,
|
||||||
|
api_key VARCHAR(255),
|
||||||
|
metadata JSON,
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_heartbeat (last_heartbeat)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS workflows (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
definition JSON NOT NULL,
|
||||||
|
created_by VARCHAR(255),
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
INDEX idx_name (name)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
await connection.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS executions (
|
||||||
|
id VARCHAR(36) PRIMARY KEY,
|
||||||
|
workflow_id VARCHAR(36) NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL,
|
||||||
|
started_by VARCHAR(255),
|
||||||
|
started_at TIMESTAMP NULL,
|
||||||
|
completed_at TIMESTAMP NULL,
|
||||||
|
logs JSON,
|
||||||
|
FOREIGN KEY (workflow_id) REFERENCES workflows(id) ON DELETE CASCADE,
|
||||||
|
INDEX idx_workflow (workflow_id),
|
||||||
|
INDEX idx_status (status),
|
||||||
|
INDEX idx_started (started_at)
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('Database tables initialized successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Database initialization error:', error);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
connection.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket connections
|
||||||
|
const clients = new Set();
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
clients.add(ws);
|
||||||
|
ws.on('close', () => clients.delete(ws));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast to all connected clients
|
||||||
|
function broadcast(data) {
|
||||||
|
clients.forEach(client => {
|
||||||
|
if (client.readyState === WebSocket.OPEN) {
|
||||||
|
client.send(JSON.stringify(data));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authelia SSO Middleware
|
||||||
|
async function authenticateSSO(req, res, next) {
|
||||||
|
// Check for Authelia headers
|
||||||
|
const remoteUser = req.headers['remote-user'];
|
||||||
|
const remoteName = req.headers['remote-name'];
|
||||||
|
const remoteEmail = req.headers['remote-email'];
|
||||||
|
const remoteGroups = req.headers['remote-groups'];
|
||||||
|
|
||||||
|
if (!remoteUser) {
|
||||||
|
return res.status(401).json({
|
||||||
|
error: 'Not authenticated',
|
||||||
|
message: 'Please access this service through auth.lotusguild.org'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is in allowed groups (admin or employee)
|
||||||
|
const groups = remoteGroups ? remoteGroups.split(',').map(g => g.trim()) : [];
|
||||||
|
const allowedGroups = ['admin', 'employee'];
|
||||||
|
const hasAccess = groups.some(g => allowedGroups.includes(g));
|
||||||
|
|
||||||
|
if (!hasAccess) {
|
||||||
|
return res.status(403).json({
|
||||||
|
error: 'Access denied',
|
||||||
|
message: 'You must be in admin or employee group'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store/update user in database
|
||||||
|
try {
|
||||||
|
const userId = generateUUID();
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO users (id, username, display_name, email, groups, last_login)
|
||||||
|
VALUES (?, ?, ?, ?, ?, NOW())
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
display_name=VALUES(display_name),
|
||||||
|
email=VALUES(email),
|
||||||
|
groups=VALUES(groups),
|
||||||
|
last_login=NOW()`,
|
||||||
|
[userId, remoteUser, remoteName, remoteEmail, remoteGroups]
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach user info to request
|
||||||
|
req.user = {
|
||||||
|
username: remoteUser,
|
||||||
|
name: remoteName || remoteUser,
|
||||||
|
email: remoteEmail || '',
|
||||||
|
groups: groups,
|
||||||
|
isAdmin: groups.includes('admin')
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Routes - All protected by SSO
|
||||||
|
app.get('/api/user', authenticateSSO, (req, res) => {
|
||||||
|
res.json(req.user);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/workflows', authenticateSSO, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM workflows ORDER BY created_at DESC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/workflows', authenticateSSO, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { name, description, definition } = req.body;
|
||||||
|
const id = generateUUID();
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO workflows (id, name, description, definition, created_by) VALUES (?, ?, ?, ?, ?)',
|
||||||
|
[id, name, description, JSON.stringify(definition), req.user.username]
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ id, name, description, definition });
|
||||||
|
broadcast({ type: 'workflow_created', workflow_id: id });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/workflows/:id', authenticateSSO, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Only admins can delete workflows
|
||||||
|
if (!req.user.isAdmin) {
|
||||||
|
return res.status(403).json({ error: 'Admin access required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query('DELETE FROM workflows WHERE id = ?', [req.params.id]);
|
||||||
|
res.json({ success: true });
|
||||||
|
broadcast({ type: 'workflow_deleted', workflow_id: req.params.id });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/workers', authenticateSSO, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query('SELECT * FROM workers ORDER BY name');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/workers/heartbeat', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { worker_id, name, metadata } = req.body;
|
||||||
|
const apiKey = req.headers['x-api-key'];
|
||||||
|
|
||||||
|
// Verify API key
|
||||||
|
if (apiKey !== process.env.WORKER_API_KEY) {
|
||||||
|
return res.status(401).json({ error: 'Invalid API key' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
`INSERT INTO workers (id, name, status, last_heartbeat, api_key, metadata)
|
||||||
|
VALUES (?, ?, 'online', NOW(), ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
status='online',
|
||||||
|
last_heartbeat=NOW(),
|
||||||
|
metadata=VALUES(metadata)`,
|
||||||
|
[worker_id, name, apiKey, JSON.stringify(metadata)]
|
||||||
|
);
|
||||||
|
|
||||||
|
broadcast({ type: 'worker_update', worker_id, status: 'online' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/executions', authenticateSSO, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { workflow_id } = 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([])]
|
||||||
|
);
|
||||||
|
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/executions', authenticateSSO, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await pool.query(
|
||||||
|
'SELECT e.*, w.name as workflow_name FROM executions e LEFT JOIN workflows w ON e.workflow_id = w.id ORDER BY e.started_at DESC LIMIT 50'
|
||||||
|
);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
res.json(rows[0]);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Health check (no auth required)
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: 'connected',
|
||||||
|
auth: 'authelia-sso'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({
|
||||||
|
status: 'error',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
database: 'disconnected',
|
||||||
|
error: error.message
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start server
|
||||||
|
const PORT = process.env.PORT || 8080;
|
||||||
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
|
|
||||||
|
initDatabase().then(() => {
|
||||||
|
server.listen(PORT, HOST, () => {
|
||||||
|
console.log(`PULSE Server running on http://${HOST}:${PORT}`);
|
||||||
|
console.log(`Connected to MariaDB at ${process.env.DB_HOST}`);
|
||||||
|
console.log(`Authentication: Authelia SSO`);
|
||||||
|
console.log(`Worker API Key configured: ${process.env.WORKER_API_KEY ? 'Yes' : 'No'}`);
|
||||||
|
});
|
||||||
|
}).catch(err => {
|
||||||
|
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