Add interactive workflow system with prompt steps, workflow editor

- executeWorkflowSteps: rewritten with step-id/goto branching support
- executePromptStep: async pause via _executionPrompts Map, 60min timeout
- POST /api/executions/:id/respond: resolves pending prompt from browser
- PUT /api/workflows/🆔 admin-only workflow editing, broadcasts workflow_updated
- GET /api/workflows/🆔 fetch single workflow for edit modal
- GET /api/executions/🆔 now includes waiting_for_input + prompt fields
- index.html: prompt/prompt_response/step_skipped log entry rendering
- index.html: execution_prompt WebSocket handler refreshes open modal
- index.html: workflow_updated WebSocket handler reloads workflow list
- index.html: Edit button + modal for in-browser workflow editing
- index.html: respondToPrompt keeps modal open, refreshes execution view
- Interactive Link Troubleshooter v2 workflow: 45-step wizard with
  copper/fiber branches, clean/swap/reseat actions, re-test loops,
  CRC error path, performance diagnostics, SUCCESS/ESCALATE terminals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 16:55:02 -05:00
parent 033237482d
commit bf9b14bc96
2 changed files with 393 additions and 68 deletions

View File

@@ -551,6 +551,31 @@
.prompt-box h3::before {
content: '⏳ ';
}
.prompt-box p {
color: var(--terminal-green);
font-family: var(--font-mono);
margin-bottom: 14px;
}
.prompt-opt-btn {
padding: 7px 16px;
margin: 4px 4px 4px 0;
background: rgba(0, 255, 255, 0.08);
border: 1px solid var(--terminal-cyan);
color: var(--terminal-cyan);
font-family: var(--font-mono);
font-size: 0.88em;
cursor: pointer;
transition: background 0.2s;
}
.prompt-opt-btn:hover {
background: rgba(0, 255, 255, 0.2);
box-shadow: 0 0 8px rgba(0, 255, 255, 0.3);
}
.prompt-opt-btn.answered {
opacity: 0.45;
cursor: default;
background: transparent;
}
/* Boot Overlay */
.boot-overlay {
position: fixed;
@@ -1010,6 +1035,25 @@
</div>
</div>
<!-- Edit Workflow Modal -->
<div id="editWorkflowModal" class="modal">
<div class="modal-content" style="max-width: 700px; width: 95%;">
<h2>✏️ Edit Workflow</h2>
<input type="hidden" id="editWorkflowId">
<label style="display:block;margin-bottom:6px;font-weight:600;">Name:</label>
<input type="text" id="editWorkflowName" placeholder="Workflow Name">
<label style="display:block;margin:12px 0 6px;font-weight:600;">Description:</label>
<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>
<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>
<button onclick="closeModal('editWorkflowModal')">Cancel</button>
</div>
</div>
</div>
<!-- Create Schedule Modal -->
<div id="createScheduleModal" class="modal">
<div class="modal-content">
@@ -1207,7 +1251,8 @@
<div style="margin-top: 10px;">
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
${currentUser && currentUser.isAdmin ?
`<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑 Delete</button>`
`<button onclick="editWorkflow('${w.id}')"> Edit</button>
<button class="danger" onclick="deleteWorkflow('${w.id}', '${w.name}')">🗑️ Delete</button>`
: ''}
</div>
</div>`;
@@ -1913,21 +1958,29 @@
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>`
<h3>Waiting for Input</h3>
<p>${escapeHtml(execution.prompt.message || '')}</p>
<div style="margin-top: 10px;">
${(execution.prompt.options || []).map(opt =>
`<button class="prompt-opt-btn" onclick="respondToPrompt('${executionId}', ${JSON.stringify(opt)})">${escapeHtml(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 += formatLogEntry(log);
// Pass executionId only to prompt steps that are still pending (no response after them)
const logs = execution.logs;
logs.forEach((log, idx) => {
let promptExecId = null;
if (log.action === 'prompt' && execution.waiting_for_input) {
// Only make interactive if this is the last prompt and no response follows
const hasResponse = logs.slice(idx + 1).some(l => l.action === 'prompt_response');
if (!hasResponse) promptExecId = executionId;
}
html += formatLogEntry(log, promptExecId);
});
}
@@ -1960,7 +2013,7 @@
}
}
function formatLogEntry(log) {
function formatLogEntry(log, executionId = null) {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
// Format based on log action type
@@ -2070,6 +2123,43 @@
`;
}
if (log.action === 'prompt') {
const optionsHtml = (log.options || []).map(opt => {
if (executionId) {
return `<button class="prompt-opt-btn" onclick="respondToPrompt('${executionId}', ${JSON.stringify(opt)})">${escapeHtml(opt)}</button>`;
}
return `<button class="prompt-opt-btn answered" disabled>${escapeHtml(opt)}</button>`;
}).join('');
return `
<div class="log-entry" style="border-left-color: var(--terminal-cyan);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-cyan);">❓ Step ${log.step}: ${escapeHtml(log.step_name || 'Prompt')}</div>
<div class="log-details">
<div style="color: var(--terminal-green); margin-bottom: 10px;">${escapeHtml(log.message || '')}</div>
<div>${optionsHtml}</div>
</div>
</div>
`;
}
if (log.action === 'prompt_response') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-green);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-green);">↪ Response: <strong style="color: var(--terminal-amber);">${escapeHtml(log.response || '')}</strong>${log.responded_by ? `<span style="color:#666;font-size:.85em;margin-left:10px;">by ${escapeHtml(log.responded_by)}</span>` : ''}</div>
</div>
`;
}
if (log.action === 'step_skipped') {
return `
<div class="log-entry" style="border-left-color: #555;">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: #666;">⊘ Step ${log.step} Skipped${log.reason ? ': ' + escapeHtml(log.reason) : ''}</div>
</div>
`;
}
// Fallback for unknown log types
return `<div class="log-entry"><pre>${JSON.stringify(log, null, 2)}</pre></div>`;
}
@@ -2184,17 +2274,18 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ response })
});
if (res.ok) {
closeModal('viewExecutionModal');
alert('Response submitted!');
refreshData();
showTerminalNotification(`Response submitted: ${response}`, 'success');
// Refresh the modal to show the next step
viewExecution(executionId);
} else {
alert('Failed to submit response');
const data = await res.json().catch(() => ({}));
showTerminalNotification(data.error || 'Failed to submit response', 'error');
}
} catch (error) {
console.error('Error responding to prompt:', error);
alert('Error submitting response');
showTerminalNotification('Error submitting response', 'error');
}
}
@@ -2348,6 +2439,68 @@
}
}
async function editWorkflow(workflowId) {
try {
const response = await fetch(`/api/workflows/${workflowId}`);
if (!response.ok) throw new Error('Workflow not found');
const wf = await response.json();
document.getElementById('editWorkflowId').value = wf.id;
document.getElementById('editWorkflowName').value = wf.name;
document.getElementById('editWorkflowDescription').value = wf.description || '';
document.getElementById('editWorkflowDefinition').value = JSON.stringify(wf.definition, null, 2);
document.getElementById('editWorkflowError').style.display = 'none';
document.getElementById('editWorkflowModal').classList.add('show');
} catch (error) {
console.error('Error loading workflow for edit:', error);
showTerminalNotification('Error loading workflow', 'error');
}
}
async function saveWorkflow() {
const id = document.getElementById('editWorkflowId').value;
const name = document.getElementById('editWorkflowName').value.trim();
const description = document.getElementById('editWorkflowDescription').value.trim();
const definitionText = document.getElementById('editWorkflowDefinition').value;
const errorEl = document.getElementById('editWorkflowError');
if (!name) {
errorEl.textContent = 'Name is required';
errorEl.style.display = 'block';
return;
}
let definition;
try {
definition = JSON.parse(definitionText);
} catch (e) {
errorEl.textContent = 'Invalid JSON: ' + e.message;
errorEl.style.display = 'block';
return;
}
errorEl.style.display = 'none';
try {
const response = await fetch(`/api/workflows/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, definition })
});
if (response.ok) {
closeModal('editWorkflowModal');
showTerminalNotification('Workflow saved!', 'success');
loadWorkflows();
} else {
const data = await response.json().catch(() => ({}));
errorEl.textContent = data.error || 'Failed to save workflow';
errorEl.style.display = 'block';
}
} catch (error) {
errorEl.textContent = 'Error saving workflow: ' + error.message;
errorEl.style.display = 'block';
}
}
function showCreateWorkflow() {
document.getElementById('createWorkflowModal').classList.add('show');
}
@@ -2715,8 +2868,25 @@
loadWorkflows();
}
if (data.type === 'workflow_updated') {
loadWorkflows();
}
if (data.type === 'execution_prompt') {
// If this execution is currently open, refresh to show the prompt
const executionModal = document.getElementById('viewExecutionModal');
if (executionModal && executionModal.classList.contains('show')) {
const currentId = executionModal.dataset.executionId;
if (currentId === data.execution_id) {
viewExecution(data.execution_id);
}
}
// Also update execution list so status indicators refresh
loadExecutions();
}
// Generic refresh for other message types
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted'].includes(data.type)) {
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated', 'execution_prompt'].includes(data.type)) {
refreshData();
}
} catch (error) {