Files
pulse/public/index.html

2640 lines
112 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;
}
/* Terminal Cursor Blink */
@keyframes cursor-blink {
0%, 49% { opacity: 1; }
50%, 100% { opacity: 0; }
}
.terminal-cursor::after {
content: '▋';
animation: cursor-blink 1s step-end infinite;
color: var(--terminal-green);
}
/* Hover effects for execution items */
.execution-item {
transition: all 0.2s ease;
cursor: pointer;
}
.execution-item:hover {
background: #001a00;
border-left-width: 5px;
transform: translateX(3px);
}
.worker-item:hover {
background: #001a00;
border-left-width: 5px;
}
.workflow-item:hover {
background: #001a00;
border-left-width: 5px;
}
/* Loading pulse effect */
.loading {
animation: loading-pulse 1.5s ease-in-out infinite;
}
@keyframes loading-pulse {
0%, 100% { opacity: 0.6; }
50% { opacity: 1; }
}
/* Success/Error message animations */
@keyframes slide-in {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.log-entry {
animation: slide-in 0.3s ease-out;
}
</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>
<button class="tab" onclick="switchTab('scheduler')">⏰ Scheduler</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>
<!-- Search and Filter Section -->
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 20px;">
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 15px;">
<!-- Search Box -->
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--terminal-amber);">🔍 Search:</label>
<input type="text" id="executionSearch" placeholder="Search by command, execution ID, or workflow name..."
oninput="filterExecutions()"
style="width: 100%; padding: 10px; margin: 0;">
</div>
<!-- Status Filter -->
<div>
<label style="display: block; margin-bottom: 8px; font-weight: 600; color: var(--terminal-amber);">📊 Status Filter:</label>
<select id="statusFilter" onchange="filterExecutions()" style="width: 100%; padding: 10px; margin: 0;">
<option value="">All Statuses</option>
<option value="running">Running</option>
<option value="completed">Completed</option>
<option value="failed">Failed</option>
<option value="waiting">Waiting</option>
</select>
</div>
</div>
<div style="display: flex; gap: 10px; align-items: center;">
<button onclick="clearFilters()" class="small">[ Clear Filters ]</button>
<span id="filterStats" style="color: var(--terminal-green); font-size: 0.9em; font-family: var(--font-mono);"></span>
</div>
</div>
<button onclick="refreshData()">[ 🔄 Refresh ]</button>
<button onclick="clearCompletedExecutions()" style="margin-left: 10px;">[ 🗑️ Clear Completed ]</button>
<button onclick="toggleCompareMode()" id="compareModeBtn" style="margin-left: 10px;">[ 📊 Compare Mode ]</button>
<button onclick="compareSelectedExecutions()" id="compareBtn" style="margin-left: 10px; display: none;">[ ⚖️ Compare Selected ]</button>
<div id="compareInstructions" style="display: none; background: rgba(255, 176, 0, 0.1); border: 2px solid var(--terminal-amber); padding: 12px; margin: 15px 0; color: var(--terminal-amber);">
Select 2-5 executions to compare their outputs. Click executions to toggle selection.
</div>
<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;">Execution Mode:</label>
<div style="margin-bottom: 20px;">
<label style="display: inline-flex; align-items: center; margin-right: 20px; cursor: pointer;">
<input type="radio" name="execMode" value="single" checked onchange="toggleWorkerSelection()" style="width: auto; margin-right: 8px;">
<span>Single Worker</span>
</label>
<label style="display: inline-flex; align-items: center; cursor: pointer;">
<input type="radio" name="execMode" value="multi" onchange="toggleWorkerSelection()" style="width: auto; margin-right: 8px;">
<span>Multiple Workers</span>
</label>
</div>
<div id="singleWorkerMode">
<label style="display: block; margin-bottom: 10px; font-weight: 600;">Select Worker:</label>
<select id="quickWorkerSelect">
<option value="">Loading workers...</option>
</select>
</div>
<div id="multiWorkerMode" style="display: none;">
<label style="display: block; margin-bottom: 10px; font-weight: 600;">Select Workers:</label>
<div id="workerCheckboxList" style="background: var(--bg-primary); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 15px; max-height: 200px; overflow-y: auto;">
<div class="loading">Loading workers...</div>
</div>
<div style="margin-bottom: 15px;">
<button onclick="selectAllWorkers()" class="small">[ Select All ]</button>
<button onclick="selectOnlineWorkers()" class="small">[ Online Only ]</button>
<button onclick="deselectAllWorkers()" class="small">[ Clear All ]</button>
</div>
</div>
<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>
<!-- Scheduler Tab -->
<div id="scheduler" class="tab-content">
<div class="card">
<h3>⏰ Scheduled Commands</h3>
<p style="color: var(--terminal-green); margin-bottom: 20px;">Automate command execution with flexible scheduling</p>
<button onclick="showCreateSchedule()">[ Create Schedule ]</button>
<button onclick="refreshData()" style="margin-left: 10px;">[ 🔄 Refresh ]</button>
<div id="scheduleList" style="margin-top: 20px;"><div class="loading">Loading...</div></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>
<!-- Compare Executions Modal -->
<div id="compareExecutionsModal" class="modal">
<div class="modal-content" style="max-width: 90%; max-height: 90vh;">
<h2>⚖️ Execution Comparison</h2>
<div id="compareContent" style="max-height: 70vh; overflow-y: auto; padding: 20px;"></div>
<button onclick="closeModal('compareExecutionsModal')">[ Close ]</button>
</div>
</div>
<!-- Create Schedule Modal -->
<div id="createScheduleModal" class="modal">
<div class="modal-content">
<h2>Create Scheduled Command</h2>
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Schedule Name:</label>
<input type="text" id="scheduleName" placeholder="e.g., Daily System Check">
<label style="display: block; margin-bottom: 8px; margin-top: 15px; font-weight: 600;">Command:</label>
<textarea id="scheduleCommand" placeholder="Enter command to execute"></textarea>
<label style="display: block; margin-bottom: 8px; margin-top: 15px; font-weight: 600;">Target Workers:</label>
<div id="scheduleWorkerList" style="background: var(--bg-primary); border: 2px solid var(--terminal-green); padding: 15px; margin-bottom: 15px; max-height: 150px; overflow-y: auto;">
<div class="loading">Loading workers...</div>
</div>
<label style="display: block; margin-bottom: 8px; margin-top: 15px; font-weight: 600;">Schedule Type:</label>
<select id="scheduleType" onchange="updateScheduleInput()">
<option value="interval">Every X Minutes</option>
<option value="hourly">Every X Hours</option>
<option value="daily">Daily at Time</option>
</select>
<div id="scheduleInputContainer" style="margin-top: 15px;">
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Interval (minutes):</label>
<input type="number" id="scheduleValue" placeholder="e.g., 30" min="1">
</div>
<div style="margin-top: 20px;">
<button onclick="createSchedule()">[ Create Schedule ]</button>
<button onclick="closeModal('createScheduleModal')" style="margin-left: 10px;">[ Cancel ]</button>
</div>
</div>
</div>
<script>
let currentUser = null;
let ws = null;
let workers = [];
let allExecutions = []; // Store all loaded executions for filtering
let compareMode = false;
let selectedExecutions = [];
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 (single mode)
const select = document.getElementById('quickWorkerSelect');
if (select) {
select.innerHTML = workers.map(w =>
`<option value="${w.id}">${w.name} (${w.status})</option>`
).join('');
}
// Update worker checkboxes (multi mode)
const checkboxList = document.getElementById('workerCheckboxList');
if (checkboxList) {
checkboxList.innerHTML = workers.length === 0 ?
'<div class="empty">No workers available</div>' :
workers.map(w => `
<label style="display: block; margin-bottom: 10px; cursor: pointer; padding: 8px; border: 1px solid var(--terminal-green); background: ${w.status === 'online' ? 'rgba(0, 255, 65, 0.05)' : 'transparent'};">
<input type="checkbox" name="workerCheckbox" value="${w.id}" data-status="${w.status}" style="width: auto; margin-right: 8px;">
<span class="status ${w.status}" style="padding: 2px 8px; font-size: 0.8em;">[${w.status === 'online' ? '●' : '○'}]</span>
<strong>${w.name}</strong>
</label>
`).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 loadSchedules() {
try {
const response = await fetch('/api/scheduled-commands');
const schedules = await response.json();
const html = schedules.length === 0 ?
'<div class="empty">No scheduled commands yet</div>' :
schedules.map(s => {
const workerIds = JSON.parse(s.worker_ids);
const workerNames = workerIds.map(id => {
const w = workers.find(worker => worker.id === id);
return w ? w.name : id.substring(0, 8);
}).join(', ');
let scheduleDesc = '';
if (s.schedule_type === 'interval') {
scheduleDesc = `Every ${s.schedule_value} minutes`;
} else if (s.schedule_type === 'hourly') {
scheduleDesc = `Every ${s.schedule_value} hour(s)`;
} else if (s.schedule_type === 'daily') {
scheduleDesc = `Daily at ${s.schedule_value}`;
}
const nextRun = s.next_run ? new Date(s.next_run).toLocaleString() : 'Not scheduled';
const lastRun = s.last_run ? new Date(s.last_run).toLocaleString() : 'Never';
return `
<div class="workflow-item" style="opacity: ${s.enabled ? 1 : 0.6};">
<div style="display: flex; justify-content: space-between; align-items: start;">
<div style="flex: 1;">
<div class="workflow-name">${s.name}</div>
<div style="color: var(--terminal-green); font-family: var(--font-mono); font-size: 0.9em; margin: 8px 0;">
Command: <code>${escapeHtml(s.command)}</code>
</div>
<div style="color: var(--terminal-amber); font-size: 0.9em; margin-bottom: 5px;">
📅 ${scheduleDesc}
</div>
<div style="color: var(--text-muted); font-size: 0.85em;">
Workers: ${workerNames}
</div>
<div class="timestamp">
Last run: ${lastRun} | Next run: ${nextRun}
</div>
</div>
<div style="margin-left: 15px;">
<span class="status ${s.enabled ? 'online' : 'offline'}" style="font-size: 0.85em;">
${s.enabled ? 'ENABLED' : 'DISABLED'}
</span>
</div>
</div>
<div style="margin-top: 10px;">
<button onclick="toggleSchedule('${s.id}')" class="small">
${s.enabled ? '⏸️ Disable' : '▶️ Enable'}
</button>
<button class="danger small" onclick="deleteSchedule('${s.id}', '${s.name}')">🗑️ Delete</button>
</div>
</div>
`;
}).join('');
document.getElementById('scheduleList').innerHTML = html;
} catch (error) {
console.error('Error loading schedules:', error);
}
}
function showCreateSchedule() {
// Populate worker checkboxes
const workerList = document.getElementById('scheduleWorkerList');
workerList.innerHTML = workers.length === 0 ?
'<div class="empty">No workers available</div>' :
workers.map(w => `
<label style="display: block; margin-bottom: 10px; cursor: pointer; padding: 8px; border: 1px solid var(--terminal-green);">
<input type="checkbox" name="scheduleWorkerCheckbox" value="${w.id}" style="width: auto; margin-right: 8px;">
<span class="status ${w.status}" style="padding: 2px 8px; font-size: 0.8em;">[${w.status === 'online' ? '●' : '○'}]</span>
<strong>${w.name}</strong>
</label>
`).join('');
document.getElementById('createScheduleModal').classList.add('show');
}
function updateScheduleInput() {
const scheduleType = document.getElementById('scheduleType').value;
const container = document.getElementById('scheduleInputContainer');
if (scheduleType === 'interval') {
container.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Interval (minutes):</label>
<input type="number" id="scheduleValue" placeholder="e.g., 30" min="1">
`;
} else if (scheduleType === 'hourly') {
container.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Every X Hours:</label>
<input type="number" id="scheduleValue" placeholder="e.g., 2" min="1" max="24">
`;
} else if (scheduleType === 'daily') {
container.innerHTML = `
<label style="display: block; margin-bottom: 8px; font-weight: 600;">Time (HH:MM):</label>
<input type="time" id="scheduleValue">
`;
}
}
async function createSchedule() {
const name = document.getElementById('scheduleName').value;
const command = document.getElementById('scheduleCommand').value;
const scheduleType = document.getElementById('scheduleType').value;
const scheduleValue = document.getElementById('scheduleValue').value;
const selectedWorkers = Array.from(document.querySelectorAll('input[name="scheduleWorkerCheckbox"]:checked')).map(cb => cb.value);
if (!name || !command || !scheduleValue || selectedWorkers.length === 0) {
alert('Please fill in all fields and select at least one worker');
return;
}
try {
const response = await fetch('/api/scheduled-commands', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name,
command,
worker_ids: selectedWorkers,
schedule_type: scheduleType,
schedule_value: scheduleValue
})
});
if (response.ok) {
closeModal('createScheduleModal');
showTerminalNotification('Schedule created successfully', 'success');
loadSchedules();
// Clear form
document.getElementById('scheduleName').value = '';
document.getElementById('scheduleCommand').value = '';
document.getElementById('scheduleValue').value = '';
} else {
const error = await response.json();
showTerminalNotification('Failed to create schedule: ' + error.error, 'error');
}
} catch (error) {
console.error('Error creating schedule:', error);
showTerminalNotification('Error creating schedule', 'error');
}
}
async function toggleSchedule(scheduleId) {
try {
const response = await fetch(`/api/scheduled-commands/${scheduleId}/toggle`, {
method: 'PUT'
});
if (response.ok) {
const data = await response.json();
showTerminalNotification(`Schedule ${data.enabled ? 'enabled' : 'disabled'}`, 'success');
loadSchedules();
} else {
showTerminalNotification('Failed to toggle schedule', 'error');
}
} catch (error) {
console.error('Error toggling schedule:', error);
showTerminalNotification('Error toggling schedule', 'error');
}
}
async function deleteSchedule(scheduleId, name) {
if (!confirm(`Delete scheduled command: ${name}?`)) return;
try {
const response = await fetch(`/api/scheduled-commands/${scheduleId}`, {
method: 'DELETE'
});
if (response.ok) {
showTerminalNotification('Schedule deleted', 'success');
loadSchedules();
} else {
showTerminalNotification('Failed to delete schedule', 'error');
}
} catch (error) {
console.error('Error deleting schedule:', error);
showTerminalNotification('Error deleting schedule', 'error');
}
}
let executionOffset = 0;
const executionLimit = 50;
async function loadExecutions(append = false) {
try {
if (!append) executionOffset = 0;
const response = await fetch(`/api/executions?limit=${executionLimit}&offset=${executionOffset}`);
const data = await response.json();
const executions = data.executions || data; // Handle old and new API format
// Store executions for filtering
if (append) {
allExecutions = allExecutions.concat(executions);
} else {
allExecutions = executions;
}
// Dashboard view (always first 5)
if (!append) {
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;
}
// Apply filters and render
renderFilteredExecutions();
// Add "Load More" button if there are more executions
if (data.hasMore) {
const loadMoreBtn = `<button onclick="loadMoreExecutions()" style="width: 100%; margin-top: 15px;">[ Load More Executions ]</button>`;
document.getElementById('executionList').innerHTML += loadMoreBtn;
}
} catch (error) {
console.error('Error loading executions:', error);
}
}
function renderFilteredExecutions() {
const searchTerm = (document.getElementById('executionSearch')?.value || '').toLowerCase();
const statusFilter = document.getElementById('statusFilter')?.value || '';
// Filter executions
let filtered = allExecutions.filter(e => {
// Status filter
if (statusFilter && e.status !== statusFilter) return false;
// Search filter (search in workflow name, execution ID, and logs)
if (searchTerm) {
const workflowName = (e.workflow_name || '[Quick Command]').toLowerCase();
const executionId = e.id.toLowerCase();
// Try to extract command from logs if it's a quick command
let commandText = '';
try {
const logs = typeof e.logs === 'string' ? JSON.parse(e.logs) : e.logs;
if (logs && logs.length > 0 && logs[0].command) {
commandText = logs[0].command.toLowerCase();
}
} catch (err) {
// Ignore parsing errors
}
const matchFound = workflowName.includes(searchTerm) ||
executionId.includes(searchTerm) ||
commandText.includes(searchTerm);
if (!matchFound) return false;
}
return true;
});
// Update filter stats
const statsEl = document.getElementById('filterStats');
if (statsEl) {
if (searchTerm || statusFilter) {
statsEl.textContent = `Showing ${filtered.length} of ${allExecutions.length} executions`;
} else {
statsEl.textContent = '';
}
}
// Render filtered results
const fullHtml = filtered.length === 0 ?
'<div class="empty">No executions match your filters</div>' :
filtered.map(e => {
const isSelected = selectedExecutions.includes(e.id);
const clickHandler = compareMode ? `toggleExecutionSelection('${e.id}')` : `viewExecution('${e.id}')`;
const selectedStyle = isSelected ? 'background: rgba(255, 176, 0, 0.2); border-left-width: 5px; border-left-color: var(--terminal-amber);' : '';
return `
<div class="execution-item" onclick="${clickHandler}" style="${selectedStyle} cursor: pointer;">
${compareMode && isSelected ? '<span style="color: var(--terminal-amber); margin-right: 8px;">✓</span>' : ''}
<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;
}
function filterExecutions() {
renderFilteredExecutions();
}
function clearFilters() {
document.getElementById('executionSearch').value = '';
document.getElementById('statusFilter').value = '';
renderFilteredExecutions();
}
function toggleCompareMode() {
compareMode = !compareMode;
selectedExecutions = [];
const btn = document.getElementById('compareModeBtn');
const compareBtn = document.getElementById('compareBtn');
const instructions = document.getElementById('compareInstructions');
if (compareMode) {
btn.textContent = '[ ✗ Exit Compare Mode ]';
btn.style.borderColor = 'var(--terminal-amber)';
btn.style.color = 'var(--terminal-amber)';
compareBtn.style.display = 'inline-block';
instructions.style.display = 'block';
} else {
btn.textContent = '[ 📊 Compare Mode ]';
btn.style.borderColor = '';
btn.style.color = '';
compareBtn.style.display = 'none';
instructions.style.display = 'none';
}
renderFilteredExecutions();
}
function toggleExecutionSelection(executionId) {
const index = selectedExecutions.indexOf(executionId);
if (index > -1) {
selectedExecutions.splice(index, 1);
} else {
if (selectedExecutions.length >= 5) {
showTerminalNotification('Maximum 5 executions can be compared', 'error');
return;
}
selectedExecutions.push(executionId);
}
renderFilteredExecutions();
// Update compare button text
const compareBtn = document.getElementById('compareBtn');
if (selectedExecutions.length >= 2) {
compareBtn.textContent = `[ ⚖️ Compare Selected (${selectedExecutions.length}) ]`;
} else {
compareBtn.textContent = '[ ⚖️ Compare Selected ]';
}
}
async function compareSelectedExecutions() {
if (selectedExecutions.length < 2) {
showTerminalNotification('Please select at least 2 executions to compare', 'error');
return;
}
// Fetch detailed data for all selected executions
const executionDetails = [];
for (const execId of selectedExecutions) {
try {
const response = await fetch(`/api/executions/${execId}`);
if (response.ok) {
executionDetails.push(await response.json());
}
} catch (error) {
console.error('Error fetching execution:', error);
}
}
if (executionDetails.length < 2) {
showTerminalNotification('Failed to load execution details', 'error');
return;
}
// Generate comparison view
let comparisonHtml = '<div style="display: grid; gap: 20px;">';
// Summary table
comparisonHtml += `
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px;">
<h3 style="margin-top: 0; color: var(--terminal-amber);">Comparison Summary</h3>
<table style="width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 0.9em;">
<thead>
<tr style="border-bottom: 2px solid var(--terminal-green);">
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Execution</th>
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Status</th>
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Started</th>
<th style="text-align: left; padding: 8px; color: var(--terminal-amber);">Duration</th>
</tr>
</thead>
<tbody>
${executionDetails.map((exec, idx) => {
const duration = exec.completed_at ?
Math.round((new Date(exec.completed_at) - new Date(exec.started_at)) / 1000) + 's' :
'Running...';
return `
<tr style="border-bottom: 1px solid #003300;">
<td style="padding: 8px; color: var(--terminal-green);">Execution ${idx + 1}</td>
<td style="padding: 8px;"><span class="status ${exec.status}" style="font-size: 0.85em;">${exec.status}</span></td>
<td style="padding: 8px; color: var(--terminal-green);">${new Date(exec.started_at).toLocaleString()}</td>
<td style="padding: 8px; color: var(--terminal-green);">${duration}</td>
</tr>
`;
}).join('')}
</tbody>
</table>
</div>
`;
// Side-by-side output comparison
comparisonHtml += `
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px;">
<h3 style="margin-top: 0; color: var(--terminal-amber);">Output Comparison</h3>
<div style="display: grid; grid-template-columns: repeat(${executionDetails.length}, 1fr); gap: 15px;">
${executionDetails.map((exec, idx) => {
const logs = typeof exec.logs === 'string' ? JSON.parse(exec.logs) : exec.logs;
const resultLog = logs.find(l => l.action === 'command_result');
const stdout = resultLog?.stdout || 'No output';
const stderr = resultLog?.stderr || '';
return `
<div style="border: 2px solid var(--terminal-green); background: #000;">
<div style="background: var(--bg-secondary); padding: 10px; border-bottom: 2px solid var(--terminal-green);">
<strong style="color: var(--terminal-amber);">Execution ${idx + 1}</strong>
<div style="font-size: 0.85em; color: var(--terminal-green);">
${exec.workflow_name || '[Quick Command]'}
</div>
</div>
<div style="padding: 12px;">
${stdout ? `
<div style="margin-bottom: 10px;">
<div style="color: var(--terminal-amber); font-weight: bold; margin-bottom: 5px;">STDOUT:</div>
<pre style="margin: 0; color: var(--terminal-green); font-size: 0.85em; max-height: 400px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(stdout)}</pre>
</div>
` : ''}
${stderr ? `
<div>
<div style="color: #ff4444; font-weight: bold; margin-bottom: 5px;">STDERR:</div>
<pre style="margin: 0; color: #ff4444; font-size: 0.85em; max-height: 200px; overflow-y: auto; white-space: pre-wrap;">${escapeHtml(stderr)}</pre>
</div>
` : ''}
</div>
</div>
`;
}).join('')}
</div>
</div>
`;
// Diff analysis (simple line-by-line comparison for 2 executions)
if (executionDetails.length === 2) {
const logs1 = typeof executionDetails[0].logs === 'string' ? JSON.parse(executionDetails[0].logs) : executionDetails[0].logs;
const logs2 = typeof executionDetails[1].logs === 'string' ? JSON.parse(executionDetails[1].logs) : executionDetails[1].logs;
const result1 = logs1.find(l => l.action === 'command_result');
const result2 = logs2.find(l => l.action === 'command_result');
const stdout1 = result1?.stdout || '';
const stdout2 = result2?.stdout || '';
const lines1 = stdout1.split('\n');
const lines2 = stdout2.split('\n');
const maxLines = Math.max(lines1.length, lines2.length);
let diffLines = [];
let identicalCount = 0;
let differentCount = 0;
for (let i = 0; i < maxLines; i++) {
const line1 = lines1[i] || '';
const line2 = lines2[i] || '';
if (line1 === line2) {
identicalCount++;
diffLines.push(`<div style="color: #666; padding: 2px;">${i+1}: ${escapeHtml(line1) || '(empty)'}</div>`);
} else {
differentCount++;
diffLines.push(`
<div style="background: rgba(255, 176, 0, 0.1); border-left: 3px solid var(--terminal-amber); padding: 2px; margin: 2px 0;">
<div style="color: var(--terminal-green);">${i+1} [Exec 1]: ${escapeHtml(line1) || '(empty)'}</div>
<div style="color: var(--terminal-amber);">${i+1} [Exec 2]: ${escapeHtml(line2) || '(empty)'}</div>
</div>
`);
}
}
comparisonHtml += `
<div style="background: rgba(0, 255, 65, 0.05); border: 2px solid var(--terminal-green); padding: 15px;">
<h3 style="margin-top: 0; color: var(--terminal-amber);">Diff Analysis</h3>
<div style="margin-bottom: 10px; font-family: var(--font-mono); font-size: 0.9em;">
<span style="color: var(--terminal-green);">✓ Identical lines: ${identicalCount}</span> |
<span style="color: var(--terminal-amber);">≠ Different lines: ${differentCount}</span>
</div>
<div style="background: #000; border: 2px solid var(--terminal-green); padding: 10px; max-height: 400px; overflow-y: auto; font-family: var(--font-mono); font-size: 0.85em;">
${diffLines.join('')}
</div>
</div>
`;
}
comparisonHtml += '</div>';
document.getElementById('compareContent').innerHTML = comparisonHtml;
document.getElementById('compareExecutionsModal').classList.add('show');
}
async function loadMoreExecutions() {
executionOffset += executionLimit;
await loadExecutions(true);
}
async function clearCompletedExecutions() {
if (!confirm('Delete all completed and failed executions?')) return;
try {
const response = await fetch('/api/executions?limit=1000'); // Get all executions
const data = await response.json();
const executions = data.executions || data; // Handle new pagination format
const toDelete = executions.filter(e => e.status === 'completed' || e.status === 'failed');
if (toDelete.length === 0) {
alert('No completed or failed executions to delete');
return;
}
let deleted = 0;
for (const execution of toDelete) {
const deleteResponse = await fetch(`/api/executions/${execution.id}`, { method: 'DELETE' });
if (deleteResponse.ok) deleted++;
}
showTerminalNotification(`Deleted ${deleted} execution(s)`, 'success');
refreshData();
} catch (error) {
console.error('Error clearing executions:', error);
showTerminalNotification('Error clearing executions', 'error');
}
}
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;">';
// Abort button (only for running executions)
if (execution.status === 'running') {
html += `<button onclick="abortExecution('${executionId}')" style="background-color: var(--terminal-red); border-color: var(--terminal-red);">[ ⛔ Abort Execution ]</button>`;
}
// 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>
`;
}
// Workflow step logs
if (log.action === 'step_started') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-amber);">▶️ Step ${log.step}: ${log.step_name}</div>
</div>
`;
}
if (log.action === 'step_completed') {
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);">✓ Step ${log.step} Completed: ${log.step_name}</div>
</div>
`;
}
if (log.action === 'waiting') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-amber);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-amber);">⏳ Waiting ${log.duration} seconds...</div>
</div>
`;
}
if (log.action === 'no_workers') {
return `
<div class="log-entry failed">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title">✗ Step ${log.step}: No Workers Available</div>
<div class="log-details">
<div class="log-field">${escapeHtml(log.message)}</div>
</div>
</div>
`;
}
if (log.action === 'worker_offline') {
return `
<div class="log-entry" style="border-left-color: #ff4444;">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: #ff4444;">⚠️ Worker Offline</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Worker ID:</span> ${log.worker_id}</div>
</div>
</div>
`;
}
if (log.action === 'workflow_error') {
return `
<div class="log-entry failed">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title">✗ Workflow Error</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Error:</span> ${escapeHtml(log.error)}</div>
</div>
</div>
`;
}
if (log.action === 'execution_aborted') {
return `
<div class="log-entry" style="border-left-color: var(--terminal-red);">
<div class="log-timestamp">[${timestamp}]</div>
<div class="log-title" style="color: var(--terminal-red);">⛔ Execution Aborted</div>
<div class="log-details">
<div class="log-field"><span class="log-label">Aborted by:</span> ${escapeHtml(log.aborted_by)}</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 abortExecution(executionId) {
if (!confirm('Are you sure you want to abort this execution? This will mark it as failed.')) return;
try {
const response = await fetch(`/api/executions/${executionId}/abort`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
if (response.ok) {
showTerminalNotification('Execution aborted', 'success');
closeModal('viewExecutionModal');
refreshData();
} else {
alert('Failed to abort execution');
}
} catch (error) {
console.error('Error aborting execution:', error);
alert('Error aborting execution');
}
}
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));
}
function toggleWorkerSelection() {
const mode = document.querySelector('input[name="execMode"]:checked').value;
const singleMode = document.getElementById('singleWorkerMode');
const multiMode = document.getElementById('multiWorkerMode');
if (mode === 'single') {
singleMode.style.display = 'block';
multiMode.style.display = 'none';
} else {
singleMode.style.display = 'none';
multiMode.style.display = 'block';
}
}
function selectAllWorkers() {
document.querySelectorAll('input[name="workerCheckbox"]').forEach(cb => {
cb.checked = true;
});
}
function selectOnlineWorkers() {
document.querySelectorAll('input[name="workerCheckbox"]').forEach(cb => {
cb.checked = cb.getAttribute('data-status') === 'online';
});
}
function deselectAllWorkers() {
document.querySelectorAll('input[name="workerCheckbox"]').forEach(cb => {
cb.checked = false;
});
}
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;
}
let definition;
try {
definition = JSON.parse(definitionText);
} catch (error) {
alert('Invalid JSON definition: ' + error.message);
return;
}
try {
const response = await fetch('/api/workflows', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, description, definition })
});
if (response.ok) {
closeModal('createWorkflowModal');
switchTab('workflows');
showTerminalNotification('Workflow created successfully!', 'success');
refreshData();
} else {
alert('Failed to create workflow');
}
} catch (error) {
alert('Error creating workflow: ' + error.message);
}
}
async function executeQuickCommand() {
const command = document.getElementById('quickCommand').value;
const execMode = document.querySelector('input[name="execMode"]:checked').value;
if (!command) {
alert('Please enter a command');
return;
}
const resultDiv = document.getElementById('quickCommandResult');
if (execMode === 'single') {
// Single worker execution
const workerId = document.getElementById('quickWorkerSelect').value;
if (!workerId) {
alert('Please select a worker');
return;
}
const worker = workers.find(w => w.id === workerId);
const workerName = worker ? worker.name : 'Unknown';
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();
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>
`;
terminalBeep('success');
} else {
resultDiv.innerHTML = '<div style="color: #ef4444;">Failed to execute command</div>';
terminalBeep('error');
}
} catch (error) {
console.error('Error executing command:', error);
resultDiv.innerHTML = '<div style="color: #ef4444;">Error: ' + error.message + '</div>';
terminalBeep('error');
}
} else {
// Multi-worker execution
const selectedCheckboxes = document.querySelectorAll('input[name="workerCheckbox"]:checked');
const selectedWorkerIds = Array.from(selectedCheckboxes).map(cb => cb.value);
if (selectedWorkerIds.length === 0) {
alert('Please select at least one worker');
return;
}
resultDiv.innerHTML = `<div class="loading">Executing command on ${selectedWorkerIds.length} worker(s)...</div>`;
const results = [];
let successCount = 0;
let failCount = 0;
for (const workerId of selectedWorkerIds) {
try {
const worker = workers.find(w => w.id === workerId);
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();
results.push({
worker: worker.name,
success: true,
executionId: data.execution_id
});
successCount++;
} else {
results.push({
worker: worker.name,
success: false,
error: 'Failed to execute'
});
failCount++;
}
} catch (error) {
const worker = workers.find(w => w.id === workerId);
results.push({
worker: worker ? worker.name : workerId,
success: false,
error: error.message
});
failCount++;
}
}
// Add to history with multi-worker notation
addToCommandHistory(command, `${selectedWorkerIds.length} workers`);
// Display results summary
resultDiv.innerHTML = `
<div style="background: #001a00; border: 2px solid var(--terminal-green); padding: 15px;">
<strong style="color: var(--terminal-amber);">Multi-Worker Execution Complete</strong>
<div style="margin-top: 10px; font-family: var(--font-mono); font-size: 0.9em;">
<span style="color: var(--terminal-green);">✓ Success: ${successCount}</span> |
<span style="color: #ef4444;">✗ Failed: ${failCount}</span>
</div>
<div style="margin-top: 15px; max-height: 300px; overflow-y: auto;">
${results.map(r => `
<div style="margin-bottom: 8px; padding: 8px; border-left: 3px solid ${r.success ? 'var(--terminal-green)' : '#ef4444'}; background: rgba(0, 0, 0, 0.5);">
<strong>${r.worker}</strong>:
${r.success ?
`<span style="color: var(--terminal-green);">✓ Sent (ID: ${r.executionId.substring(0, 8)}...)</span>` :
`<span style="color: #ef4444;">✗ ${r.error}</span>`
}
</div>
`).join('')}
</div>
<div style="margin-top: 15px; color: var(--terminal-amber);">
Check the Executions tab to see detailed results
</div>
</div>
`;
if (failCount === 0) {
terminalBeep('success');
} else if (successCount > 0) {
terminalBeep('info');
} else {
terminalBeep('error');
}
}
}
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');
}
async function refreshData() {
try {
await loadWorkers();
} catch (e) {
console.error('Error loading workers:', e);
}
try {
await loadWorkflows();
} catch (e) {
console.error('Error loading workflows:', e);
}
try {
await loadExecutions();
} catch (e) {
console.error('Error loading executions:', e);
}
try {
await loadSchedules();
} catch (e) {
console.error('Error loading schedules:', e);
}
}
// Terminal beep sound (Web Audio API)
function terminalBeep(type = 'success') {
try {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const oscillator = audioContext.createOscillator();
const gainNode = audioContext.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioContext.destination);
// Different tones for different events
if (type === 'success') {
oscillator.frequency.value = 800; // Higher pitch for success
} else if (type === 'error') {
oscillator.frequency.value = 200; // Lower pitch for errors
} else {
oscillator.frequency.value = 440; // Standard A note
}
oscillator.type = 'sine';
gainNode.gain.setValueAtTime(0.1, audioContext.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
oscillator.start(audioContext.currentTime);
oscillator.stop(audioContext.currentTime + 0.1);
} catch (error) {
// Silently fail if Web Audio API not supported
}
}
// Show terminal notification
function showTerminalNotification(message, type = 'info') {
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 80px;
right: 20px;
background: #001a00;
border: 2px solid var(--terminal-green);
color: var(--terminal-green);
padding: 15px 20px;
font-family: var(--font-mono);
z-index: 10000;
animation: slide-in 0.3s ease-out;
box-shadow: 0 0 20px rgba(0, 255, 65, 0.3);
`;
if (type === 'error') {
notification.style.borderColor = '#ff4444';
notification.style.color = '#ff4444';
message = '✗ ' + message;
} else if (type === 'success') {
message = '✓ ' + message;
} else {
message = ' ' + message;
}
notification.textContent = message;
document.body.appendChild(notification);
// Play beep
terminalBeep(type);
// Remove after 3 seconds
setTimeout(() => {
notification.style.opacity = '0';
notification.style.transition = 'opacity 0.5s';
setTimeout(() => notification.remove(), 500);
}, 3000);
}
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(`${protocol}//${window.location.host}`);
ws.onmessage = (event) => {
try {
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}`);
}
// Show terminal notification
if (data.success) {
showTerminalNotification('Command completed successfully', 'success');
} else {
showTerminalNotification('Command execution failed', 'error');
}
// 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();
}
} catch (error) {
console.error('Error handling WebSocket message:', error);
console.error('Stack trace:', error.stack);
}
};
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>