Frontend improvements: safety, UX, and WebSocket handling
- Guard all new Date().toLocaleString() calls with safeDate() to prevent 'Invalid Date' - Guard escapeHtml() for null/undefined input - Guard getTimeAgo() for null/NaN dates; add safeDate() and formatElapsed() helpers - Show elapsed time for running executions in the execution list - Add status-running CSS pulse animation class to running execution items - Add explicit executions_bulk_deleted WebSocket handler - clearCompletedExecutions() uses new bulk DELETE endpoint instead of N individual requests - switchTab() persists active tab to localStorage; init restores it on load - refreshData() updates lastRefreshed timestamp in header - Add Ctrl+Enter shortcut for quick command form - Wrap rerunCommand worker_id with escapeHtml() to prevent XSS Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,9 @@
|
|||||||
--terminal-green: #00ff41;
|
--terminal-green: #00ff41;
|
||||||
--terminal-amber: #ffb000;
|
--terminal-amber: #ffb000;
|
||||||
--terminal-cyan: #00ffff;
|
--terminal-cyan: #00ffff;
|
||||||
|
--terminal-red: #ff4444;
|
||||||
|
--bg-terminal: #001a00;
|
||||||
|
--bg-terminal-border: #003300;
|
||||||
--text-primary: #00ff41;
|
--text-primary: #00ff41;
|
||||||
--text-secondary: #00cc33;
|
--text-secondary: #00cc33;
|
||||||
--text-muted: #008822;
|
--text-muted: #008822;
|
||||||
@@ -40,6 +43,7 @@
|
|||||||
--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-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: 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;
|
--glow-amber-intense: 0 0 8px #ffb000, 0 0 16px #ffb000, 0 0 24px #ffb000;
|
||||||
|
--glow-red: 0 0 5px #ff4444, 0 0 10px #ff4444;
|
||||||
}
|
}
|
||||||
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
@@ -72,6 +76,7 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
animation: scanline 8s linear infinite;
|
animation: scanline 8s linear infinite;
|
||||||
|
will-change: transform;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes scanline {
|
@keyframes scanline {
|
||||||
@@ -361,7 +366,12 @@
|
|||||||
button::after { content: ' ]'; flex-shrink: 0; }
|
button::after { content: ' ]'; flex-shrink: 0; }
|
||||||
/* Suppress bracket pseudo-elements for tab/nav buttons and inline-styled sub-tabs */
|
/* Suppress bracket pseudo-elements for tab/nav buttons and inline-styled sub-tabs */
|
||||||
button.tab::before, button.tab::after,
|
button.tab::before, button.tab::after,
|
||||||
button[style*="border:none"]::before, button[style*="border:none"]::after { content: none; }
|
button[style*="border:none"]::before, button[style*="border:none"]::after,
|
||||||
|
button[style*="border: none"]::before, button[style*="border: none"]::after,
|
||||||
|
button[style*="flex:0"]::before, button[style*="flex:0"]::after,
|
||||||
|
button[style*="flex: 0"]::before, button[style*="flex: 0"]::after,
|
||||||
|
button[style*="flex:1"]::before, button[style*="flex:1"]::after,
|
||||||
|
button[style*="flex: 1"]::before, button[style*="flex: 1"]::after { content: none; }
|
||||||
button:hover {
|
button:hover {
|
||||||
background: rgba(0, 255, 65, 0.15);
|
background: rgba(0, 255, 65, 0.15);
|
||||||
color: var(--terminal-amber);
|
color: var(--terminal-amber);
|
||||||
@@ -466,7 +476,7 @@
|
|||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 90%;
|
width: 90%;
|
||||||
max-height: 80vh;
|
max-height: 85vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
box-shadow: 0 0 30px rgba(0, 255, 65, 0.3);
|
box-shadow: 0 0 30px rgba(0, 255, 65, 0.3);
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -624,7 +634,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.failed {
|
.log-entry.failed {
|
||||||
border-left-color: #ff4444;
|
border-left-color: var(--terminal-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-timestamp {
|
.log-timestamp {
|
||||||
@@ -642,8 +652,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-entry.failed .log-title {
|
.log-entry.failed .log-title {
|
||||||
color: #ff4444;
|
color: var(--terminal-red);
|
||||||
text-shadow: 0 0 5px #ff4444;
|
text-shadow: var(--glow-red);
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-details {
|
.log-details {
|
||||||
@@ -662,8 +672,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-output {
|
.log-output {
|
||||||
background: #0a0a0a;
|
background: var(--bg-primary);
|
||||||
border: 1px solid #003300;
|
border: 1px solid var(--bg-terminal-border);
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
margin: 6px 0;
|
margin: 6px 0;
|
||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
@@ -675,14 +685,14 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.log-error {
|
.log-error {
|
||||||
color: #ff6666;
|
color: var(--terminal-red);
|
||||||
border-color: #330000;
|
border-color: #330000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log-entry code {
|
.log-entry code {
|
||||||
background: #001a00;
|
background: var(--bg-terminal);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border: 1px solid #003300;
|
border: 1px solid var(--bg-terminal-border);
|
||||||
color: var(--terminal-green);
|
color: var(--terminal-green);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
@@ -699,15 +709,15 @@
|
|||||||
|
|
||||||
.worker-stats span {
|
.worker-stats span {
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
background: #001a00;
|
background: var(--bg-terminal);
|
||||||
border: 1px solid #003300;
|
border: 1px solid var(--bg-terminal-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-metadata {
|
.worker-metadata {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #001a00;
|
background: var(--bg-terminal);
|
||||||
border: 1px solid #003300;
|
border: 1px solid var(--bg-terminal-border);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
@@ -743,18 +753,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.execution-item:hover {
|
.execution-item:hover {
|
||||||
background: #001a00;
|
background: var(--bg-terminal);
|
||||||
border-left-width: 5px;
|
border-left-width: 5px;
|
||||||
transform: translateX(3px);
|
transform: translateX(3px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.worker-item:hover {
|
.worker-item:hover {
|
||||||
background: #001a00;
|
background: var(--bg-terminal);
|
||||||
border-left-width: 5px;
|
border-left-width: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.workflow-item:hover {
|
.workflow-item:hover {
|
||||||
background: #001a00;
|
background: var(--bg-terminal);
|
||||||
border-left-width: 5px;
|
border-left-width: 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,6 +778,15 @@
|
|||||||
50% { opacity: 1; }
|
50% { opacity: 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Running execution pulse */
|
||||||
|
.execution-item.status-running {
|
||||||
|
animation: exec-running-pulse 2s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes exec-running-pulse {
|
||||||
|
0%, 100% { border-color: var(--terminal-green); }
|
||||||
|
50% { border-color: var(--status-running); box-shadow: 0 0 8px rgba(255,193,7,0.35); }
|
||||||
|
}
|
||||||
|
|
||||||
/* Success/Error message animations */
|
/* Success/Error message animations */
|
||||||
@keyframes slide-in {
|
@keyframes slide-in {
|
||||||
from {
|
from {
|
||||||
@@ -792,9 +811,12 @@
|
|||||||
<h1>⚡ PULSE</h1>
|
<h1>⚡ PULSE</h1>
|
||||||
<p>Pipelined Unified Logic & Server Engine</p>
|
<p>Pipelined Unified Logic & Server Engine</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="text-align:right;">
|
||||||
<div class="user-info" id="userInfo">
|
<div class="user-info" id="userInfo">
|
||||||
<div class="loading">Loading user...</div>
|
<div class="loading">Loading user...</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="lastRefreshed" style="font-size:0.72em;color:var(--text-muted);font-family:var(--font-mono);margin-top:4px;"></div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
@@ -1263,7 +1285,7 @@
|
|||||||
<div class="workflow-item">
|
<div class="workflow-item">
|
||||||
<div class="workflow-name">${w.name}${paramBadge(def)}</div>
|
<div class="workflow-name">${w.name}${paramBadge(def)}</div>
|
||||||
<div class="workflow-desc">${w.description || 'No description'}</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 class="timestamp">Created by ${w.created_by || 'Unknown'} on ${safeDate(w.created_at)?.toLocaleString() ?? 'N/A'}</div>
|
||||||
<div style="margin-top: 10px;">
|
<div style="margin-top: 10px;">
|
||||||
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
|
<button onclick="executeWorkflow('${w.id}')">▶️ Execute</button>
|
||||||
${currentUser && currentUser.isAdmin ?
|
${currentUser && currentUser.isAdmin ?
|
||||||
@@ -1304,8 +1326,8 @@
|
|||||||
scheduleDesc = `Cron: ${s.schedule_value}`;
|
scheduleDesc = `Cron: ${s.schedule_value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextRun = s.next_run ? new Date(s.next_run).toLocaleString() : 'Not scheduled';
|
const nextRun = safeDate(s.next_run)?.toLocaleString() ?? 'Not scheduled';
|
||||||
const lastRun = s.last_run ? new Date(s.last_run).toLocaleString() : 'Never';
|
const lastRun = safeDate(s.last_run)?.toLocaleString() ?? 'Never';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="workflow-item" style="opacity: ${s.enabled ? 1 : 0.6};">
|
<div class="workflow-item" style="opacity: ${s.enabled ? 1 : 0.6};">
|
||||||
@@ -1497,7 +1519,7 @@
|
|||||||
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
<div class="execution-item" onclick="viewExecution('${e.id}')">
|
||||||
<span class="status ${e.status}">${e.status}</span>
|
<span class="status ${e.status}">${e.status}</span>
|
||||||
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
||||||
<div class="timestamp">by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}</div>
|
<div class="timestamp">by ${e.started_by} at ${safeDate(e.started_at)?.toLocaleString() ?? 'N/A'}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
document.getElementById('dashExecutions').innerHTML = dashHtml;
|
document.getElementById('dashExecutions').innerHTML = dashHtml;
|
||||||
@@ -1615,14 +1637,15 @@
|
|||||||
const clickHandler = compareMode ? `toggleExecutionSelection('${e.id}')` : `viewExecution('${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);' : '';
|
const selectedStyle = isSelected ? 'background: rgba(255, 176, 0, 0.2); border-left-width: 5px; border-left-color: var(--terminal-amber);' : '';
|
||||||
|
|
||||||
|
const elapsed = e.status === 'running' ? ` • ${formatElapsed(e.started_at)}` : '';
|
||||||
return `
|
return `
|
||||||
<div class="execution-item" onclick="${clickHandler}" style="${selectedStyle} cursor: pointer;">
|
<div class="execution-item${e.status === 'running' ? ' status-running' : ''}" onclick="${clickHandler}" style="${selectedStyle} cursor: pointer;">
|
||||||
${compareMode && isSelected ? '<span style="color: var(--terminal-amber); margin-right: 8px;">✓</span>' : ''}
|
${compareMode && isSelected ? '<span style="color: var(--terminal-amber); margin-right: 8px;">✓</span>' : ''}
|
||||||
<span class="status ${e.status}">${e.status}</span>
|
<span class="status ${e.status}">${e.status}</span>
|
||||||
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
<strong>${e.workflow_name || '[Quick Command]'}</strong>
|
||||||
<div class="timestamp">
|
<div class="timestamp">
|
||||||
Started by ${e.started_by} at ${new Date(e.started_at).toLocaleString()}
|
Started by ${e.started_by} at ${safeDate(e.started_at)?.toLocaleString() ?? 'N/A'}
|
||||||
${e.completed_at ? ` • Completed at ${new Date(e.completed_at).toLocaleString()}` : ''}
|
${e.completed_at ? ` • Completed at ${safeDate(e.completed_at)?.toLocaleString() ?? 'N/A'}` : elapsed}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -1737,7 +1760,7 @@
|
|||||||
<tr style="border-bottom: 1px solid #003300;">
|
<tr style="border-bottom: 1px solid #003300;">
|
||||||
<td style="padding: 8px; color: var(--terminal-green);">Execution ${idx + 1}</td>
|
<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;"><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);">${safeDate(exec.started_at)?.toLocaleString() ?? 'N/A'}</td>
|
||||||
<td style="padding: 8px; color: var(--terminal-green);">${duration}</td>
|
<td style="padding: 8px; color: var(--terminal-green);">${duration}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -1853,24 +1876,14 @@
|
|||||||
if (!confirm('Delete all completed and failed executions?')) return;
|
if (!confirm('Delete all completed and failed executions?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/executions?limit=9999'); // Get all executions
|
const response = await fetch('/api/executions/completed', { method: 'DELETE' });
|
||||||
const data = await response.json();
|
if (!response.ok) {
|
||||||
const executions = data.executions || data; // Handle new pagination format
|
const err = await response.json().catch(() => ({}));
|
||||||
|
showTerminalNotification(err.error || 'Failed to delete executions', 'error');
|
||||||
const toDelete = executions.filter(e => e.status === 'completed' || e.status === 'failed');
|
|
||||||
|
|
||||||
if (toDelete.length === 0) {
|
|
||||||
alert('No completed or failed executions to delete');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const data = await response.json();
|
||||||
let deleted = 0;
|
showTerminalNotification(`Deleted ${data.deleted} execution(s)`, 'success');
|
||||||
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();
|
refreshData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error clearing executions:', error);
|
console.error('Error clearing executions:', error);
|
||||||
@@ -1970,8 +1983,8 @@
|
|||||||
|
|
||||||
let html = `
|
let html = `
|
||||||
<div><strong>Status:</strong> <span class="status ${execution.status}">${execution.status}</span></div>
|
<div><strong>Status:</strong> <span class="status ${execution.status}">${execution.status}</span></div>
|
||||||
<div><strong>Started:</strong> ${new Date(execution.started_at).toLocaleString()}</div>
|
<div><strong>Started:</strong> ${safeDate(execution.started_at)?.toLocaleString() ?? 'N/A'}</div>
|
||||||
${execution.completed_at ? `<div><strong>Completed:</strong> ${new Date(execution.completed_at).toLocaleString()}</div>` : ''}
|
${execution.completed_at ? `<div><strong>Completed:</strong> ${safeDate(execution.completed_at)?.toLocaleString() ?? 'N/A'}</div>` : ''}
|
||||||
<div><strong>Started by:</strong> ${execution.started_by}</div>
|
<div><strong>Started by:</strong> ${execution.started_by}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -2015,7 +2028,7 @@
|
|||||||
// Re-run button (only for quick commands with command in logs)
|
// Re-run button (only for quick commands with command in logs)
|
||||||
const commandLog = execution.logs?.find(l => l.action === 'command_sent');
|
const commandLog = execution.logs?.find(l => l.action === 'command_sent');
|
||||||
if (commandLog && commandLog.command) {
|
if (commandLog && commandLog.command) {
|
||||||
html += `<button onclick="rerunCommand('${escapeHtml(commandLog.command)}', '${commandLog.worker_id}')">🔄 Re-run Command</button>`;
|
html += `<button onclick="rerunCommand('${escapeHtml(commandLog.command)}', '${escapeHtml(commandLog.worker_id || '')}')">🔄 Re-run Command</button>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download logs button
|
// Download logs button
|
||||||
@@ -2264,8 +2277,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
|
if (text == null) return '';
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.textContent = text;
|
div.textContent = String(text);
|
||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2288,7 +2302,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getTimeAgo(date) {
|
function getTimeAgo(date) {
|
||||||
|
if (!date || isNaN(date)) return 'N/A';
|
||||||
const seconds = Math.floor((new Date() - date) / 1000);
|
const seconds = Math.floor((new Date() - date) / 1000);
|
||||||
|
if (seconds < 0) return 'just now';
|
||||||
if (seconds < 60) return `${seconds}s ago`;
|
if (seconds < 60) return `${seconds}s ago`;
|
||||||
const minutes = Math.floor(seconds / 60);
|
const minutes = Math.floor(seconds / 60);
|
||||||
if (minutes < 60) return `${minutes}m ago`;
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
@@ -2298,6 +2314,22 @@
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function safeDate(val) {
|
||||||
|
if (!val) return null;
|
||||||
|
const d = new Date(val);
|
||||||
|
return isNaN(d) ? null : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatElapsed(startedAt) {
|
||||||
|
const start = safeDate(startedAt);
|
||||||
|
if (!start) return '';
|
||||||
|
const secs = Math.floor((Date.now() - start) / 1000);
|
||||||
|
if (secs < 60) return `${secs}s`;
|
||||||
|
const mins = Math.floor(secs / 60);
|
||||||
|
if (mins < 60) return `${mins}m ${secs % 60}s`;
|
||||||
|
return `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||||
|
}
|
||||||
|
|
||||||
async function rerunCommand(command, workerId) {
|
async function rerunCommand(command, workerId) {
|
||||||
if (!confirm(`Re-run command: ${command}?`)) return;
|
if (!confirm(`Re-run command: ${command}?`)) return;
|
||||||
|
|
||||||
@@ -2431,7 +2463,7 @@
|
|||||||
const html = history.map((item, index) => `
|
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 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: 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 style="color: #666; font-size: 0.85em;">${safeDate(item.timestamp)?.toLocaleString() ?? 'N/A'} - ${item.worker}</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
document.getElementById('historyList').innerHTML = html;
|
document.getElementById('historyList').innerHTML = html;
|
||||||
@@ -2800,37 +2832,24 @@
|
|||||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||||
|
|
||||||
// Find the button by its onclick attribute rather than relying on bare `event`
|
|
||||||
const tabBtn = document.querySelector(`.tab[onclick*="'${tabName}'"]`);
|
const tabBtn = document.querySelector(`.tab[onclick*="'${tabName}'"]`);
|
||||||
if (tabBtn) tabBtn.classList.add('active');
|
if (tabBtn) tabBtn.classList.add('active');
|
||||||
const tabContent = document.getElementById(tabName);
|
const tabContent = document.getElementById(tabName);
|
||||||
if (tabContent) tabContent.classList.add('active');
|
if (tabContent) tabContent.classList.add('active');
|
||||||
|
|
||||||
|
// Persist active tab across page loads
|
||||||
|
try { localStorage.setItem('pulse_activeTab', tabName); } catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshData() {
|
async function refreshData() {
|
||||||
try {
|
try { await loadWorkers(); } catch (e) { console.error('Error loading workers:', e); }
|
||||||
await loadWorkers();
|
try { await loadWorkflows(); } catch (e) { console.error('Error loading workflows:', e); }
|
||||||
} catch (e) {
|
try { await loadExecutions(); } catch (e) { console.error('Error loading executions:', e); }
|
||||||
console.error('Error loading workers:', e);
|
try { await loadSchedules(); } catch (e) { console.error('Error loading schedules:', e); }
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
// Update "last refreshed" indicator
|
||||||
await loadWorkflows();
|
const el = document.getElementById('lastRefreshed');
|
||||||
} catch (e) {
|
if (el) el.textContent = `Refreshed: ${new Date().toLocaleTimeString()}`;
|
||||||
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)
|
// Terminal beep sound (Web Audio API)
|
||||||
@@ -2992,8 +3011,12 @@
|
|||||||
loadExecutions();
|
loadExecutions();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === 'executions_bulk_deleted') {
|
||||||
|
loadExecutions();
|
||||||
|
}
|
||||||
|
|
||||||
// Generic refresh for other message types
|
// Generic refresh for other message types
|
||||||
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated', 'execution_prompt'].includes(data.type)) {
|
if (!['command_result', 'workflow_result', 'worker_update', 'execution_started', 'execution_status', 'workflow_created', 'workflow_deleted', 'workflow_updated', 'execution_prompt', 'executions_bulk_deleted'].includes(data.type)) {
|
||||||
refreshData();
|
refreshData();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -3026,12 +3049,29 @@
|
|||||||
// Initialize
|
// Initialize
|
||||||
loadUser().then((success) => {
|
loadUser().then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// Restore last-active tab
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem('pulse_activeTab');
|
||||||
|
if (saved) switchTab(saved);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
setExecutionView(executionView);
|
setExecutionView(executionView);
|
||||||
refreshData();
|
refreshData();
|
||||||
connectWebSocket();
|
connectWebSocket();
|
||||||
setInterval(refreshData, 30000);
|
setInterval(refreshData, 30000);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Ctrl+Enter submits the quick command form
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||||
|
const activeTab = document.querySelector('.tab-content.active');
|
||||||
|
if (activeTab && activeTab.id === 'quickcommand') {
|
||||||
|
e.preventDefault();
|
||||||
|
executeQuickCommand();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Terminal Boot Sequence -->
|
<!-- Terminal Boot Sequence -->
|
||||||
|
|||||||
Reference in New Issue
Block a user