Files
LEDMatrix/web_interface/static/v3/app.js
Chuck 704e99f55c fix(web-ui): support multiple browser tabs via SSE broadcaster pattern
Each SSE stream (stats, display preview, logs) previously ran a separate
generator per connected client, so two open tabs meant double the PIL
image encodes per second and double the journalctl subprocesses. Under
load or on reconnect storms the tight "20 per minute" rate limit was
easily exhausted, silently breaking tabs without any user-facing
explanation.

- Replace per-client sse_response generators with _StreamBroadcaster:
  one background thread per stream type fans data to all subscribed
  client queues, keeping CPU/subprocess work constant regardless of
  how many tabs are open
- Add 30-second SSE heartbeat comments to keep idle connections alive
  through proxies
- Raise SSE rate limit from "20/min" to "200/min" to prevent reconnect
  storms from exhausting the limit
- Assign statsSource/displaySource to window.* so reconnectSSE() in
  app.js can actually reach them (was dead code due to const scoping)
- Add displaySource error handler so display preview failures are no
  longer completely silent
- Improve connection status badge: shows "Reconnecting…" on first few
  errors, "Disconnected" with tooltip hint after persistent failure
- Complete the empty displaySource.onmessage stub in reconnectSSE()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:16:47 -04:00

272 lines
8.9 KiB
JavaScript

