Files
pulse/public/index.html
Jared Vititoe d25ba27f24 Phase 4: Execution detail enhancements with re-run and download
Changes:
- Added "Re-run Command" button to execution details modal
- Added "Download Logs" button to export execution data as JSON
- Re-run automatically switches to Quick Command tab and pre-fills form
- Download includes all execution metadata and logs
- Buttons only show for applicable execution types
- Terminal-themed button styling

Features:
- Re-run: Quickly repeat a previous command on same worker
- Download: Export execution logs for auditing/debugging
- JSON format includes: execution_id, status, timestamps, logs
- Filename includes execution ID and date for easy organization

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-07 22:49:20 -05:00

1607 lines
63 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>
:root {
/* Terminal Dark Backgrounds */
--bg-primary: #0a0a0a;
--bg-secondary: #1a1a1a;
--bg-tertiary: #2a2a2a;
/* Terminal Colors */
--terminal-green: #00ff41;
--terminal-amber: #ffb000;
--terminal-cyan: #00ffff;
--text-primary: #00ff41;
--text-secondary: #00cc33;
--text-muted: #008822;
/* Border & UI */
--border-color: #00ff41;
--shadow: none;
--hover-bg: rgba(0, 255, 65, 0.1);
/* Status Colors (adapted) */
--status-online: #28a745;
--status-offline: #dc3545;
--status-running: #ffc107;
--status-completed: #28a745;
--status-failed: #dc3545;
--status-waiting: #ffc107;
/* Terminal Font Stack */
--font-mono: 'Courier New', 'Consolas', 'Monaco', 'Menlo', monospace;
/* Glow Effects */
--glow-green: 0 0 5px #00ff41, 0 0 10px #00ff41, 0 0 15px #00ff41;
--glow-green-intense: 0 0 8px #00ff41, 0 0 16px #00ff41, 0 0 24px #00ff41, 0 0 32px rgba(0, 255, 65, 0.5);
--glow-amber: 0 0 5px #ffb000, 0 0 10px #ffb000, 0 0 15px #ffb000;
--glow-amber-intense: 0 0 8px #ffb000, 0 0 16px #ffb000, 0 0 24px #ffb000;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: var(--font-mono);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
padding: 20px;
position: relative;
animation: flicker 0.2s ease-in-out 30s infinite;
}
/* CRT Scanline Effect */
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 9999;
animation: scanline 8s linear infinite;
}
@keyframes scanline {
0% { transform: translateY(0); }
100% { transform: translateY(4px); }
}
/* Data Stream Corner Effect */
body::after {
content: '10101010';
position: fixed;
bottom: 10px;
right: 10px;
font-family: var(--font-mono);
font-size: 0.6rem;
color: var(--terminal-green);
opacity: 0.1;
pointer-events: none;
letter-spacing: 2px;
animation: data-stream 3s linear infinite;
}
@keyframes data-stream {
0% { content: '10101010'; opacity: 0.1; }
25% { content: '01010101'; opacity: 0.15; }
50% { content: '11001100'; opacity: 0.1; }
75% { content: '00110011'; opacity: 0.15; }
100% { content: '10101010'; opacity: 0.1; }
}
@keyframes flicker {
0% { opacity: 1; }
10% { opacity: 0.95; }
20% { opacity: 1; }
30% { opacity: 0.97; }
40% { opacity: 1; }
}
.container { max-width: 1600px; margin: 0 auto; }
.header {
background: var(--bg-secondary);
padding: 20px 30px;
border: 2px solid var(--terminal-green);
border-radius: 0;
box-shadow: none;
margin-bottom: 30px;
display: flex;
justify-content: space-between;
align-items: center;
position: relative;
}
.header::before {
content: '╔';
position: absolute;
top: -2px;
left: -2px;
font-size: 1.5rem;
color: var(--terminal-green);
text-shadow: var(--glow-green);
line-height: 1;
z-index: 10;
}
.header::after {
content: '╗';
position: absolute;
top: -2px;
right: -2px;
font-size: 1.5rem;
color: var(--terminal-green);
text-shadow: var(--glow-green);
line-height: 1;
z-index: 10;
}
.header-left h1 {
color: var(--terminal-amber);
font-size: 2em;
margin-bottom: 5px;
text-shadow: var(--glow-amber-intense);
font-family: var(--font-mono);
}
.header-left h1::before {
content: '>> ';
color: var(--terminal-green);
}
.header-left p {
color: var(--terminal-green);
font-size: 1em;
font-family: var(--font-mono);
}
.user-info { text-align: right; }
.user-info .name {
font-weight: 600;
color: var(--terminal-green);
font-size: 1.1em;
font-family: var(--font-mono);
}
.user-info .email {
color: var(--text-secondary);
font-size: 0.9em;
font-family: var(--font-mono);
}
.user-info .badge {
display: inline-block;
background: transparent;
color: var(--terminal-amber);
padding: 4px 12px;
border: 2px solid var(--terminal-amber);
border-radius: 0;
font-size: 0.8em;
margin-top: 5px;
margin-left: 5px;
font-family: var(--font-mono);
}
.user-info .badge::before {
content: '[';
margin-right: 3px;
}
.user-info .badge::after {
content: ']';
margin-left: 3px;
}
.tabs {
background: var(--bg-secondary);
border: 2px solid var(--terminal-green);
border-radius: 0;
padding: 10px;
margin-bottom: 20px;
display: flex;
gap: 10px;
}
.tab {
padding: 12px 24px;
background: transparent;
border: 2px solid var(--terminal-green);
border-radius: 0;
cursor: pointer;
font-size: 1em;
font-weight: 600;
color: var(--terminal-green);
font-family: var(--font-mono);
transition: all 0.3s;
}
.tab.active {
background: rgba(0, 255, 65, 0.2);
color: var(--terminal-amber);
border-color: var(--terminal-amber);
text-shadow: var(--glow-amber);
}
.tab:hover {
background: rgba(0, 255, 65, 0.1);
color: var(--terminal-amber);
}
.tab.active:hover {
background: rgba(0, 255, 65, 0.25);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.card {
background: var(--bg-secondary);
padding: 25px;
border: 2px solid var(--terminal-green);
border-radius: 0;
box-shadow: none;
position: relative;
}
.card::before {
content: '┌';
position: absolute;
top: -2px;
left: -2px;
font-size: 1.2rem;
color: var(--terminal-green);
line-height: 1;
}
.card::after {
content: '┐';
position: absolute;
top: -2px;
right: -2px;
font-size: 1.2rem;
color: var(--terminal-green);
line-height: 1;
}
.card h3 {
color: var(--terminal-amber);
margin-bottom: 15px;
font-size: 1.2em;
font-family: var(--font-mono);
text-shadow: var(--glow-amber);
}
.card h3::before {
content: '═══ ';
color: var(--terminal-green);
}
.card h3::after {
content: ' ═══';
color: var(--terminal-green);
}
.status {
display: inline-block;
padding: 5px 15px;
border-radius: 0;
font-size: 0.9em;
font-weight: 600;
margin-bottom: 5px;
background: transparent;
border: 2px solid;
font-family: var(--font-mono);
}
.status.online {
border-color: var(--status-online);
color: var(--status-online);
text-shadow: 0 0 5px var(--status-online), 0 0 10px var(--status-online);
}
.status.online::before { content: '[●'; margin-right: 4px; }
.status.online::after { content: ']'; margin-left: 4px; }
.status.offline {
border-color: var(--status-offline);
color: var(--status-offline);
text-shadow: 0 0 5px var(--status-offline), 0 0 10px var(--status-offline);
}
.status.offline::before { content: '[○'; margin-right: 4px; }
.status.offline::after { content: ']'; margin-left: 4px; }
.status.running {
border-color: var(--status-running);
color: var(--status-running);
text-shadow: 0 0 5px var(--status-running), 0 0 10px var(--status-running);
}
.status.running::before {
content: '[◐';
margin-right: 4px;
animation: spin-status 2s linear infinite;
}
@keyframes spin-status {
0% { content: '[◐'; }
25% { content: '[◓'; }
50% { content: '[◑'; }
75% { content: '[◒'; }
100% { content: '[◐'; }
}
.status.running::after { content: ']'; margin-left: 4px; }
.status.completed {
border-color: var(--status-completed);
color: var(--status-completed);
text-shadow: 0 0 5px var(--status-completed), 0 0 10px var(--status-completed);
}
.status.completed::before { content: '[✓'; margin-right: 4px; }
.status.completed::after { content: ']'; margin-left: 4px; }
.status.failed {
border-color: var(--status-failed);
color: var(--status-failed);
text-shadow: 0 0 5px var(--status-failed), 0 0 10px var(--status-failed);
}
.status.failed::before { content: '[✗'; margin-right: 4px; }
.status.failed::after { content: ']'; margin-left: 4px; }
.status.waiting {
border-color: var(--status-waiting);
color: var(--status-waiting);
text-shadow: 0 0 5px var(--status-waiting), 0 0 10px var(--status-waiting);
}
.status.waiting::before { content: '[⏳'; margin-right: 4px; }
.status.waiting::after { content: ']'; margin-left: 4px; }
button {
background: transparent;
color: var(--terminal-green);
border: 2px solid var(--terminal-green);
border-radius: 0;
padding: 12px 24px;
cursor: pointer;
font-size: 1em;
font-weight: 600;
font-family: var(--font-mono);
text-transform: uppercase;
transition: all 0.3s ease;
margin-right: 10px;
margin-bottom: 10px;
}
button::before { content: '[ '; }
button::after { content: ' ]'; }
button:hover {
background: rgba(0, 255, 65, 0.15);
color: var(--terminal-amber);
border-color: var(--terminal-amber);
text-shadow: var(--glow-amber);
box-shadow: var(--glow-amber);
transform: translateY(-2px);
}
button.danger {
color: var(--status-failed);
border-color: var(--status-failed);
}
button.danger:hover {
background: rgba(220, 53, 69, 0.15);
text-shadow: 0 0 5px var(--status-failed), 0 0 10px var(--status-failed);
}
button.small {
padding: 6px 12px;
font-size: 0.85em;
}
.worker-item, .execution-item, .workflow-item {
padding: 15px;
border: 2px solid var(--terminal-green);
border-radius: 0;
margin-bottom: 10px;
background: var(--bg-secondary);
font-family: var(--font-mono);
transition: all 0.3s;
}
.worker-item:hover, .execution-item:hover, .workflow-item:hover {
background: rgba(0, 255, 65, 0.08);
box-shadow: inset 0 0 20px rgba(0, 255, 65, 0.1);
}
.workflow-name {
font-weight: 600;
color: var(--terminal-amber);
font-size: 1.1em;
margin-bottom: 5px;
font-family: var(--font-mono);
text-shadow: var(--glow-amber);
}
.workflow-name::before {
content: '> ';
color: var(--terminal-green);
}
.workflow-desc {
color: var(--terminal-green);
font-size: 0.9em;
margin-bottom: 10px;
font-family: var(--font-mono);
}
.loading {
text-align: center;
padding: 20px;
color: var(--terminal-green);
font-family: var(--font-mono);
}
.loading::after {
content: '...';
animation: loading-dots 1.5s steps(4, end) infinite;
}
@keyframes loading-dots {
0%, 20% { content: '.'; }
40% { content: '..'; }
60%, 100% { content: '...'; }
}
.empty {
text-align: center;
padding: 30px;
color: var(--text-muted);
font-family: var(--font-mono);
}
.empty::before {
content: '[ NO DATA ]';
display: block;
font-size: 1.2rem;
color: var(--terminal-green);
margin-bottom: 10px;
}
.timestamp {
font-size: 0.85em;
color: var(--text-muted);
font-family: var(--font-mono);
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.85);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal.show { display: flex; }
.modal-content {
background: var(--bg-primary);
padding: 0;
border: 3px double var(--terminal-green);
border-radius: 0;
max-width: 600px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 0 30px rgba(0, 255, 65, 0.3);
position: relative;
}
.modal-content::before {
content: '╔';
position: absolute;
top: -3px;
left: -3px;
font-size: 1.5rem;
color: var(--terminal-green);
line-height: 1;
z-index: 10;
}
.modal-content::after {
content: '╗';
position: absolute;
top: -3px;
right: -3px;
font-size: 1.5rem;
color: var(--terminal-green);
line-height: 1;
z-index: 10;
}
.modal-content h2 {
margin: 0;
padding: 20px 30px;
background: var(--bg-secondary);
color: var(--terminal-amber);
border-bottom: 2px solid var(--terminal-green);
font-family: var(--font-mono);
text-shadow: var(--glow-amber);
}
.modal-content h2::before {
content: '═══ ';
color: var(--terminal-green);
}
.modal-content h2::after {
content: ' ═══';
color: var(--terminal-green);
}
input, textarea, select {
width: 100%;
padding: 12px;
margin-bottom: 15px;
border: 2px solid var(--terminal-green);
border-radius: 0;
font-size: 1em;
font-family: var(--font-mono);
background: var(--bg-primary);
color: var(--terminal-green);
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.5);
}
input:focus, textarea:focus, select:focus {
outline: none;
border-color: var(--terminal-amber);
box-shadow: var(--glow-amber), inset 0 0 10px rgba(0, 0, 0, 0.5);
background: rgba(0, 255, 65, 0.05);
}
textarea { min-height: 100px; }
.tab-content { display: none; }
.tab-content.active { display: block; }
.log-entry {
padding: 10px;
background: var(--bg-secondary);
border-left: 3px solid var(--terminal-green);
margin-bottom: 10px;
font-family: var(--font-mono);
font-size: 0.9em;
color: var(--terminal-green);
}
.log-entry::before {
content: '> ';
color: var(--terminal-amber);
font-weight: bold;
}
.prompt-box {
background: rgba(255, 176, 0, 0.1);
border: 2px solid var(--terminal-amber);
padding: 20px;
border-radius: 0;
margin: 20px 0;
}
.prompt-box h3 {
color: var(--terminal-amber);
margin-bottom: 15px;
font-family: var(--font-mono);
text-shadow: var(--glow-amber);
}
.prompt-box h3::before {
content: '⏳ ';
}
/* Boot Overlay */
.boot-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--bg-primary);
z-index: 99999;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.5s;
}
#boot-text {
font-family: var(--font-mono);
color: var(--terminal-green);
text-shadow: var(--glow-green);
font-size: 0.95rem;
line-height: 1.6;
white-space: pre;
padding: 2rem;
}
/* Formatted Log Entry Styles */
.log-entry {
background: #000;
border: 1px solid var(--terminal-green);
border-left: 3px solid var(--terminal-green);
padding: 12px;
margin-bottom: 12px;
font-family: var(--font-mono);
font-size: 0.9em;
}
.log-entry.success {
border-left-color: var(--terminal-green);
}
.log-entry.failed {
border-left-color: #ff4444;
}
.log-timestamp {
color: var(--terminal-amber);
font-size: 0.85em;
margin-bottom: 6px;
}
.log-title {
color: var(--terminal-green);
font-weight: bold;
text-shadow: var(--glow-green);
margin-bottom: 8px;
font-size: 1.1em;
}
.log-entry.failed .log-title {
color: #ff4444;
text-shadow: 0 0 5px #ff4444;
}
.log-details {
margin-left: 20px;
}
.log-field {
margin: 6px 0;
color: var(--terminal-green);
}
.log-label {
color: var(--terminal-amber);
font-weight: bold;
margin-right: 8px;
}
.log-output {
background: #0a0a0a;
border: 1px solid #003300;
padding: 10px;
margin: 6px 0;
color: var(--terminal-green);
font-family: var(--font-mono);
font-size: 0.9em;
overflow-x: auto;
max-height: 300px;
overflow-y: auto;
}
.log-error {
color: #ff6666;
border-color: #330000;
}
.log-entry code {
background: #001a00;
padding: 2px 6px;
border: 1px solid #003300;
color: var(--terminal-green);
font-family: var(--font-mono);
}
/* Worker Metadata Styles */
.worker-stats {
display: flex;
gap: 15px;
margin-top: 8px;
font-size: 0.85em;
color: var(--terminal-green);
font-family: var(--font-mono);
}
.worker-stats span {
padding: 2px 6px;
background: #001a00;
border: 1px solid #003300;
}
.worker-metadata {
margin-top: 12px;
padding: 10px;
background: #001a00;
border: 1px solid #003300;
font-family: var(--font-mono);
font-size: 0.85em;
}
.meta-row {
display: flex;
padding: 4px 0;
color: var(--terminal-green);
}
.meta-label {
color: var(--terminal-amber);
min-width: 120px;
font-weight: bold;
}
</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>
<button onclick="clearCompletedExecutions()" style="margin-left: 10px;">[ 🗑️ Clear Completed ]</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: var(--terminal-green); margin-bottom: 20px;">Execute a command on selected workers instantly</p>
<div style="display: flex; gap: 10px; margin-bottom: 15px;">
<button onclick="showCommandTemplates()" style="flex: 0;">[ 📋 Templates ]</button>
<button onclick="showCommandHistory()" style="flex: 0;">[ 🕐 History ]</button>
</div>
<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>
<!-- Command Templates Modal -->
<div id="commandTemplatesModal" class="modal">
<div class="modal-content">
<h2>Command Templates</h2>
<div id="templateList" style="max-height: 400px; overflow-y: auto;"></div>
<button onclick="closeModal('commandTemplatesModal')">[ Close ]</button>
</div>
</div>
<!-- Command History Modal -->
<div id="commandHistoryModal" class="modal">
<div class="modal-content">
<h2>Command History</h2>
<div id="historyList" style="max-height: 400px; overflow-y: auto;"></div>
<button onclick="closeModal('commandHistoryModal')">[ 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 => {
const meta = w.metadata ? (typeof w.metadata === 'string' ? JSON.parse(w.metadata) : w.metadata) : null;
const lastSeen = getTimeAgo(new Date(w.last_heartbeat));
return `
<div class="worker-item">
<span class="status ${w.status}">[${w.status === 'online' ? '●' : '○'}]</span>
<strong>${w.name}</strong>
${meta ? `<div class="worker-stats">
<span>CPU: ${meta.cpus || '?'} cores</span>
<span>RAM: ${formatBytes(meta.freeMem)}/${formatBytes(meta.totalMem)}</span>
<span>Tasks: ${meta.activeTasks || 0}/${meta.maxConcurrentTasks || 0}</span>
</div>` : ''}
<div class="timestamp">Last seen: ${lastSeen}</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 = w.metadata ? (typeof w.metadata === 'string' ? JSON.parse(w.metadata) : w.metadata) : null;
const lastSeen = getTimeAgo(new Date(w.last_heartbeat));
const memUsagePercent = meta && meta.totalMem ? ((meta.totalMem - meta.freeMem) / meta.totalMem * 100).toFixed(1) : 0;
const loadAvg = meta && meta.loadavg ? meta.loadavg.map(l => l.toFixed(2)).join(', ') : 'N/A';
return `
<div class="worker-item">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<span class="status ${w.status}">[${w.status === 'online' ? '●' : '○'}]</span>
<strong>${w.name}</strong>
<div class="timestamp">Last seen: ${lastSeen}</div>
${meta ? `
<div class="worker-metadata">
<div class="meta-row">
<span class="meta-label">System:</span>
<span>${meta.platform || 'N/A'} ${meta.arch || ''} | ${meta.cpus || '?'} CPU cores</span>
</div>
<div class="meta-row">
<span class="meta-label">Memory:</span>
<span>${formatBytes(meta.totalMem - meta.freeMem)} / ${formatBytes(meta.totalMem)} (${memUsagePercent}% used)</span>
</div>
<div class="meta-row">
<span class="meta-label">Load Avg:</span>
<span>${loadAvg}</span>
</div>
<div class="meta-row">
<span class="meta-label">Uptime:</span>
<span>${formatUptime(meta.uptime)}</span>
</div>
<div class="meta-row">
<span class="meta-label">Active Tasks:</span>
<span>${meta.activeTasks || 0} / ${meta.maxConcurrentTasks || 0}</span>
</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 || '[Quick Command]'}</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 || '[Quick Command]'}</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 clearCompletedExecutions() {
if (!confirm('Delete all completed and failed executions?')) return;
try {
const response = await fetch('/api/executions');
const executions = await response.json();
const toDelete = executions.filter(e => e.status === 'completed' || e.status === 'failed');
if (toDelete.length === 0) {
alert('No completed or failed executions to delete');
return;
}
for (const execution of toDelete) {
await fetch(`/api/executions/${execution.id}`, { method: 'DELETE' });
}
alert(`Deleted ${toDelete.length} execution(s)`);
refreshData();
} catch (error) {
console.error('Error clearing executions:', error);
alert('Error clearing executions');
}
}
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 += formatLogEntry(log);
});
}
// Add action buttons
html += '<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid var(--terminal-green); display: flex; gap: 10px;">';
// Re-run button (only for quick commands with command in logs)
const commandLog = execution.logs?.find(l => l.action === 'command_sent');
if (commandLog && commandLog.command) {
html += `<button onclick="rerunCommand('${escapeHtml(commandLog.command)}', '${commandLog.worker_id}')">[ 🔄 Re-run Command ]</button>`;
}
// Download logs button
html += `<button onclick="downloadExecutionLogs('${executionId}')">[ 💾 Download Logs ]</button>`;
html += '</div>';
document.getElementById('executionDetails').innerHTML = html;
const modal = document.getElementById('viewExecutionModal');
modal.dataset.executionId = executionId;
modal.classList.add('show');
} catch (error) {
console.error('Error viewing execution:', error);
alert('Error loading execution details');
}
}
function formatLogEntry(log) {
const timestamp = new Date(log.timestamp).toLocaleTimeString();
// Format based on log action type
if (log.action === 'command_sent') {
return `
<div class="log-entry log-command-sent">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title">Command Sent</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Command:</span> <code>${escapeHtml(log.command)}</code></div>
</div>
</div>
`;
}
if (log.action === 'command_result') {
const statusIcon = log.success ? '✓' : '✗';
const statusClass = log.success ? 'success' : 'failed';
return `
<div class="log-entry log-command-result ${statusClass}">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title">${statusIcon} Command Result</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Status:</span> ${log.success ? 'Success' : 'Failed'}</div>
${log.duration ? `<div class="log-field"><span class="log-label">Duration:</span> ${log.duration}ms</div>` : ''}
${log.stdout ? `<div class="log-field"><span class="log-label">Output:</span><pre class="log-output">${escapeHtml(log.stdout)}</pre></div>` : ''}
${log.stderr ? `<div class="log-field"><span class="log-label">Errors:</span><pre class="log-output log-error">${escapeHtml(log.stderr)}</pre></div>` : ''}
${log.error ? `<div class="log-field"><span class="log-label">Error:</span> ${escapeHtml(log.error)}</div>` : ''}
</div>
</div>
`;
}
// Fallback for unknown log types
return `<div class="log-entry"><pre>${JSON.stringify(log, null, 2)}</pre></div>`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return (bytes / Math.pow(k, i)).toFixed(1) + ' ' + sizes[i];
}
function formatUptime(seconds) {
if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h ${minutes}m`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function getTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return `${seconds}s ago`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes}m ago`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
return `${days}d ago`;
}
async function rerunCommand(command, workerId) {
if (!confirm(`Re-run command: ${command}?`)) return;
closeModal('viewExecutionModal');
switchTab('quickcommand');
// Set the worker and command
document.getElementById('quickWorkerSelect').value = workerId;
document.getElementById('quickCommand').value = command;
// Scroll to the command field
document.getElementById('quickCommand').scrollIntoView({ behavior: 'smooth' });
}
async function downloadExecutionLogs(executionId) {
try {
const response = await fetch(`/api/executions/${executionId}`);
const execution = await response.json();
// Create downloadable JSON
const data = {
execution_id: executionId,
workflow_name: execution.workflow_name || '[Quick Command]',
status: execution.status,
started_by: execution.started_by,
started_at: execution.started_at,
completed_at: execution.completed_at,
logs: execution.logs
};
// Create blob and download
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `execution-${executionId}-${new Date().toISOString().split('T')[0]}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
} catch (error) {
console.error('Error downloading logs:', error);
alert('Error downloading execution logs');
}
}
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');
}
}
// Command Templates
const commandTemplates = [
{ name: 'System Info', cmd: 'uname -a', desc: 'Show system information' },
{ name: 'Uptime', cmd: 'uptime', desc: 'Show system uptime and load' },
{ name: 'Disk Usage', cmd: 'df -h', desc: 'Show disk space usage' },
{ name: 'Memory Usage', cmd: 'free -h', desc: 'Show memory usage' },
{ name: 'CPU Info', cmd: 'lscpu', desc: 'Show CPU information' },
{ name: 'Running Processes', cmd: 'ps aux --sort=-%mem | head -20', desc: 'Top 20 processes by memory' },
{ name: 'Network Interfaces', cmd: 'ip addr show', desc: 'Show network interfaces' },
{ name: 'Active Connections', cmd: 'ss -tunap', desc: 'Show active network connections' },
{ name: 'Docker Containers', cmd: 'docker ps -a', desc: 'List all Docker containers' },
{ name: 'System Log Tail', cmd: 'tail -n 50 /var/log/syslog', desc: 'Last 50 lines of system log' },
{ name: 'Who is Logged In', cmd: 'w', desc: 'Show logged in users' },
{ name: 'Last Logins', cmd: 'last -n 20', desc: 'Show last 20 logins' }
];
function showCommandTemplates() {
const html = commandTemplates.map((template, index) => `
<div class="template-item" onclick="useTemplate(${index})" style="cursor: pointer; padding: 12px; margin: 8px 0; background: #001a00; border: 1px solid #003300; border-left: 3px solid var(--terminal-green);">
<div style="color: var(--terminal-green); font-weight: bold; margin-bottom: 4px;">${template.name}</div>
<div style="color: var(--terminal-amber); font-family: var(--font-mono); font-size: 0.9em; margin-bottom: 4px;"><code>${escapeHtml(template.cmd)}</code></div>
<div style="color: #666; font-size: 0.85em;">${template.desc}</div>
</div>
`).join('');
document.getElementById('templateList').innerHTML = html;
document.getElementById('commandTemplatesModal').classList.add('show');
}
function useTemplate(index) {
document.getElementById('quickCommand').value = commandTemplates[index].cmd;
closeModal('commandTemplatesModal');
}
function showCommandHistory() {
const history = JSON.parse(localStorage.getItem('commandHistory') || '[]');
if (history.length === 0) {
document.getElementById('historyList').innerHTML = '<div class="empty">No command history yet</div>';
} else {
const html = history.map((item, index) => `
<div class="history-item" onclick="useHistoryCommand(${index})" style="cursor: pointer; padding: 12px; margin: 8px 0; background: #001a00; border: 1px solid #003300; border-left: 3px solid var(--terminal-amber);">
<div style="color: var(--terminal-green); font-family: var(--font-mono); margin-bottom: 4px;"><code>${escapeHtml(item.command)}</code></div>
<div style="color: #666; font-size: 0.85em;">${new Date(item.timestamp).toLocaleString()} - ${item.worker}</div>
</div>
`).join('');
document.getElementById('historyList').innerHTML = html;
}
document.getElementById('commandHistoryModal').classList.add('show');
}
function useHistoryCommand(index) {
const history = JSON.parse(localStorage.getItem('commandHistory') || '[]');
document.getElementById('quickCommand').value = history[index].command;
closeModal('commandHistoryModal');
}
function addToCommandHistory(command, workerName) {
const history = JSON.parse(localStorage.getItem('commandHistory') || '[]');
// Add to beginning, limit to 50 items
history.unshift({
command: command,
worker: workerName,
timestamp: new Date().toISOString()
});
// Keep only last 50 commands
if (history.length > 50) {
history.splice(50);
}
localStorage.setItem('commandHistory', JSON.stringify(history));
}
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;
}
// Find worker name for history
const worker = workers.find(w => w.id === workerId);
const workerName = worker ? worker.name : 'Unknown';
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();
// Add to command history
addToCommandHistory(command, workerName);
resultDiv.innerHTML = `
<div style="background: #001a00; border: 2px solid var(--terminal-green); padding: 15px;">
<strong style="color: var(--terminal-green);">✓ Command sent successfully!</strong>
<div style="margin-top: 10px; font-family: var(--font-mono); font-size: 0.9em; color: var(--terminal-green);">
Execution ID: ${data.execution_id}
</div>
<div style="margin-top: 10px; color: var(--terminal-amber);">
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);
// Handle specific message types
if (data.type === 'command_result') {
// Display command result in real-time
console.log(`Command result received for execution ${data.execution_id}`);
console.log(`Success: ${data.success}`);
console.log(`Output: ${data.stdout}`);
if (data.stderr) {
console.log(`Error: ${data.stderr}`);
}
// If viewing execution details, refresh that specific execution
const executionModal = document.getElementById('viewExecutionModal');
if (executionModal && executionModal.classList.contains('show')) {
// Reload execution details to show new logs
const executionId = executionModal.dataset.executionId;
if (executionId === data.execution_id) {
viewExecution(executionId);
}
}
// Refresh execution list to show updated status
loadExecutions();
}
if (data.type === 'workflow_result') {
console.log(`Workflow ${data.status} for execution ${data.execution_id}`);
// Refresh execution list
loadExecutions();
// If viewing this execution, refresh details
const executionModal = document.getElementById('viewExecutionModal');
if (executionModal && executionModal.classList.contains('show')) {
const executionId = executionModal.dataset.executionId;
if (executionId === data.execution_id) {
viewExecution(executionId);
}
}
}
if (data.type === 'worker_update') {
console.log(`Worker ${data.worker_id} status: ${data.status}`);
loadWorkers();
}
if (data.type === 'execution_started' || data.type === 'execution_status') {
loadExecutions();
}
if (data.type === 'workflow_created' || data.type === 'workflow_deleted') {
loadWorkflows();
}
// Generic refresh for other message types
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted'].includes(data.type)) {
refreshData();
}
};
ws.onclose = () => {
console.log('WebSocket closed, reconnecting...');
setTimeout(connectWebSocket, 5000);
};
}
// Initialize
loadUser().then((success) => {
if (success) {
refreshData();
connectWebSocket();
setInterval(refreshData, 30000);
}
});
</script>
<!-- Terminal Boot Sequence -->
<div id="boot-sequence" class="boot-overlay" style="display: none;">
<pre id="boot-text"></pre>
</div>
<script>
function showBootSequence() {
const bootText = document.getElementById('boot-text');
const bootOverlay = document.getElementById('boot-sequence');
bootOverlay.style.display = 'flex';
const messages = [
'╔═══════════════════════════════════════╗',
'║ PULSE ORCHESTRATION TERMINAL v1.0 ║',
'║ BOOTING SYSTEM... ║',
'╚═══════════════════════════════════════╝',
'',
'[ OK ] Loading kernel modules...',
'[ OK ] Initializing workflow engine...',
'[ OK ] Mounting worker connections...',
'[ OK ] Starting WebSocket services...',
'[ OK ] Rendering terminal interface...',
'',
'> SYSTEM READY ✓',
''
];
let i = 0;
const interval = setInterval(() => {
if (i < messages.length) {
bootText.textContent += messages[i] + '\n';
i++;
} else {
setTimeout(() => {
bootOverlay.style.opacity = '0';
setTimeout(() => {
bootOverlay.style.display = 'none';
}, 500);
}, 500);
clearInterval(interval);
}
}, 80);
}
// Run on first visit only (per session)
if (!sessionStorage.getItem('booted')) {
showBootSequence();
sessionStorage.setItem('booted', 'true');
}
</script>
</body>
</html>