feat: workflow param substitution + link troubleshooter support
Adds {{param_name}} template substitution to the workflow execution engine
so workflows can accept user-supplied inputs at run time.
server.js:
- applyParams() helper — substitutes {{name}} in command strings with
validated values (alphanumeric + safe punctuation only); unsafe values
throw and fail the execution cleanly
- executeCommandStep() / executeWorkflowSteps() — accept params={} and
apply substitution before dispatching commands to workers
- POST /api/executions — accepts params:{} from client; validates required
params against definition.params[]; logs params in initial execution log
index.html:
- loadWorkflows() caches definition in _workflowRegistry keyed by id;
shows "[N params]" badge on parameterised workflows
- executeWorkflow() checks for definition.params; if present, shows
param input modal instead of plain confirm()
- showParamModal() — builds labelled input form from param definitions,
marks required fields, focuses first input, Enter submits
- submitParamForm() — validates required fields, calls startExecution()
- startExecution() — POSTs {workflow_id, params} and switches to executions tab
- Param input modal — terminal-aesthetic overlay, no external dependencies
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -991,6 +991,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Workflow Param Input Modal -->
|
||||||
|
<div id="paramModal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:1000;align-items:center;justify-content:center;">
|
||||||
|
<div style="background:#0a0a0a;border:2px solid var(--terminal-green);padding:28px 32px;min-width:380px;max-width:520px;width:90%;font-family:var(--font-mono);box-shadow:0 0 30px rgba(0,255,65,.2);">
|
||||||
|
<h2 style="color:var(--terminal-green);margin:0 0 6px;font-size:1.1em;letter-spacing:.05em;">▶ RUN WORKFLOW</h2>
|
||||||
|
<p style="color:var(--terminal-amber);font-size:.75em;margin:0 0 20px;letter-spacing:.04em;">Fill in required parameters</p>
|
||||||
|
<div id="paramModalForm"></div>
|
||||||
|
<div style="display:flex;gap:10px;margin-top:20px;">
|
||||||
|
<button onclick="submitParamForm()"
|
||||||
|
style="flex:1;padding:8px;background:rgba(0,255,65,.1);border:1px solid var(--terminal-green);color:var(--terminal-green);font-family:var(--font-mono);cursor:pointer;font-size:.9em;">
|
||||||
|
[ ▶ Run ]
|
||||||
|
</button>
|
||||||
|
<button onclick="closeParamModal()"
|
||||||
|
style="padding:8px 16px;background:transparent;border:1px solid #555;color:#888;font-family:var(--font-mono);cursor:pointer;font-size:.9em;">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Create Schedule Modal -->
|
<!-- Create Schedule Modal -->
|
||||||
<div id="createScheduleModal" class="modal">
|
<div id="createScheduleModal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -1156,26 +1175,43 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Workflow registry (id → definition) for param lookup
|
||||||
|
let _workflowRegistry = {};
|
||||||
|
|
||||||
async function loadWorkflows() {
|
async function loadWorkflows() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/workflows');
|
const response = await fetch('/api/workflows');
|
||||||
const workflows = await response.json();
|
const workflows = await response.json();
|
||||||
|
|
||||||
|
// Cache definitions for param lookup at execute time
|
||||||
|
_workflowRegistry = {};
|
||||||
|
workflows.forEach(w => {
|
||||||
|
const def = typeof w.definition === 'string' ? JSON.parse(w.definition) : w.definition;
|
||||||
|
_workflowRegistry[w.id] = def;
|
||||||
|
});
|
||||||
|
|
||||||
|
const paramBadge = (def) => {
|
||||||
|
const ps = (def && def.params) || [];
|
||||||
|
return ps.length ? `<span style="font-size:.75em;color:var(--terminal-amber);margin-left:6px;">[${ps.length} param${ps.length > 1 ? 's' : ''}]</span>` : '';
|
||||||
|
};
|
||||||
|
|
||||||
const html = workflows.length === 0 ?
|
const html = workflows.length === 0 ?
|
||||||
'<div class="empty">No workflows defined yet</div>' :
|
'<div class="empty">No workflows defined yet</div>' :
|
||||||
workflows.map(w => `
|
workflows.map(w => {
|
||||||
|
const def = _workflowRegistry[w.id] || {};
|
||||||
|
return `
|
||||||
<div class="workflow-item">
|
<div class="workflow-item">
|
||||||
<div class="workflow-name">${w.name}</div>
|
<div class="workflow-name">${w.name}${paramBadge(def)}</div>
|
||||||
<div class="workflow-desc">${w.description || 'No description'}</div>
|
<div class="workflow-desc">${w.description || 'No description'}</div>
|
||||||
<div class="timestamp">Created by ${w.created_by || 'Unknown'} on ${new Date(w.created_at).toLocaleString()}</div>
|
<div class="timestamp">Created by ${w.created_by || 'Unknown'} on ${new Date(w.created_at).toLocaleString()}</div>
|
||||||
<div style="margin-top: 10px;">
|
<div style="margin-top: 10px;">
|
||||||
<button onclick="executeWorkflow('${w.id}', '${w.name}')">▶️ Execute</button>
|
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
|
||||||
${currentUser && currentUser.isAdmin ?
|
${currentUser && currentUser.isAdmin ?
|
||||||
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
|
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
|
||||||
: ''}
|
: ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>`;
|
||||||
`).join('');
|
}).join('');
|
||||||
document.getElementById('workflowList').innerHTML = html;
|
document.getElementById('workflowList').innerHTML = html;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading workflows:', error);
|
console.error('Error loading workflows:', error);
|
||||||
@@ -1781,30 +1817,87 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeWorkflow(workflowId, name) {
|
// ── Workflow execution with optional param modal ───────────────────
|
||||||
if (!confirm(`Execute workflow: ${name}?`)) return;
|
let _pendingExecWorkflowId = null;
|
||||||
|
|
||||||
|
async function executeWorkflow(workflowId) {
|
||||||
|
const def = _workflowRegistry[workflowId] || {};
|
||||||
|
const paramDefs = def.params || [];
|
||||||
|
if (paramDefs.length > 0) {
|
||||||
|
showParamModal(workflowId, paramDefs);
|
||||||
|
} else {
|
||||||
|
const name = document.querySelector(`[onclick="executeWorkflow('${workflowId}')"]`)
|
||||||
|
?.closest('.workflow-item')?.querySelector('.workflow-name')?.textContent || 'this workflow';
|
||||||
|
if (!confirm(`Execute: ${name}?`)) return;
|
||||||
|
await startExecution(workflowId, {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startExecution(workflowId, params) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/executions', {
|
const response = await fetch('/api/executions', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ workflow_id: workflowId })
|
body: JSON.stringify({ workflow_id: workflowId, params })
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json();
|
|
||||||
alert('Workflow execution started!');
|
|
||||||
switchTab('executions');
|
switchTab('executions');
|
||||||
refreshData();
|
refreshData();
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to start workflow');
|
const err = await response.json().catch(() => ({}));
|
||||||
|
alert('Failed to start: ' + (err.error || response.status));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error executing workflow:', error);
|
alert('Error starting workflow: ' + error.message);
|
||||||
alert('Error executing workflow');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showParamModal(workflowId, paramDefs) {
|
||||||
|
_pendingExecWorkflowId = workflowId;
|
||||||
|
const form = document.getElementById('paramModalForm');
|
||||||
|
form.innerHTML = paramDefs.map(p => `
|
||||||
|
<div style="margin-bottom:14px;">
|
||||||
|
<label style="display:block;margin-bottom:4px;color:var(--terminal-amber);font-size:.85em;">
|
||||||
|
${p.label || p.name}${p.required ? ' <span style="color:var(--terminal-red)">*</span>' : ''}
|
||||||
|
</label>
|
||||||
|
<input type="text" id="param_${p.name}"
|
||||||
|
placeholder="${p.placeholder || ''}"
|
||||||
|
style="width:100%;background:#0a0a0a;border:1px solid var(--terminal-green);color:var(--terminal-green);font-family:var(--font-mono);padding:6px 8px;font-size:.9em;"
|
||||||
|
${p.required ? 'required' : ''}>
|
||||||
|
</div>`).join('');
|
||||||
|
document.getElementById('paramModal').style.display = 'flex';
|
||||||
|
// Focus first input; Enter key submits
|
||||||
|
form.querySelectorAll('input').forEach(inp => {
|
||||||
|
inp.addEventListener('keydown', e => { if (e.key === 'Enter') submitParamForm(); });
|
||||||
|
});
|
||||||
|
const first = form.querySelector('input');
|
||||||
|
if (first) setTimeout(() => first.focus(), 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeParamModal() {
|
||||||
|
document.getElementById('paramModal').style.display = 'none';
|
||||||
|
_pendingExecWorkflowId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitParamForm() {
|
||||||
|
if (!_pendingExecWorkflowId) return;
|
||||||
|
const def = _workflowRegistry[_pendingExecWorkflowId] || {};
|
||||||
|
const paramDefs = def.params || [];
|
||||||
|
const params = {};
|
||||||
|
for (const p of paramDefs) {
|
||||||
|
const el = document.getElementById(`param_${p.name}`);
|
||||||
|
const val = el ? el.value.trim() : '';
|
||||||
|
if (p.required && !val) {
|
||||||
|
el.style.borderColor = 'var(--terminal-red)';
|
||||||
|
el.focus();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (val) params[p.name] = val;
|
||||||
|
}
|
||||||
|
closeParamModal();
|
||||||
|
await startExecution(_pendingExecWorkflowId, params);
|
||||||
|
}
|
||||||
|
|
||||||
async function viewExecution(executionId) {
|
async function viewExecution(executionId) {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/executions/${executionId}`);
|
const response = await fetch(`/api/executions/${executionId}`);
|
||||||
|
|||||||
42
server.js
42
server.js
@@ -494,7 +494,21 @@ function authenticateGandalf(req, res, next) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Workflow Execution Engine
|
// Workflow Execution Engine
|
||||||
async function executeWorkflowSteps(executionId, workflowId, definition, username) {
|
|
||||||
|
// Substitute {{param_name}} placeholders in a command string.
|
||||||
|
// Only alphanumeric + safe punctuation allowed in substituted values.
|
||||||
|
function applyParams(command, params) {
|
||||||
|
return command.replace(/\{\{(\w+)\}\}/g, (match, key) => {
|
||||||
|
if (!(key in params)) return match;
|
||||||
|
const val = String(params[key]).trim();
|
||||||
|
if (!/^[a-zA-Z0-9._:@\-\/]+$/.test(val)) {
|
||||||
|
throw new Error(`Unsafe value for workflow parameter "${key}"`);
|
||||||
|
}
|
||||||
|
return val;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeWorkflowSteps(executionId, workflowId, definition, username, params = {}) {
|
||||||
try {
|
try {
|
||||||
console.log(`[Workflow] Starting execution ${executionId} for workflow ${workflowId}`);
|
console.log(`[Workflow] Starting execution ${executionId} for workflow ${workflowId}`);
|
||||||
|
|
||||||
@@ -522,7 +536,7 @@ async function executeWorkflowSteps(executionId, workflowId, definition, usernam
|
|||||||
|
|
||||||
if (step.type === 'execute') {
|
if (step.type === 'execute') {
|
||||||
// Execute command step
|
// Execute command step
|
||||||
const success = await executeCommandStep(executionId, step, i + 1);
|
const success = await executeCommandStep(executionId, step, i + 1, params);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
allStepsSucceeded = false;
|
allStepsSucceeded = false;
|
||||||
break; // Stop workflow on failure
|
break; // Stop workflow on failure
|
||||||
@@ -580,9 +594,12 @@ async function executeWorkflowSteps(executionId, workflowId, definition, usernam
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeCommandStep(executionId, step, stepNumber) {
|
async function executeCommandStep(executionId, step, stepNumber, params = {}) {
|
||||||
try {
|
try {
|
||||||
const command = step.command;
|
let command = step.command;
|
||||||
|
if (Object.keys(params).length > 0) {
|
||||||
|
command = applyParams(command, params);
|
||||||
|
}
|
||||||
const targets = step.targets || ['all'];
|
const targets = step.targets || ['all'];
|
||||||
|
|
||||||
// Determine which workers to target
|
// Determine which workers to target
|
||||||
@@ -817,7 +834,7 @@ app.post('/api/workers/heartbeat', async (req, res) => {
|
|||||||
|
|
||||||
app.post('/api/executions', authenticateSSO, async (req, res) => {
|
app.post('/api/executions', authenticateSSO, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { workflow_id } = req.body;
|
const { workflow_id, params = {} } = req.body;
|
||||||
const id = generateUUID();
|
const id = generateUUID();
|
||||||
|
|
||||||
// Get workflow definition
|
// Get workflow definition
|
||||||
@@ -829,16 +846,27 @@ app.post('/api/executions', authenticateSSO, async (req, res) => {
|
|||||||
const workflow = workflows[0];
|
const workflow = workflows[0];
|
||||||
const definition = typeof workflow.definition === 'string' ? JSON.parse(workflow.definition) : workflow.definition;
|
const definition = typeof workflow.definition === 'string' ? JSON.parse(workflow.definition) : workflow.definition;
|
||||||
|
|
||||||
|
// Validate required params
|
||||||
|
const paramDefs = definition.params || [];
|
||||||
|
for (const pd of paramDefs) {
|
||||||
|
if (pd.required && !params[pd.name]) {
|
||||||
|
return res.status(400).json({ error: `Missing required parameter: ${pd.label || pd.name}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Create execution record
|
// Create execution record
|
||||||
|
const initLogs = Object.keys(params).length > 0
|
||||||
|
? [{ action: 'params', params, timestamp: new Date().toISOString() }]
|
||||||
|
: [];
|
||||||
await pool.query(
|
await pool.query(
|
||||||
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
|
'INSERT INTO executions (id, workflow_id, status, started_by, started_at, logs) VALUES (?, ?, ?, ?, NOW(), ?)',
|
||||||
[id, workflow_id, 'running', req.user.username, JSON.stringify([])]
|
[id, workflow_id, 'running', req.user.username, JSON.stringify(initLogs)]
|
||||||
);
|
);
|
||||||
|
|
||||||
broadcast({ type: 'execution_started', execution_id: id, workflow_id });
|
broadcast({ type: 'execution_started', execution_id: id, workflow_id });
|
||||||
|
|
||||||
// Start workflow execution asynchronously
|
// Start workflow execution asynchronously
|
||||||
executeWorkflowSteps(id, workflow_id, definition, req.user.username).catch(err => {
|
executeWorkflowSteps(id, workflow_id, definition, req.user.username, params).catch(err => {
|
||||||
console.error(`[Workflow] Execution ${id} failed:`, err);
|
console.error(`[Workflow] Execution ${id} failed:`, err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user