/* global showNotification, updateSystemStats, htmx */
// LED Matrix v3 JavaScript
// Additional helpers for HTMX and Alpine.js integration
// Global notification system
window.showNotification = function(message, type = 'info') {
// Use Alpine.js notification if available
if (window.Alpine) {
// This would trigger the Alpine.js notification system
const event = new CustomEvent('show-notification', {
detail: { message, type }
});
document.dispatchEvent(event);
} else {
// Fallback notification
console.log(`${type}: ${message}`);
}
};
// HTMX response handlers
document.body.addEventListener('htmx:beforeRequest', function(event) {
// Show loading states for buttons
const btn = event.target.closest('button, .btn');
if (btn) {
btn.classList.add('loading');
const textEl = btn.querySelector('.btn-text');
if (textEl) textEl.style.opacity = '0.5';
}
});
document.body.addEventListener('htmx:afterRequest', function(event) {
// Remove loading states
const btn = event.target.closest('button, .btn');
if (btn) {
btn.classList.remove('loading');
const textEl = btn.querySelector('.btn-text');
if (textEl) textEl.style.opacity = '1';
}
// Handle response notifications
const response = event.detail.xhr;
if (response && response.responseText) {
try {
const data = JSON.parse(response.responseText);
if (data.message) {
showNotification(data.message, data.status || 'info');
}
} catch {
// Not JSON, ignore
}
}
});
// SSE reconnection helper — closes and reopens both SSE streams.
window.reconnectSSE = function() {
if (window.statsSource) {
window.statsSource.close();
window.statsSource = new EventSource('/api/v3/stream/stats');
window.statsSource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (typeof updateSystemStats === 'function') updateSystemStats(data);
};
}
if (window.displaySource) {
window.displaySource.close();
window.displaySource = new EventSource('/api/v3/stream/display');
window.displaySource.onmessage = function(event) {
const data = JSON.parse(event.data);
if (typeof updateDisplayPreview === 'function') updateDisplayPreview(data);
};
}
};
// Utility functions
window.hexToRgb = function(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
};
window.rgbToHex = function(r, g, b) {
return "#" + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1);
};
// Form validation helpers
window.validateForm = function(form) {
const inputs = form.querySelectorAll('input[required], select[required], textarea[required]');
let isValid = true;
inputs.forEach(input => {
if (!input.value.trim()) {
input.classList.add('border-red-500');
isValid = false;
} else {
input.classList.remove('border-red-500');
}
});
return isValid;
};
// Auto-resize textareas
document.addEventListener('DOMContentLoaded', function() {
const textareas = document.querySelectorAll('textarea');
textareas.forEach(textarea => {
textarea.addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = this.scrollHeight + 'px';
});
});
});
// Keyboard shortcuts
document.addEventListener('keydown', function(e) {
// Ctrl/Cmd + R to refresh
if ((e.ctrlKey || e.metaKey) && e.key === 'r') {
e.preventDefault();
location.reload();
}
// Ctrl/Cmd + S to save current form
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
const form = document.querySelector('form');
if (form) {
form.dispatchEvent(new Event('submit'));
}
}
});
// Plugin management helpers
window.installPlugin = function(pluginId) {
fetch('/api/v3/plugins/install', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ plugin_id: pluginId })
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status === 'success') {
// Refresh plugin list
htmx.ajax('GET', '/v3/partials/plugins', '#plugins-content');
}
})
.catch(error => {
showNotification('Error installing plugin: ' + error.message, 'error');
});
};
// Font management helpers
window.uploadFont = function(fileInput) {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('font_file', file);
formData.append('font_family', file.name.replace(/\.[^/.]+$/, '').toLowerCase().replace(/[^a-z0-9]/g, '_'));
fetch('/api/v3/fonts/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
showNotification(data.message, data.status);
if (data.status === 'success') {
// Refresh fonts list
htmx.ajax('GET', '/v3/partials/fonts', '#fonts-content');
}
})
.catch(error => {
showNotification('Error uploading font: ' + error.message, 'error');
});
};
// Tab switching helper
window.switchTab = function(tabName) {
// Update Alpine.js active tab if available
if (window.Alpine) {
// Dispatch event for Alpine.js
const event = new CustomEvent('switch-tab', {
detail: { tab: tabName }
});
document.dispatchEvent(event);
}
};
// Error handling for unhandled promise rejections
window.addEventListener('unhandledrejection', function(event) {
console.error('Unhandled promise rejection:', event.reason);
showNotification('An unexpected error occurred', 'error');
});
// Performance monitoring
window.performanceMonitor = {
startTime: performance.now(),
mark: function(name) {
if (window.performance.mark) {
performance.mark(name);
}
},
measure: function(name, start, end) {
if (window.performance.measure) {
performance.measure(name, start, end);
}
},
getMeasures: function() {
if (window.performance && window.performance.getEntriesByType) {
return window.performance.getEntriesByType('measure');
}
return [];
},
getMetrics: function() {
if (!window.performance || !window.performance.getEntriesByType) {
return {};
}
const navigation = window.performance.getEntriesByType('navigation')[0];
const paint = window.performance.getEntriesByType('paint');
const resources = window.performance.getEntriesByType('resource');
return {
domContentLoaded: navigation ? navigation.domContentLoadedEventEnd - navigation.domContentLoadedEventStart : 0,
loadComplete: navigation ? navigation.loadEventEnd - navigation.fetchStart : 0,
firstPaint: paint.find(p => p.name === 'first-paint')?.startTime || 0,
firstContentfulPaint: paint.find(p => p.name === 'first-contentful-paint')?.startTime || 0,
resourceCount: resources.length,
totalResourceSize: resources.reduce((sum, r) => sum + (r.transferSize || 0), 0),
measures: this.measures
};
},
logMetrics: function() {
const metrics = this.getMetrics();
console.group('Performance Metrics');
console.log('DOM Content Loaded:', metrics.domContentLoaded?.toFixed(2) || 'N/A', 'ms');
console.log('Load Complete:', metrics.loadComplete?.toFixed(2) || 'N/A', 'ms');
console.log('First Paint:', metrics.firstPaint?.toFixed(2) || 'N/A', 'ms');
console.log('First Contentful Paint:', metrics.firstContentfulPaint?.toFixed(2) || 'N/A', 'ms');
console.log('Resources:', metrics.resourceCount || 0, 'files,', (metrics.totalResourceSize / 1024).toFixed(2) || '0', 'KB');
if (Object.keys(metrics.measures || {}).length > 0) {
console.log('Custom Measures:', metrics.measures);
}
console.groupEnd();
}
};
// Initialize performance monitoring
document.addEventListener('DOMContentLoaded', function() {
window.performanceMonitor.mark('app-start');
// Log metrics after page load
window.addEventListener('load', function() {
setTimeout(() => {
window.performanceMonitor.mark('app-loaded');
window.performanceMonitor.measure('app-load-time', 'app-start', 'app-loaded');
if (window.location.search.includes('debug=perf')) {
window.performanceMonitor.logMetrics();
}
}, 100);
});
});