// 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
Telemetry Parameters
Node Identifier
${client.id}
Hardware Model
${state.model || 'DEV-1'}
Device Fleet Class
${deviceType}
IP Address
${client.ip}
MAC Address
${macAddr}
Wi-Fi Signal (RSSI)
${signalStrength}
${(() => {
if (state.model === 'PROTO-1') {
const rawVal = state.ambient_light !== undefined ? state.ambient_light : 0;
const lightPct = Math.min(100, Math.max(0, Math.round((rawVal / 4095) * 100)));
let desc = 'Dim';
if (lightPct < 15) desc = 'Dark';
else if (lightPct > 75) desc = 'Bright';
else if (lightPct > 40) desc = 'Normal';
return `
Ambient Light Level
${state.ambient_light !== undefined ? `${rawVal} (${lightPct}% - ${desc})` : 'Polling...'}
Touch Sensor Status
${state.touch_active !== undefined ? (state.touch_active ? 'TOUCHED (Active)' : 'Released') : 'Polling...'}
`;
} else {
return `
Ambient Light Level
N/A (PROTO-1 Only)
Touch Sensor Status
N/A (PROTO-1 Only)
`;
}
})()}
LED Pattern State
${activePattern}
System Free Heap
${freeHeap}
Connection Time
${new Date(client.connectedAt).toLocaleString()}
User Agent
${client.userAgent || 'N/A'}
Command Center
Select a release above to see details
No file selected
`;
// 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}
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();