Add rate limiting, cron scheduling, webhooks, dry-run, execution filtering, and UX improvements

- Rate limiting: 300 req/15min general, 20 req/min on POST /api/executions
- Cron schedule type support using cron-parser for full cron expressions
- Webhook notifications: POST to workflow webhook_url on execution complete/failed
- Dry-run mode: simulate workflow execution without running any commands
- Global execution timeout via EXECUTION_MAX_MINUTES env var (default 60min)
- Execution filtering: status, workflow_id, started_by, after, before, search
- Event-driven command result delivery (replaces 500ms DB polling)
- Atomic log appends via JSON_ARRAY_APPEND (no read-modify-write race)
- Separate browserClients/workerClients sets (workers no longer receive broadcasts)
- Stale execution cleanup on startup (mark running→failed after crash)
- Scheduler overlap prevention (skip if same workflow already running)
- Frontend: webhook_url field in create/edit workflow modals
- Frontend: dry-run checkbox in workflow param modal
- Frontend: ESC closes modals, ws.onerror handler added
- Frontend: selectedExecutions changed from Array to Set (O(1) ops)
- Frontend: XSS fixes via escapeHtml() on all user-controlled innerHTML
- Frontend: param modal keydown listener deduplication fix
- Remove unused npm packages (bcryptjs, body-parser, cors, js-yaml, jsonwebtoken)
- Add express-rate-limit and cron-parser dependencies

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-11 23:06:09 -04:00
parent 58c172e131
commit 2d6a0f1054
4 changed files with 240 additions and 47 deletions

View File

@@ -975,6 +975,8 @@
}
]
}</textarea>
<label style="display:block;margin:12px 0 6px;font-weight:600;">Webhook URL (optional):</label>
<input type="url" id="workflowWebhookUrl" placeholder="https://example.com/webhook">
<button onclick="createWorkflow()">Create</button>
<button onclick="closeModal('createWorkflowModal')">Cancel</button>
</div>
@@ -1022,6 +1024,10 @@
<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="margin-top:16px;display:flex;align-items:center;gap:10px;">
<input type="checkbox" id="paramDryRun" style="accent-color:var(--terminal-amber);">
<label for="paramDryRun" style="color:var(--terminal-amber);font-size:.85em;cursor:pointer;">Dry Run (simulate, no commands executed)</label>
</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;">
@@ -1046,6 +1052,8 @@
<textarea id="editWorkflowDescription" placeholder="Description" style="min-height:60px;"></textarea>
<label style="display:block;margin:12px 0 6px;font-weight:600;">Definition (JSON):</label>
<textarea id="editWorkflowDefinition" style="min-height: 320px; font-family: var(--font-mono); font-size: 0.85em;"></textarea>
<label style="display:block;margin:12px 0 6px;font-weight:600;">Webhook URL (optional):</label>
<input type="url" id="editWorkflowWebhookUrl" placeholder="https://example.com/webhook">
<div id="editWorkflowError" style="color:var(--terminal-red);font-size:0.85em;margin-top:8px;display:none;"></div>
<div style="margin-top:16px;display:flex;gap:10px;">
<button onclick="saveWorkflow()">[ 💾 Save ]</button>
@@ -1871,17 +1879,18 @@
} 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, {});
const choice = confirm(`Execute: ${name}?\n\nClick OK to run normally, or Cancel to abort.\n(Use the workflow's Run button with dry-run checkbox for a dry run.)`);
if (!choice) return;
await startExecution(workflowId, {}, false);
}
}
async function startExecution(workflowId, params) {
async function startExecution(workflowId, params, dryRun = false) {
try {
const response = await fetch('/api/executions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workflow_id: workflowId, params })
body: JSON.stringify({ workflow_id: workflowId, params, dry_run: dryRun })
});
if (response.ok) {
switchTab('executions');
@@ -1919,6 +1928,7 @@
function closeParamModal() {
document.getElementById('paramModal').style.display = 'none';
document.getElementById('paramDryRun').checked = false;
_pendingExecWorkflowId = null;
}
@@ -1937,8 +1947,10 @@
}
if (val) params[p.name] = val;
}
const dryRun = document.getElementById('paramDryRun').checked;
const wfId = _pendingExecWorkflowId;
closeParamModal();
await startExecution(_pendingExecWorkflowId, params);
await startExecution(wfId, params, dryRun);
}
async function viewExecution(executionId) {
@@ -2446,6 +2458,7 @@
document.getElementById('editWorkflowName').value = wf.name;
document.getElementById('editWorkflowDescription').value = wf.description || '';
document.getElementById('editWorkflowDefinition').value = JSON.stringify(wf.definition, null, 2);
document.getElementById('editWorkflowWebhookUrl').value = wf.webhook_url || '';
document.getElementById('editWorkflowError').style.display = 'none';
document.getElementById('editWorkflowModal').classList.add('show');
} catch (error) {
@@ -2459,6 +2472,7 @@
const name = document.getElementById('editWorkflowName').value.trim();
const description = document.getElementById('editWorkflowDescription').value.trim();
const definitionText = document.getElementById('editWorkflowDefinition').value;
const webhook_url = document.getElementById('editWorkflowWebhookUrl').value.trim() || null;
const errorEl = document.getElementById('editWorkflowError');
if (!name) {
@@ -2481,7 +2495,7 @@
const response = await fetch(`/api/workflows/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, definition })
body: JSON.stringify({ name, description, definition, webhook_url })
});
if (response.ok) {
@@ -2507,12 +2521,13 @@
const name = document.getElementById('workflowName').value;
const description = document.getElementById('workflowDescription').value;
const definitionText = document.getElementById('workflowDefinition').value;
const webhook_url = document.getElementById('workflowWebhookUrl').value.trim() || null;
if (!name || !definitionText) {
alert('Name and definition are required');
return;
}
let definition;
try {
definition = JSON.parse(definitionText);
@@ -2525,7 +2540,7 @@
const response = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, definition })
body: JSON.stringify({ name, description, definition, webhook_url })
});
if (response.ok) {