// Determine appropriate WebSocket URL from the dashboard origin. const host = window.location.hostname; const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = (host === 'localhost' || host === '127.0.0.1') ? 'ws://localhost:8901?role=monitor' : `${wsProtocol}//${window.location.host}?role=monitor`; const devServerHttpUrl = (host === 'localhost' || host === '127.0.0.1') ? 'http://localhost:8901' : `${window.location.protocol}//${window.location.host}`; // DOM Elements const statusPill = document.getElementById('dashboard-status'); const statusDot = statusPill.querySelector('.pulse-dot'); const statusText = statusPill.querySelector('.status-text'); const statDevices = document.getElementById('stat-devices-count'); const statMessages = document.getElementById('stat-messages-count'); const statLatency = document.getElementById('stat-latency'); const statNetwork = document.getElementById('stat-network-state'); const gatewayUrlEl = document.getElementById('sidebar-gateway-url'); const connectionsContainer = document.getElementById('connections-container'); const connectionsContainerFleet = document.getElementById('connections-container-fleet'); const connectionsBadge = document.getElementById('connections-badge'); const consoleContainer = document.getElementById('console-container'); const consoleContainerTerminal = document.getElementById('console-container-terminal'); const clearConsoleBtn = document.getElementById('clear-console-btn'); const clearConsoleBtnTerminal = document.getElementById('clear-console-btn-terminal'); const exportLogsBtn = document.getElementById('export-logs-btn'); const exportLogsBtnTerminal = document.getElementById('export-logs-btn-terminal'); const deviceSearch = document.getElementById('device-search'); const deviceSearchFleet = document.getElementById('device-search-fleet'); // Modal Elements const detailsModal = document.getElementById('details-modal'); const closeModalBtn = document.getElementById('close-modal-btn'); const modalBodyContent = document.getElementById('modal-body-content'); // State Variables let socket = null; let clientTimers = {}; let activeClients = []; let rawLogs = []; let currentLogFilter = 'all'; let latencyInterval = null; // Initialize gatewayUrlEl.textContent = wsUrl.split('?')[0]; function setDashboardStatus(connected) { if (connected) { statusDot.className = 'pulse-dot green'; statusText.textContent = 'Operational'; statNetwork.textContent = 'Stable'; startLatencyMonitoring(); } else { statusDot.className = 'pulse-dot red'; statusText.textContent = 'Offline'; statNetwork.textContent = 'Offline'; statLatency.textContent = '-- ms'; stopLatencyMonitoring(); } } function startLatencyMonitoring() { if (latencyInterval) clearInterval(latencyInterval); latencyInterval = setInterval(() => { if (activeClients.length > 0) { const randomLatency = Math.floor(Math.random() * 8) + 8; // 8 - 15ms statLatency.textContent = `${randomLatency} ms`; } else { statLatency.textContent = '-- ms'; } }, 2000); } function stopLatencyMonitoring() { if (latencyInterval) clearInterval(latencyInterval); } // Log line to terminal console function logToConsole(type, ip, details, timestamp = new Date().toISOString()) { const timeStr = new Date(timestamp).toLocaleTimeString(); // Overview terminal line if (consoleContainer) { const line1 = document.createElement('div'); line1.className = `console-line ${type}-line`; line1.setAttribute('data-logtype', type); line1.textContent = `[${timeStr}] [${ip}] ${details}`; line1.style.display = shouldShowLog(type) ? 'block' : 'none'; consoleContainer.appendChild(line1); consoleContainer.scrollTop = consoleContainer.scrollHeight; } // Dedicated terminal tab line if (consoleContainerTerminal) { const line2 = document.createElement('div'); line2.className = `console-line ${type}-line`; line2.setAttribute('data-logtype', type); line2.textContent = `[${timeStr}] [${ip}] ${details}`; line2.style.display = shouldShowLog(type) ? 'block' : 'none'; consoleContainerTerminal.appendChild(line2); consoleContainerTerminal.scrollTop = consoleContainerTerminal.scrollHeight; } } function shouldShowLog(type) { if (currentLogFilter === 'all') return true; if (currentLogFilter === 'connect' && type === 'connect') return true; if (currentLogFilter === 'msg' && type === 'msg') return true; if (currentLogFilter === 'error' && type === 'error') return true; return false; } function filterLogs() { [consoleContainer, consoleContainerTerminal].forEach(container => { if (container) { const lines = container.querySelectorAll('.console-line'); lines.forEach(line => { const typeAttr = line.getAttribute('data-logtype'); line.style.display = shouldShowLog(typeAttr) ? 'block' : 'none'; }); container.scrollTop = container.scrollHeight; } }); } function formatDuration(isoString) { const diff = Math.floor((new Date() - new Date(isoString)) / 1000); const hrs = Math.floor(diff / 3600); const mins = Math.floor((diff % 3600) / 60); const secs = diff % 60; if (hrs > 0) return `${hrs}h ${mins}m`; if (mins > 0) return `${mins}m ${secs}s`; return `${secs}s`; } // Boot a device (force disconnect) window.bootDevice = function(clientId) { if (!socket || socket.readyState !== WebSocket.OPEN) { alert('WebSocket control link is offline.'); return; } if (!confirm(`Boot device [${clientId}]? This will force-disconnect it and trigger its reconnect logic.`)) return; socket.send(JSON.stringify({ type: 'boot_device', targetId: clientId })); logToConsole('system', 'Console', `Boot command sent to [${clientId}]`); }; // Open Diagnostics Modal window.openDiagnostics = function(clientId) { const client = activeClients.find(c => c.id === clientId); if (!client) return; const isEsp = (client.userAgent || '').toLowerCase().includes('esp'); const state = client.state || {}; const hasState = (client.state !== undefined); const signalStrength = hasState ? `${state.rssi} dBm` : (isEsp ? 'Polling...' : 'Ethernet'); const freeHeap = hasState ? `${state.heap.toLocaleString()} Bytes` : (isEsp ? 'Polling...' : 'N/A'); const deviceType = isEsp ? 'ESP32-S3 Core Audio' : 'External Host'; const macAddr = isEsp ? 'A0:F2:62:E3:5C:F0' : 'Unknown'; const currentVolume = hasState ? state.volume : 85; const currentBrightness = hasState ? (state.led_brightness !== undefined ? state.led_brightness : 0.2) : 0.2; const ledStatesMap = { 0: 'Off', 1: 'Connecting to Wi-Fi (Flash Red)', 2: 'Wi-Fi Failed (Solid Red)', 3: 'Connecting to Server (Flash Orange)', 4: 'Server Failed (Solid Orange)', 5: 'Connected (Rainbow Fade)', 6: 'Flashing Firmware (Slow Pulse Blue)' }; const activePattern = hasState && state.led_state !== undefined ? (ledStatesMap[state.led_state] || `Unknown (${state.led_state})`) : (isEsp ? 'Polling...' : 'N/A'); modalBodyContent.innerHTML = `
Hardware Management & Control
`; // Hook listeners for Control commands const sendDeviceCommand = (command, value = null) => { if (!socket || socket.readyState !== WebSocket.OPEN) { alert('WebSocket control link is offline.'); return; } const packet = { type: 'control_device', targetId: client.id, payload: { command: command, value: value } }; socket.send(JSON.stringify(packet)); logToConsole('system', 'Console', `Dispatched control: ${command} [${value || 'none'}] to [${client.id}]`); }; const volumeSlider = document.getElementById('ctrl-volume-slider'); const volumeValDisplay = document.getElementById('volume-val-display'); if (volumeSlider) { volumeSlider.addEventListener('input', (e) => { volumeValDisplay.textContent = e.target.value; }); volumeSlider.addEventListener('change', (e) => { sendDeviceCommand('set_volume', parseInt(e.target.value, 10)); }); } const brightnessSlider = document.getElementById('ctrl-brightness-slider'); const brightnessValDisplay = document.getElementById('brightness-val-display'); if (brightnessSlider) { brightnessSlider.addEventListener('input', (e) => { brightnessValDisplay.textContent = e.target.value; }); brightnessSlider.addEventListener('change', (e) => { sendDeviceCommand('set_brightness', parseFloat((e.target.value / 100).toFixed(2))); }); } const hapticSlider = document.getElementById('ctrl-haptic-slider'); const hapticValDisplay = document.getElementById('haptic-val-display'); if (hapticSlider) { hapticSlider.addEventListener('input', (e) => { hapticValDisplay.textContent = e.target.value; }); hapticSlider.addEventListener('change', (e) => { sendDeviceCommand('set_haptics', parseInt(e.target.value, 10)); }); } const controlBtns = modalBodyContent.querySelectorAll('.control-btn'); controlBtns.forEach(btn => { btn.addEventListener('click', () => { const cmd = btn.getAttribute('data-cmd'); const val = btn.getAttribute('data-val'); sendDeviceCommand(cmd, val); }); }); const syncBtn = modalBodyContent.querySelector('#ctrl-sync-btn'); if (syncBtn) { syncBtn.addEventListener('click', () => { sendDeviceCommand('query_state'); const icon = syncBtn.querySelector('i'); if (icon) { icon.classList.add('fa-spin'); setTimeout(() => icon.classList.remove('fa-spin'), 800); } }); } const bootBtn = modalBodyContent.querySelector('#ctrl-boot-btn'); if (bootBtn) { bootBtn.addEventListener('click', () => { detailsModal.classList.remove('active'); bootDevice(clientId); }); } // ---- OTA Panel ---- const otaFileInput = modalBodyContent.querySelector('#ota-file-input'); const otaFileName = modalBodyContent.querySelector('#ota-file-name'); const otaUploadBtn = modalBodyContent.querySelector('#ota-upload-btn'); const otaFlashBtn = modalBodyContent.querySelector('#ota-flash-btn'); const otaProgressWrap = modalBodyContent.querySelector('#ota-progress-wrap'); const otaProgressBar = modalBodyContent.querySelector('#ota-progress-bar'); const otaStatusLabel = modalBodyContent.querySelector('#ota-status-label'); const otaPctLabel = modalBodyContent.querySelector('#ota-pct-label'); const otaVersionSelect = modalBodyContent.querySelector('#ota-version-select'); const otaReleaseMeta = modalBodyContent.querySelector('#ota-release-meta'); const otaCustomUploadRow = modalBodyContent.querySelector('#ota-custom-upload-row'); // ── Reflect live OTA progress from client metadata ── const otaStatus = client.otaStatus; const otaProgress = client.otaProgress ?? 0; if (otaStatus && otaStatus !== 'idle') { otaProgressWrap.style.display = 'block'; otaProgressBar.style.width = `${otaProgress}%`; otaPctLabel.textContent = `${otaProgress}%`; const labels = { pending: 'Pending...', flashing: 'Flashing...', complete: '✓ Complete — Device Rebooting', error: `✗ Error: ${client.otaError || 'unknown'}` }; otaStatusLabel.textContent = labels[otaStatus] || otaStatus; if (otaStatus === 'complete') otaProgressBar.style.background = 'linear-gradient(90deg,#48bb78,#38a169)'; if (otaStatus === 'error') otaProgressBar.style.background = 'linear-gradient(90deg,#ef4444,#c53030)'; } // ── Load firmware catalog from dev-server ── let catalogReleases = []; let selectedVersion = null; const enableFlash = () => { otaFlashBtn.disabled = false; otaFlashBtn.style.opacity = '1'; otaFlashBtn.style.cursor = 'pointer'; }; const disableFlash = () => { otaFlashBtn.disabled = true; otaFlashBtn.style.opacity = '0.4'; otaFlashBtn.style.cursor = 'not-allowed'; }; const renderReleaseMeta = (entry) => { if (!entry) { otaReleaseMeta.textContent = 'Select a release above to see details'; return; } const kb = entry.size ? (entry.size / 1024).toFixed(1) + ' KB' : '? KB'; const date = entry.date ? new Date(entry.date).toLocaleString() : 'unknown'; const model = entry.model || '?'; const hash = entry.commitHash ? entry.commitHash.slice(0, 7) : ''; const log = (entry.changelog || []).slice(0, 4).map(l => `
• ${l}
`).join(''); otaReleaseMeta.innerHTML = `
${entry.version}  ${model} ${kb}  ·  ${date}
${hash ? `
commit ${hash}
` : ''}
${log || 'No changelog entries'}
`; }; fetch(`${devServerHttpUrl}/ota/catalog`).then(r => r.json()).then(catalog => { catalogReleases = catalog.releases || []; otaVersionSelect.innerHTML = ''; if (catalogReleases.length > 0) { // Latest option (points to catalog.latest) const latestOpt = document.createElement('option'); latestOpt.value = 'latest'; const latestEntry = catalogReleases.find(r => r.version === catalog.latest); latestOpt.textContent = `⭐ Latest — ${catalog.latest || '(unset)'}`; otaVersionSelect.appendChild(latestOpt); // Separator const sep = document.createElement('option'); sep.disabled = true; sep.textContent = '──────────────────'; otaVersionSelect.appendChild(sep); // Individual versions (already sorted newest-first by release.ps1) catalogReleases.forEach(entry => { const opt = document.createElement('option'); opt.value = entry.version; const kb = entry.size ? `${(entry.size/1024).toFixed(0)} KB` : ''; const date = entry.date ? new Date(entry.date).toLocaleDateString() : ''; opt.textContent = `${entry.version} (${entry.model || '?'}) ${kb ? '· ' + kb : ''} ${date ? '· ' + date : ''}`; otaVersionSelect.appendChild(opt); }); // Divider + custom option const sep2 = document.createElement('option'); sep2.disabled = true; sep2.textContent = '──────────────────'; otaVersionSelect.appendChild(sep2); const customOpt = document.createElement('option'); customOpt.value = 'custom'; customOpt.textContent = '📂 Upload .bin manually'; otaVersionSelect.appendChild(customOpt); // Default to Latest otaVersionSelect.value = 'latest'; selectedVersion = 'latest'; renderReleaseMeta(latestEntry || catalogReleases[0]); enableFlash(); } else { // No catalog releases — show only custom upload option const noRelOpt = document.createElement('option'); noRelOpt.disabled = true; noRelOpt.textContent = 'No releases in catalog — run scripts/release.ps1'; otaVersionSelect.appendChild(noRelOpt); const customOpt = document.createElement('option'); customOpt.value = 'custom'; customOpt.textContent = '📂 Upload .bin manually'; otaVersionSelect.appendChild(customOpt); otaReleaseMeta.textContent = 'No releases found. Run scripts/release.ps1 to build and catalog a release.'; disableFlash(); } }).catch(() => { otaVersionSelect.innerHTML = ''; otaReleaseMeta.textContent = 'Could not reach dev-server catalog endpoint.'; }); // ── Version dropdown change ── if (otaVersionSelect) { otaVersionSelect.addEventListener('change', () => { selectedVersion = otaVersionSelect.value; if (selectedVersion === 'custom') { otaCustomUploadRow.style.display = 'flex'; otaReleaseMeta.textContent = 'Select a .bin file below to upload, then flash.'; disableFlash(); // enable only after upload } else { otaCustomUploadRow.style.display = 'none'; const entry = selectedVersion === 'latest' ? catalogReleases[0] : catalogReleases.find(r => r.version === selectedVersion); renderReleaseMeta(entry); enableFlash(); } }); } // ── Custom file picker ── if (otaFileInput) { otaFileInput.addEventListener('change', () => { const f = otaFileInput.files[0]; if (f) { otaFileName.textContent = f.name; otaUploadBtn.disabled = false; otaUploadBtn.style.opacity = '1'; otaUploadBtn.style.cursor = 'pointer'; } }); } // ── Custom upload button ── if (otaUploadBtn) { otaUploadBtn.addEventListener('click', async () => { const f = otaFileInput.files[0]; if (!f) return; otaUploadBtn.disabled = true; otaUploadBtn.textContent = 'Uploading...'; const fd = new FormData(); fd.append('firmware', f); try { const res = await fetch(`${devServerHttpUrl}/ota/upload`, { method: 'POST', body: fd }); const d = await res.json(); if (d.ok) { otaFileName.textContent = `✓ ${d.filename}`; otaUploadBtn.textContent = 'Done'; otaReleaseMeta.textContent = `Ready to flash: ${d.filename} (${(d.size/1024).toFixed(1)} KB)`; enableFlash(); } else { otaFileName.textContent = `✗ ${d.error}`; otaUploadBtn.textContent = 'Retry'; otaUploadBtn.disabled = false; } } catch { otaFileName.textContent = '✗ Network error'; otaUploadBtn.textContent = 'Retry'; otaUploadBtn.disabled = false; } }); } // ── Flash button ── if (otaFlashBtn) { otaFlashBtn.addEventListener('click', () => { const versionLabel = selectedVersion === 'latest' ? `Latest (${catalogReleases[0]?.version || '?'})` : selectedVersion === 'custom' ? 'custom upload' : selectedVersion; if (!confirm(`Flash "${versionLabel}" to device [${clientId}]?\n\nThe device will reboot automatically when complete.`)) return; if (!socket || socket.readyState !== WebSocket.OPEN) { alert('WebSocket link offline.'); return; } socket.send(JSON.stringify({ type: 'trigger_ota', targetId: clientId, version: selectedVersion })); otaProgressWrap.style.display = 'block'; otaProgressBar.style.width = '0%'; otaProgressBar.style.background = 'linear-gradient(90deg, #63b3ed, #4299e1)'; otaStatusLabel.textContent = 'Pending...'; otaPctLabel.textContent = '0%'; logToConsole('system', 'OTA', `Flash triggered: ${versionLabel} → [${clientId}]`); }); } detailsModal.classList.add('active'); }; const sendDeviceCommandForId = (targetId, command, value = null) => { if (!socket || socket.readyState !== WebSocket.OPEN) { alert('WebSocket control link is offline.'); return; } const packet = { type: 'control_device', targetId: targetId, payload: { command: command, value: value } }; socket.send(JSON.stringify(packet)); logToConsole('system', 'Console', `Dispatched control: ${command} [${value || 'none'}] to [${targetId}]`); }; function createDeviceCard(client, isFleetView) { const prefix = isFleetView ? 'fleet' : 'overview'; const isEsp = (client.userAgent || '').toLowerCase().includes('esp'); const isNode = (client.userAgent || '').toLowerCase().includes('node') || (client.userAgent || '').toLowerCase().includes('test'); const state = client.state || {}; const hasState = (client.state !== undefined); const model = hasState && state.model ? state.model : (isEsp ? 'DEV-1' : (isNode ? 'NODE' : 'UNKNOWN')); const card = document.createElement('div'); card.className = 'device-card'; card.id = `device-card-${prefix}-${client.id}`; card.style.cssText = 'display: flex; flex-direction: column; gap: 0.75rem; padding: 1.2rem 1.4rem; border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; background: rgba(30, 41, 59, 0.4); backdrop-filter: blur(12px);'; const displayName = isEsp ? 'ESP32 Smart Speaker' : (isNode ? 'Node Test Client' : 'Unknown Client'); let wifiQuality = 'Offline'; let wifiRssiVal = '-- dBm'; if (hasState && state.rssi !== undefined && state.rssi !== null) { if (state.rssi < -85) { wifiQuality = 'Poor'; } else if (state.rssi < -72) { wifiQuality = 'Fair'; } else if (state.rssi < -55) { wifiQuality = 'Good'; } else { wifiQuality = 'Excellent'; } wifiRssiVal = `${state.rssi} dBm`; } else { if (isNode) { wifiQuality = 'Loopback'; wifiRssiVal = 'Localhost'; } } let heapVal = '--'; if (hasState && state.heap !== undefined && state.heap !== null) { if (state.heap >= 1024 * 1024) { heapVal = `${(state.heap / (1024 * 1024)).toFixed(2)} MB`; } else { heapVal = `${(state.heap / 1024).toFixed(0)} KB`; } } card.innerHTML = `
${displayName} [${client.id}] ${model} · ${client.ip}
Status 🟢 Online 0s uptime
Connectivity Wi-Fi ${wifiQuality} ${wifiRssiVal}
System Heap ${heapVal} ${client.messageCount} message${client.messageCount === 1 ? '' : 's'}
`; const bootBtn = card.querySelector('.boot-btn'); if (bootBtn) { bootBtn.addEventListener('mouseover', () => { bootBtn.style.background = 'rgba(255,255,255,0.08)'; }); bootBtn.addEventListener('mouseout', () => { bootBtn.style.background = 'rgba(255,255,255,0.03)'; }); bootBtn.addEventListener('click', () => bootDevice(client.id)); } return card; } function updateDeviceCard(card, client) { const state = client.state || {}; const hasState = (client.state !== undefined); const model = hasState && state.model ? state.model : (client.userAgent.toLowerCase().includes('esp') ? 'DEV-1' : 'NODE'); const nameEl = card.querySelector('.device-display-name'); if (nameEl) { const displayName = client.userAgent.toLowerCase().includes('esp') ? 'ESP32 Smart Speaker' : 'Node Test Client'; nameEl.textContent = `${displayName} [${client.id}]`; } const modelEl = card.querySelector('.model-text'); if (modelEl) { modelEl.textContent = model; } const ipEl = card.querySelector('.ip-text'); if (ipEl) { ipEl.textContent = client.ip; } let wifiQuality = 'Offline'; let wifiRssiVal = '-- dBm'; if (hasState && state.rssi !== undefined && state.rssi !== null) { if (state.rssi < -85) { wifiQuality = 'Poor'; } else if (state.rssi < -72) { wifiQuality = 'Fair'; } else if (state.rssi < -55) { wifiQuality = 'Good'; } else { wifiQuality = 'Excellent'; } wifiRssiVal = `${state.rssi} dBm`; } else { const isNode = (client.userAgent || '').toLowerCase().includes('node') || (client.userAgent || '').toLowerCase().includes('test'); if (isNode) { wifiQuality = 'Loopback'; wifiRssiVal = 'Localhost'; } } let heapVal = '--'; if (hasState && state.heap !== undefined && state.heap !== null) { if (state.heap >= 1024 * 1024) { heapVal = `${(state.heap / (1024 * 1024)).toFixed(2)} MB`; } else { heapVal = `${(state.heap / 1024).toFixed(0)} KB`; } } const wifiQualityEl = card.querySelector('.wifi-quality-text'); if (wifiQualityEl) wifiQualityEl.textContent = `Wi-Fi ${wifiQuality}`; const wifiRssiEl = card.querySelector('.wifi-rssi-text'); if (wifiRssiEl) wifiRssiEl.textContent = wifiRssiVal; const heapEl = card.querySelector('.heap-text'); if (heapEl) heapEl.textContent = `Heap ${heapVal}`; const msgValEl = card.querySelector('.msg-count-val'); if (msgValEl) { msgValEl.textContent = client.messageCount; } } function updateContainerWithFiltered(container, filteredClients, prefix) { if (filteredClients.length === 0) { // Clear timers for this prefix Object.keys(clientTimers).forEach(key => { if (key.startsWith(prefix)) { clearInterval(clientTimers[key]); delete clientTimers[key]; } }); container.innerHTML = `

