- Dashboard, Workers, Workflows, Executions, Quick Command, Scheduler pages on the shared layout, all behaviour via Pulse delegated actions (no inline handlers) - Executions: server-driven Manual/Automated views, compare mode, detail modal with all log types, ?open= deep link, cross-page re-run via sessionStorage - Workflows: run modal (dry-run available for every workflow), create modal pre-filled with the example definition - Fix EJS comment in page-header partial that broke rendering Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HamVMDrA8RqhyxmUHgiqRp
356 lines
13 KiB
JavaScript
356 lines
13 KiB
JavaScript
/* =====================================================================
|
|
PULSE — Workflows page (WP-D)
|
|
---------------------------------------------------------------------
|
|
Owns: DOM id prefix `wf-`, action namespace `wf:*`.
|
|
Consumes the frozen window.Pulse contract from /assets/app.js.
|
|
===================================================================== */
|
|
'use strict';
|
|
|
|
(function () {
|
|
// Example workflow definition. The Create modal is pre-filled with this
|
|
// every time it opens (fixes the old bug where the textarea was blanked).
|
|
var EXAMPLE_DEFINITION = {
|
|
steps: [
|
|
{
|
|
name: 'Example Step',
|
|
type: 'execute',
|
|
targets: ['all'],
|
|
command: "echo 'Hello from PULSE'"
|
|
}
|
|
]
|
|
};
|
|
|
|
// id -> parsed definition object, rebuilt on every list load.
|
|
var _registry = {};
|
|
// id -> raw row from the list response (name/description/etc.).
|
|
var _rowsById = {};
|
|
// workflow id currently targeted by the Run modal.
|
|
var _pendingWorkflowId = null;
|
|
|
|
function paramBadge(def) {
|
|
var params = (def && def.params) || [];
|
|
if (!params.length) return '';
|
|
var n = params.length;
|
|
return ' <span class="lt-badge lt-badge-sm">' + n + ' param' + (n > 1 ? 's' : '') + '</span>';
|
|
}
|
|
|
|
function parseDefinition(raw) {
|
|
if (raw && typeof raw === 'object') return raw;
|
|
if (typeof raw === 'string') {
|
|
try { return JSON.parse(raw); } catch (e) { return {}; }
|
|
}
|
|
return {};
|
|
}
|
|
|
|
function renderEmptyState() {
|
|
return (
|
|
'<div class="lt-empty-state">' +
|
|
'<div class="lt-empty-state-icon">⚙</div>' +
|
|
'<div class="lt-empty-state-title">No workflows yet</div>' +
|
|
'<div class="lt-empty-state-body">Workflows let you run multi-step jobs across one or more workers.</div>' +
|
|
'<button type="button" class="lt-btn lt-btn-primary" data-action="wf:create-open">Create your first workflow</button>' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
function renderRow(w) {
|
|
var def = _registry[w.id] || {};
|
|
var isAdmin = window.Pulse.isAdmin;
|
|
var esc = window.Pulse.esc;
|
|
var actions =
|
|
'<button type="button" class="lt-btn lt-btn-sm lt-btn-primary" data-action="wf:execute" data-workflow-id="' +
|
|
esc(w.id) + '">Execute</button>';
|
|
if (isAdmin) {
|
|
actions +=
|
|
' <button type="button" class="lt-btn lt-btn-sm lt-btn-secondary" data-action="wf:edit-open" data-workflow-id="' +
|
|
esc(w.id) + '">Edit</button>' +
|
|
' <button type="button" class="lt-btn lt-btn-sm lt-btn-danger" data-action="wf:delete" data-workflow-id="' +
|
|
esc(w.id) + '" data-workflow-name="' + esc(w.name) + '">Delete</button>';
|
|
}
|
|
var createdAt = window.Pulse.fmt.dateTime(w.created_at);
|
|
return (
|
|
'<tr>' +
|
|
'<td data-label="Name">' + esc(w.name) + paramBadge(def) + '</td>' +
|
|
'<td data-label="Description">' + esc(w.description || 'No description') + '</td>' +
|
|
'<td data-label="Created">' + esc(w.created_by || 'Unknown') + ' · ' + esc(createdAt) + '</td>' +
|
|
'<td data-label="Actions"><div class="lt-btn-group">' + actions + '</div></td>' +
|
|
'</tr>'
|
|
);
|
|
}
|
|
|
|
function renderList(workflows) {
|
|
var el = document.getElementById('wf-list');
|
|
if (!el) return;
|
|
if (!workflows.length) {
|
|
el.innerHTML = renderEmptyState();
|
|
return;
|
|
}
|
|
var rows = workflows.map(renderRow).join('');
|
|
el.innerHTML =
|
|
'<div class="lt-table-wrap">' +
|
|
'<table class="lt-table lt-table-responsive">' +
|
|
'<thead><tr><th>Name</th><th>Description</th><th>Created</th><th>Actions</th></tr></thead>' +
|
|
'<tbody>' + rows + '</tbody>' +
|
|
'</table>' +
|
|
'</div>';
|
|
}
|
|
|
|
function renderLoadError(message) {
|
|
var el = document.getElementById('wf-list');
|
|
if (!el) return;
|
|
el.innerHTML =
|
|
'<div class="lt-alert lt-alert--error" role="alert">' +
|
|
'<span class="lt-alert-icon">⚠</span>' +
|
|
'<span class="lt-alert-msg">' + window.Pulse.esc(message || 'Failed to load workflows') + '</span>' +
|
|
'</div>';
|
|
}
|
|
|
|
function loadWorkflows() {
|
|
return window.Pulse.api.get('/api/workflows').then(function (workflows) {
|
|
var list = workflows || [];
|
|
_registry = {};
|
|
_rowsById = {};
|
|
list.forEach(function (w) {
|
|
_rowsById[w.id] = w;
|
|
_registry[w.id] = parseDefinition(w.definition);
|
|
});
|
|
renderList(list);
|
|
}).catch(function (e) {
|
|
renderLoadError(e && e.message);
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Create modal
|
|
--------------------------------------------------------------- */
|
|
function showFieldError(id, message) {
|
|
var el = document.getElementById(id);
|
|
if (!el) return;
|
|
el.textContent = message || '';
|
|
el.hidden = !message;
|
|
}
|
|
|
|
function openCreateModal() {
|
|
document.getElementById('wf-create-form').reset();
|
|
document.getElementById('wf-create-definition').value = JSON.stringify(EXAMPLE_DEFINITION, null, 2);
|
|
showFieldError('wf-create-error', '');
|
|
window.lt.modal.open('wf-create-modal');
|
|
}
|
|
|
|
function submitCreateForm(formEl) {
|
|
var name = formEl.elements['name'].value.trim();
|
|
var description = formEl.elements['description'].value.trim();
|
|
var definitionText = formEl.elements['definition'].value;
|
|
var webhookUrl = formEl.elements['webhook_url'].value.trim() || null;
|
|
|
|
if (!name) {
|
|
showFieldError('wf-create-error', 'Name is required.');
|
|
return;
|
|
}
|
|
|
|
var definition;
|
|
try {
|
|
definition = JSON.parse(definitionText);
|
|
} catch (e) {
|
|
showFieldError('wf-create-error', 'Invalid JSON: ' + e.message);
|
|
return;
|
|
}
|
|
showFieldError('wf-create-error', '');
|
|
|
|
return window.Pulse.api.post('/api/workflows', {
|
|
name: name,
|
|
description: description,
|
|
definition: definition,
|
|
webhook_url: webhookUrl
|
|
}).then(function () {
|
|
window.lt.modal.close('wf-create-modal');
|
|
window.Pulse.toast.success('Workflow created');
|
|
return loadWorkflows();
|
|
}).catch(function (e) {
|
|
showFieldError('wf-create-error', (e && e.message) || 'Failed to create workflow');
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Edit modal (admin)
|
|
--------------------------------------------------------------- */
|
|
function openEditModal(workflowId) {
|
|
showFieldError('wf-edit-error', '');
|
|
return window.Pulse.api.get('/api/workflows/' + encodeURIComponent(workflowId)).then(function (wf) {
|
|
document.getElementById('wf-edit-id').value = wf.id;
|
|
document.getElementById('wf-edit-name').value = wf.name || '';
|
|
document.getElementById('wf-edit-description').value = wf.description || '';
|
|
document.getElementById('wf-edit-definition').value = JSON.stringify(parseDefinition(wf.definition), null, 2);
|
|
document.getElementById('wf-edit-webhook').value = wf.webhook_url || '';
|
|
window.lt.modal.open('wf-edit-modal');
|
|
}).catch(function (e) {
|
|
window.Pulse.toast.error((e && e.message) || 'Failed to load workflow');
|
|
});
|
|
}
|
|
|
|
function submitEditForm(formEl) {
|
|
var id = formEl.elements['id'].value;
|
|
var name = formEl.elements['name'].value.trim();
|
|
var description = formEl.elements['description'].value.trim();
|
|
var definitionText = formEl.elements['definition'].value;
|
|
var webhookUrl = formEl.elements['webhook_url'].value.trim() || null;
|
|
|
|
if (!name) {
|
|
showFieldError('wf-edit-error', 'Name is required.');
|
|
return;
|
|
}
|
|
|
|
var definition;
|
|
try {
|
|
definition = JSON.parse(definitionText);
|
|
} catch (e) {
|
|
showFieldError('wf-edit-error', 'Invalid JSON: ' + e.message);
|
|
return;
|
|
}
|
|
showFieldError('wf-edit-error', '');
|
|
|
|
return window.Pulse.api.put('/api/workflows/' + encodeURIComponent(id), {
|
|
name: name,
|
|
description: description,
|
|
definition: definition,
|
|
webhook_url: webhookUrl
|
|
}).then(function () {
|
|
window.lt.modal.close('wf-edit-modal');
|
|
window.Pulse.toast.success('Workflow saved');
|
|
return loadWorkflows();
|
|
}).catch(function (e) {
|
|
showFieldError('wf-edit-error', (e && e.message) || 'Failed to save workflow');
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Delete (admin)
|
|
--------------------------------------------------------------- */
|
|
function deleteWorkflow(workflowId, workflowName) {
|
|
return window.Pulse.confirm({
|
|
title: 'Delete Workflow',
|
|
message: 'Delete workflow "' + workflowName + '"? This cannot be undone.',
|
|
type: 'error',
|
|
confirmLabel: 'DELETE'
|
|
}).then(function (ok) {
|
|
if (!ok) return;
|
|
return window.Pulse.api.delete('/api/workflows/' + encodeURIComponent(workflowId)).then(function () {
|
|
window.Pulse.toast.success('Workflow deleted');
|
|
return loadWorkflows();
|
|
}).catch(function (e) {
|
|
window.Pulse.toast.error((e && e.message) || 'Failed to delete workflow');
|
|
});
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Run modal (execute): always shown, replaces the old confirm().
|
|
--------------------------------------------------------------- */
|
|
function paramFieldHtml(p) {
|
|
var esc = window.Pulse.esc;
|
|
var label = esc(p.label || p.name);
|
|
var required = p.required ? ' <span class="wf-required">*</span>' : '';
|
|
return (
|
|
'<div class="lt-form-group">' +
|
|
'<label class="lt-label" for="wf-run-param-' + esc(p.name) + '">' + label + required + '</label>' +
|
|
'<input type="text" id="wf-run-param-' + esc(p.name) + '" class="lt-input" ' +
|
|
'data-param-name="' + esc(p.name) + '" data-required="' + (p.required ? '1' : '0') + '" ' +
|
|
'placeholder="' + esc(p.placeholder || '') + '">' +
|
|
'</div>'
|
|
);
|
|
}
|
|
|
|
function openRunModal(workflowId) {
|
|
var row = _rowsById[workflowId];
|
|
var def = _registry[workflowId] || {};
|
|
var paramDefs = def.params || [];
|
|
|
|
_pendingWorkflowId = workflowId;
|
|
document.getElementById('wf-run-workflow-id').value = workflowId;
|
|
document.getElementById('wf-run-name').textContent = row ? row.name : 'this workflow';
|
|
document.getElementById('wf-run-dryrun').checked = false;
|
|
|
|
var paramsEl = document.getElementById('wf-run-params');
|
|
paramsEl.innerHTML = paramDefs.map(paramFieldHtml).join('');
|
|
|
|
window.lt.modal.open('wf-run-modal');
|
|
|
|
var first = paramsEl.querySelector('input');
|
|
if (first) setTimeout(function () { first.focus(); }, 60);
|
|
}
|
|
|
|
function submitRunForm() {
|
|
if (!_pendingWorkflowId) return;
|
|
var def = _registry[_pendingWorkflowId] || {};
|
|
var paramDefs = def.params || [];
|
|
var params = {};
|
|
|
|
for (var i = 0; i < paramDefs.length; i++) {
|
|
var p = paramDefs[i];
|
|
var el = document.getElementById('wf-run-param-' + p.name);
|
|
var val = el ? el.value.trim() : '';
|
|
if (p.required && !val) {
|
|
el.classList.add('wf-input-invalid');
|
|
el.focus();
|
|
return;
|
|
}
|
|
el && el.classList.remove('wf-input-invalid');
|
|
if (val) params[p.name] = val;
|
|
}
|
|
|
|
var dryRun = document.getElementById('wf-run-dryrun').checked;
|
|
var workflowId = _pendingWorkflowId;
|
|
|
|
return window.Pulse.api.post('/api/executions', {
|
|
workflow_id: workflowId,
|
|
params: params,
|
|
dry_run: dryRun
|
|
}).then(function (result) {
|
|
window.lt.modal.close('wf-run-modal');
|
|
_pendingWorkflowId = null;
|
|
window.Pulse.toast.success(dryRun ? 'Dry run started' : 'Workflow started');
|
|
window.location.href = '/executions?open=' + encodeURIComponent(result.id);
|
|
}).catch(function (e) {
|
|
window.Pulse.toast.error((e && e.message) || 'Failed to start execution');
|
|
});
|
|
}
|
|
|
|
/* ---------------------------------------------------------------
|
|
Actions + page registration
|
|
--------------------------------------------------------------- */
|
|
window.Pulse.actions.registerAll({
|
|
'wf:create-open': function () { openCreateModal(); },
|
|
'wf:create-submit': function (el) { return submitCreateForm(el); },
|
|
'wf:execute': function (el) { openRunModal(el.getAttribute('data-workflow-id')); },
|
|
'wf:edit-open': function (el) { return openEditModal(el.getAttribute('data-workflow-id')); },
|
|
'wf:edit-submit': function (el) { return submitEditForm(el); },
|
|
'wf:delete': function (el) {
|
|
return deleteWorkflow(el.getAttribute('data-workflow-id'), el.getAttribute('data-workflow-name'));
|
|
},
|
|
'wf:param-submit': function () { return submitRunForm(); }
|
|
});
|
|
|
|
// Enter in a param input submits the Run form (matches old behaviour).
|
|
document.addEventListener('keydown', function (ev) {
|
|
if (ev.key !== 'Enter') return;
|
|
var target = ev.target;
|
|
if (!target || target.tagName !== 'INPUT') return;
|
|
if (!target.closest || !target.closest('#wf-run-form')) return;
|
|
ev.preventDefault();
|
|
submitRunForm();
|
|
});
|
|
|
|
window.Pulse.registerPage({
|
|
name: 'workflows',
|
|
init: function () { return loadWorkflows(); },
|
|
refresh: function () { return loadWorkflows(); },
|
|
onEvent: function (type) {
|
|
if (type === 'workflow_created' || type === 'workflow_updated' || type === 'workflow_deleted') {
|
|
loadWorkflows();
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
});
|
|
})();
|