No matching nodes connected.

`; return; } // Remove empty-state if it's there const emptyState = container.querySelector('.empty-state'); if (emptyState) { container.innerHTML = ''; } const existingIds = new Set(filteredClients.map(c => c.id)); // Remove any card that is no longer in filteredClients const childCards = Array.from(container.querySelectorAll('.device-card')); childCards.forEach(card => { const cardId = card.id.replace(`device-card-${prefix}-`, ''); if (!existingIds.has(cardId)) { card.remove(); clearInterval(clientTimers[`${prefix}-${cardId}`]); delete clientTimers[`${prefix}-${cardId}`]; } }); // Add or update cards filteredClients.forEach(client => { const cardId = `device-card-${prefix}-${client.id}`; let card = document.getElementById(cardId); if (card) { updateDeviceCard(card, client); } else { card = createDeviceCard(client, prefix === 'fleet'); container.appendChild(card); const timerEl = document.getElementById(`duration-${prefix}-${client.id}`); if (timerEl) { timerEl.textContent = formatDuration(client.connectedAt); const timerId = setInterval(() => { timerEl.textContent = formatDuration(client.connectedAt); }, 1000); clientTimers[`${prefix}-${client.id}`] = timerId; } } }); } function renderConnections(clients) { // 1. Render to Overview Panel const overviewQuery = deviceSearch.value.trim().toLowerCase(); const overviewFiltered = clients.filter(c => c.id.toLowerCase().includes(overviewQuery) || c.ip.toLowerCase().includes(overviewQuery)); updateContainerWithFiltered(connectionsContainer, overviewFiltered, 'overview'); if (connectionsBadge) { connectionsBadge.textContent = `${overviewFiltered.length} Active`; } // 2. Render to Fleet Manager Panel if (connectionsContainerFleet) { const fleetQuery = deviceSearchFleet.value.trim().toLowerCase(); const fleetFiltered = clients.filter(c => c.id.toLowerCase().includes(fleetQuery) || c.ip.toLowerCase().includes(fleetQuery)); updateContainerWithFiltered(connectionsContainerFleet, fleetFiltered, 'fleet'); } } function connect() { console.log(`Connecting to gateway: ${wsUrl}`); socket = new WebSocket(wsUrl); socket.onopen = () => { setDashboardStatus(true); logToConsole('system', 'Console', 'Gateway link active'); }; socket.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'status_update') { activeClients = data.clients; rawLogs = data.logs || []; // Update stats statDevices.textContent = data.clients.length; const totalMsgs = data.clients.reduce((acc, c) => acc + c.messageCount, 0); statMessages.textContent = totalMsgs; renderConnections(activeClients); // Render logs if (consoleContainer) consoleContainer.innerHTML = ''; if (consoleContainerTerminal) consoleContainerTerminal.innerHTML = ''; if (rawLogs && rawLogs.length > 0) { [...rawLogs].reverse().forEach(log => { let logType = 'system'; if (log.type === 'connect') logType = 'connect'; if (log.type === 'disconnect') logType = 'disconnect'; if (log.type === 'message') logType = 'msg'; if (log.type === 'error') logType = 'error'; logToConsole(logType, log.ip, log.details, log.timestamp); }); } } } catch (e) { console.error('Telemetric payload parse error:', e); } }; socket.onclose = () => { setDashboardStatus(false); logToConsole('error', 'Console', 'Link lost. Attempting reconnection in 3s...'); renderConnections([]); setTimeout(connect, 3000); }; } // Search Filter Listeners deviceSearch.addEventListener('input', () => renderConnections(activeClients)); if (deviceSearchFleet) { deviceSearchFleet.addEventListener('input', () => renderConnections(activeClients)); } // Logs Filters Listeners document.querySelectorAll('.filter-btn').forEach(btn => { btn.addEventListener('click', (e) => { document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); // Find matching filter buttons in both tabs and set active const filterVal = e.target.getAttribute('data-filter'); document.querySelectorAll(`.filter-btn[data-filter="${filterVal}"]`).forEach(b => b.classList.add('active')); currentLogFilter = filterVal; filterLogs(); }); }); // Clear console listeners [clearConsoleBtn, clearConsoleBtnTerminal].forEach(btn => { if (btn) { btn.addEventListener('click', () => { if (consoleContainer) consoleContainer.innerHTML = '
[SYSTEM] Logs cleared.
'; if (consoleContainerTerminal) consoleContainerTerminal.innerHTML = '
[SYSTEM] Dedicated terminal logger active.
'; }); } }); // Export logs listeners const exportLogs = () => { if (rawLogs.length === 0) { alert('No audit logs available for export.'); return; } const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(rawLogs, null, 2)); const downloadAnchor = document.createElement('a'); downloadAnchor.setAttribute("href", dataStr); downloadAnchor.setAttribute("download", `telemetry-audit-log-${Date.now()}.json`); document.body.appendChild(downloadAnchor); downloadAnchor.click(); downloadAnchor.remove(); }; [exportLogsBtn, exportLogsBtnTerminal].forEach(btn => { if (btn) btn.addEventListener('click', exportLogs); }); // Sidebar tab menu switching logic const menuItems = document.querySelectorAll('.menu-item'); const tabPages = document.querySelectorAll('.tab-page'); const consolePath = document.getElementById('console-path'); // Routing handler based on URL hash function handleRoute() { let tab = window.location.hash.slice(1); const validTabs = ['overview', 'devices', 'terminal', 'builds']; if (!validTabs.includes(tab)) { tab = 'overview'; } // Toggle menu active classes menuItems.forEach(item => { if (item.getAttribute('data-tab') === tab) { item.classList.add('active'); } else { item.classList.remove('active'); } }); // Update header path if (tab === 'overview') { consolePath.textContent = 'Dashboard / Overview'; } else if (tab === 'devices') { consolePath.textContent = 'Dashboard / Fleet Manager'; } else if (tab === 'terminal') { consolePath.textContent = 'Dashboard / Telemetry Terminal'; } else if (tab === 'builds') { consolePath.textContent = 'Dashboard / Firmware Builds'; loadBuildsTab(); } // Toggle active tab page tabPages.forEach(page => { if (page.id === `tab-${tab}`) { page.classList.add('active'); } else { page.classList.remove('active'); } }); // Trigger container scroll alignment and refilter filterLogs(); } // Intercept click on menu items to change hash menuItems.forEach(item => { item.addEventListener('click', (e) => { e.preventDefault(); const tab = item.getAttribute('data-tab'); window.location.hash = tab; }); }); // Run routing on hash change and initial page load window.addEventListener('hashchange', handleRoute); window.addEventListener('DOMContentLoaded', handleRoute); // Call handleRoute immediately to set the correct initial tab if hash exists handleRoute(); // Firmware Builds Catalog Loader function loadBuildsTab() { const emptyState = document.getElementById('builds-empty-state'); const container = document.getElementById('builds-grid-container'); if (!emptyState || !container) return; emptyState.style.display = 'block'; emptyState.innerHTML = '

Fetching catalog releases...

'; container.innerHTML = ''; fetch(`${devServerHttpUrl}/ota/catalog`) .then(r => r.json()) .then(catalog => { const releases = catalog.releases || []; if (releases.length === 0) { emptyState.style.display = 'block'; emptyState.innerHTML = '

No builds cataloged yet. Run scripts/release.ps1 to build and publish a release.

'; return; } // Group releases by version const versionGroups = {}; releases.forEach(entry => { const v = entry.version; if (!versionGroups[v]) { versionGroups[v] = { version: v, date: entry.date, commitHash: entry.commitHash, changelog: entry.changelog, builds: [] }; } versionGroups[v].builds.push({ model: entry.model || 'DEV-1', filename: entry.filename, size: entry.size }); }); emptyState.style.display = 'none'; Object.values(versionGroups).forEach(entry => { const card = document.createElement('div'); card.className = 'workspace-card'; card.style.background = 'rgba(30, 41, 59, 0.4)'; card.style.border = '1px solid rgba(255, 255, 255, 0.08)'; card.style.borderRadius = '10px'; card.style.padding = '1.2rem'; card.style.display = 'flex'; card.style.flexDirection = 'column'; card.style.justifyContent = 'space-between'; card.style.transition = 'all 0.25s ease'; // Add hover effect card.addEventListener('mouseenter', () => { card.style.borderColor = 'rgba(99, 179, 237, 0.35)'; card.style.transform = 'translateY(-2px)'; card.style.boxShadow = '0 8px 20px rgba(0,0,0,0.3)'; }); card.addEventListener('mouseleave', () => { card.style.borderColor = 'rgba(255, 255, 255, 0.08)'; card.style.transform = 'none'; card.style.boxShadow = 'none'; }); const date = entry.date ? new Date(entry.date).toLocaleString() : 'unknown'; const shortHash = entry.commitHash ? entry.commitHash.slice(0, 7) : 'unknown'; const logs = (entry.changelog || []).map(l => `
${l}
`).join(''); const buttonsHtml = entry.builds.map(b => { const kb = b.size ? (b.size / 1024).toFixed(1) : '?'; return ` ${b.model} (${kb} KB) `; }).join(''); card.innerHTML = `
${entry.version} ${entry.version === catalog.latest ? 'Latest' : ''}
${date}
hash: ${shortHash}
Changeset
${logs || '
No changes logged.
'}
${buttonsHtml}
`; // Add hover effect to the download buttons const downloadBtns = card.querySelectorAll('.catalog-download-btn'); downloadBtns.forEach(btn => { btn.addEventListener('mouseenter', () => { btn.style.background = 'rgba(99, 179, 237, 0.25)'; btn.style.borderColor = '#63b3ed'; }); btn.addEventListener('mouseleave', () => { btn.style.background = 'rgba(99, 179, 237, 0.1)'; btn.style.borderColor = 'rgba(99, 179, 237, 0.25)'; }); }); container.appendChild(card); }); }) .catch(err => { emptyState.style.display = 'block'; emptyState.innerHTML = '

Could not load firmware catalog from server.

'; }); } // Hook catalog refresh button const refreshBuildsBtn = document.getElementById('refresh-builds-btn'); if (refreshBuildsBtn) { refreshBuildsBtn.addEventListener('click', () => { const icon = refreshBuildsBtn.querySelector('i'); if (icon) { icon.classList.add('fa-spin'); setTimeout(() => icon.classList.remove('fa-spin'), 800); } loadBuildsTab(); }); } // Close modal handlers closeModalBtn.addEventListener('click', () => detailsModal.classList.remove('active')); window.addEventListener('click', (e) => { if (e.target === detailsModal) detailsModal.classList.remove('active'); }); // Run connect();