Files

1234 lines
61 KiB
JavaScript

// 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 = `
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.8rem; padding-bottom: 0.8rem; border-bottom: 1px solid var(--border-color);">
<span style="font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-secondary); font-weight: 600;">Hardware Management & Control</span>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="action-btn" id="ctrl-sync-btn" style="padding: 0.35rem 0.7rem; font-size: 0.75rem; border-radius: 4px; display: flex; align-items: center; gap: 0.4rem; background: rgba(168, 85, 247, 0.1); border: 1px solid rgba(168, 85, 247, 0.25); color: var(--accent-purple); cursor: pointer; transition: all 0.2s ease;">
<i class="fa-solid fa-rotate"></i> Sync State
</button>
<button class="action-btn" id="ctrl-boot-btn" style="padding: 0.35rem 0.7rem; font-size: 0.75rem; border-radius: 4px; display: flex; align-items: center; gap: 0.4rem; background: rgba(239, 68, 68, 0.1); border: 1px solid rgba(239, 68, 68, 0.3); color: #ef4444; cursor: pointer; transition: all 0.2s ease;">
<i class="fa-solid fa-plug-circle-xmark"></i> Boot Device
</button>
</div>
</div>
<div class="modal-grid">
<!-- Left Column: Telemetry Info -->
<div class="modal-col-left">
<h4 class="section-title" style="margin-top: 0;"><i class="fa-solid fa-list-check"></i> Telemetry Parameters</h4>
<div class="diagnostic-item">
<span class="diag-label">Node Identifier</span>
<span class="diag-value">${client.id}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">Hardware Model</span>
<span class="diag-value" style="font-weight: 600; color: #3182ce;">${state.model || 'DEV-1'}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">Device Fleet Class</span>
<span class="diag-value">${deviceType}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">IP Address</span>
<span class="diag-value">${client.ip}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">MAC Address</span>
<span class="diag-value">${macAddr}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">Wi-Fi Signal (RSSI)</span>
<span class="diag-value high-green">${signalStrength}</span>
</div>
${(() => {
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 `
<div class="diagnostic-item">
<span class="diag-label">Ambient Light Level</span>
<span class="diag-value">${state.ambient_light !== undefined ? `${rawVal} (${lightPct}% - ${desc})` : 'Polling...'}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">Touch Sensor Status</span>
<span class="diag-value">${state.touch_active !== undefined ? (state.touch_active ? 'TOUCHED (Active)' : 'Released') : 'Polling...'}</span>
</div>
`;
} else {
return `
<div class="diagnostic-item" style="opacity: 0.4;" title="Requires PROTO-1 hardware profile">
<span class="diag-label">Ambient Light Level</span>
<span class="diag-value">N/A (PROTO-1 Only)</span>
</div>
<div class="diagnostic-item" style="opacity: 0.4;" title="Requires PROTO-1 hardware profile">
<span class="diag-label">Touch Sensor Status</span>
<span class="diag-value">N/A (PROTO-1 Only)</span>
</div>
`;
}
})()}
<div class="diagnostic-item">
<span class="diag-label">LED Pattern State</span>
<span class="diag-value" style="font-weight: 500; color: #4299e1;">${activePattern}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">System Free Heap</span>
<span class="diag-value">${freeHeap}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">Connection Time</span>
<span class="diag-value" style="font-size: 0.75rem;">${new Date(client.connectedAt).toLocaleString()}</span>
</div>
<div class="diagnostic-item">
<span class="diag-label">User Agent</span>
<span class="diag-value" style="font-size: 0.7rem; text-align: right; max-width: 180px; word-break: break-all;">${client.userAgent || 'N/A'}</span>
</div>
</div>
<!-- Right Column: Command Center -->
<div class="modal-col-right">
<h4 class="section-title" style="margin-top: 0;"><i class="fa-solid fa-sliders"></i> Command Center</h4>
<div class="control-group">
<label class="control-label">Volume Level: <span id="volume-val-display">${currentVolume}</span>%</label>
<div class="slider-row" style="padding: 0.35rem 0.6rem; background: rgba(0,0,0,0.15);">
<i class="fa-solid fa-volume-low"></i>
<input type="range" id="ctrl-volume-slider" min="0" max="100" value="${currentVolume}" class="custom-slider">
<i class="fa-solid fa-volume-high"></i>
</div>
</div>
<div class="control-group">
<label class="control-label">LED Brightness: <span id="brightness-val-display">${Math.round(currentBrightness * 100)}</span>%</label>
<div class="slider-row" style="padding: 0.35rem 0.6rem; background: rgba(0,0,0,0.15);">
<i class="fa-solid fa-lightbulb"></i>
<input type="range" id="ctrl-brightness-slider" min="0" max="100" value="${Math.round(currentBrightness * 100)}" class="custom-slider">
<i class="fa-solid fa-lightbulb" style="color: #ecc94b;"></i>
</div>
</div>
<div class="control-group" style="${state.model === 'PROTO-1' ? '' : 'opacity: 0.35; pointer-events: none;'}" title="${state.model === 'PROTO-1' ? 'Haptic vibration' : 'Haptics requires PROTO-1 model'}">
<label class="control-label">Haptic Vibration Level: <span id="haptic-val-display">${state.haptic_intensity || 0}</span>% ${state.model === 'PROTO-1' ? '' : ' (Requires PROTO-1)'}</label>
<div class="slider-row" style="padding: 0.35rem 0.6rem; background: rgba(0,0,0,0.15);">
<i class="fa-solid fa-arrows-spin"></i>
<input type="range" id="ctrl-haptic-slider" min="0" max="100" value="${state.haptic_intensity || 0}" ${state.model === 'PROTO-1' ? '' : 'disabled'} class="custom-slider" style="cursor: ${state.model === 'PROTO-1' ? 'pointer' : 'not-allowed'};">
<i class="fa-solid fa-arrows-spin" style="color: #4299e1;"></i>
</div>
</div>
<div class="control-group">
<label class="control-label" style="margin-bottom: 0.2rem;">LED Status Mode</label>
<div class="btn-grid">
<button class="control-btn" data-cmd="set_led" data-val="rainbow" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-rainbow"></i> Rainbow</button>
<button class="control-btn" data-cmd="set_led" data-val="connecting" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-wave-square"></i> Orange</button>
<button class="control-btn" data-cmd="set_led" data-val="failed" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-triangle-exclamation"></i> Red</button>
<button class="control-btn" data-cmd="set_led" data-val="idle" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-power-off"></i> Off</button>
</div>
</div>
<div class="control-group">
<label class="control-label" style="margin-bottom: 0.2rem;">Trigger Audio Prompt</label>
<div class="btn-grid" style="grid-template-columns: repeat(3, 1fr);">
<button class="control-btn" data-cmd="play_audio" data-val="startup" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-play"></i> Startup</button>
<button class="control-btn" data-cmd="play_audio" data-val="wifi_connecting" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-wifi"></i> Connecting</button>
<button class="control-btn" data-cmd="play_audio" data-val="wifi_connected" style="padding: 0.4rem; font-size: 0.75rem;"><i class="fa-solid fa-network-wired"></i> Connected</button>
</div>
</div>
<!-- OTA Firmware Update Panel -->
<div class="control-group" style="border: 1px solid rgba(99,179,237,0.2); background: rgba(99,179,237,0.03); border-radius: 8px; padding: 0.75rem;">
<label class="control-label" style="margin-bottom: 0.6rem; color: #63b3ed; display: flex; align-items: center; gap: 0.4rem; font-size: 0.8rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;">
<i class="fa-solid fa-microchip"></i> OTA Firmware Update
</label>
<!-- Firmware selector -->
<div style="margin-bottom: 0.55rem;">
<label style="font-size: 0.7rem; color: var(--text-secondary); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; display: block; margin-bottom: 0.3rem;">Select Firmware</label>
<select id="ota-version-select" style="width: 100%; background: rgba(0,0,0,0.3); border: 1px solid rgba(99,179,237,0.25); color: var(--text-primary); padding: 0.4rem 0.6rem; border-radius: 6px; font-size: 0.78rem; font-family: inherit; cursor: pointer; appearance: auto;">
<option value="" disabled selected>Loading catalog...</option>
</select>
</div>
<!-- Selected release metadata -->
<div id="ota-release-meta" style="font-size: 0.7rem; color: var(--text-secondary); background: rgba(0,0,0,0.2); border-radius: 5px; padding: 0.4rem 0.55rem; margin-bottom: 0.55rem; min-height: 1.8rem; line-height: 1.6;">
Select a release above to see details
</div>
<!-- Custom .bin upload (collapsed unless "Upload .bin manually" is selected) -->
<div id="ota-custom-upload-row" style="display: none; gap: 0.4rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.55rem;">
<label for="ota-file-input" style="display: inline-flex; align-items: center; gap: 0.35rem; background: rgba(255,255,255,0.07); border: 1px solid rgba(255,255,255,0.15); color: var(--text-primary); padding: 0.3rem 0.6rem; border-radius: 5px; font-size: 0.72rem; cursor: pointer; font-weight: 600;">
<i class="fa-solid fa-upload"></i> Choose .bin
</label>
<input type="file" id="ota-file-input" accept=".bin" style="display:none;">
<span id="ota-file-name" style="font-size: 0.7rem; color: var(--text-secondary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">No file selected</span>
<button id="ota-upload-btn" disabled style="background: rgba(99,179,237,0.1); border: 1px solid rgba(99,179,237,0.3); color: #63b3ed; padding: 0.3rem 0.6rem; border-radius: 5px; font-size: 0.72rem; cursor: not-allowed; font-family: inherit; font-weight: 600; opacity: 0.5;">Upload</button>
</div>
<!-- Progress bar -->
<div id="ota-progress-wrap" style="display: none; margin-bottom: 0.55rem;">
<div style="display: flex; justify-content: space-between; font-size: 0.7rem; color: var(--text-secondary); margin-bottom: 0.2rem;">
<span id="ota-status-label">Idle</span>
<span id="ota-pct-label">0%</span>
</div>
<div style="background: rgba(255,255,255,0.08); border-radius: 4px; height: 6px; overflow: hidden;">
<div id="ota-progress-bar" style="height: 100%; width: 0%; background: linear-gradient(90deg, #63b3ed, #4299e1); border-radius: 4px; transition: width 0.4s ease;"></div>
</div>
</div>
<!-- Flash button -->
<button id="ota-flash-btn" disabled style="width: 100%; background: linear-gradient(135deg, rgba(99,179,237,0.12), rgba(66,153,225,0.08)); border: 1px solid rgba(99,179,237,0.3); color: #63b3ed; padding: 0.45rem; border-radius: 6px; font-size: 0.75rem; cursor: not-allowed; font-family: inherit; font-weight: 700; opacity: 0.4; transition: all 0.2s ease; letter-spacing: 0.5px; text-transform: uppercase; display: flex; align-items: center; justify-content: center; gap: 0.4rem;">
<i class="fa-solid fa-bolt"></i> Flash to Device
</button>
</div>
</div>
</div>
`;
// 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 => `<div style="opacity:0.75;">• ${l}</div>`).join('');
otaReleaseMeta.innerHTML = `
<div style="display:flex;justify-content:space-between;margin-bottom:0.25rem;">
<span><strong style="color:var(--text-primary);">${entry.version}</strong> &nbsp;<span style="background:rgba(99,179,237,0.15);border-radius:3px;padding:1px 5px;font-size:0.65rem;">${model}</span></span>
<span style="opacity:0.6;">${kb} &nbsp;·&nbsp; ${date}</span>
</div>
${hash ? `<div style="opacity:0.5;font-size:0.65rem;margin-bottom:0.2rem;">commit ${hash}</div>` : ''}
<div style="font-size:0.68rem;margin-top:0.15rem;">${log || '<span style="opacity:0.5;">No changelog entries</span>'}</div>
`;
};
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 = '<option value="custom">📂 Upload .bin manually (catalog unavailable)</option>';
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 = `
<!-- Row 1: Identity & Actions -->
<div style="display: flex; justify-content: space-between; align-items: flex-start; width: 100%; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 0.6rem;">
<div style="display: flex; flex-direction: column; gap: 0.15rem; min-width: 0;">
<span class="device-display-name" style="font-size: 1.05rem; font-weight: 700; color: var(--text-primary); text-overflow: ellipsis; overflow: hidden; white-space: nowrap;">${displayName} [${client.id}]</span>
<span class="device-sub-details" style="font-size: 0.78rem; color: var(--text-secondary); display: inline-flex; align-items: center; gap: 0.4rem;">
<span class="model-text" style="font-weight: 600; color: var(--text-primary);">${model}</span>
<span style="opacity: 0.35;">&middot;</span>
<span class="ip-text" style="font-family: var(--font-mono);">${client.ip}</span>
</span>
</div>
<div style="display: flex; gap: 0.5rem; align-items: center; margin-top: 0.15rem;">
<button class="diagnostics-btn" onclick="openDiagnostics('${client.id}')" style="background: rgba(99,179,237,0.08); border: 1px solid rgba(99,179,237,0.25); color: #63b3ed; padding: 0.4rem 0.95rem; border-radius: 6px; font-size: 0.75rem; cursor: pointer; font-family: inherit; font-weight: 600; transition: all 0.2s ease;">Diag</button>
<button class="boot-btn" data-clientid="${client.id}" title="Force disconnect this device" style="background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.12); color: var(--text-primary); padding: 0.4rem 0.95rem; border-radius: 6px; font-size: 0.75rem; cursor: pointer; font-family: inherit; font-weight: 600; transition: all 0.2s ease;">Boot</button>
</div>
</div>
<!-- Row 2: Labeled Vertical Columns Grid -->
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; width: 100%; padding-top: 0.2rem;">
<!-- Column 1: STATUS -->
<div style="display: flex; flex-direction: column; gap: 0.18rem;">
<span style="font-size: 0.62rem; color: var(--text-secondary); opacity: 0.6; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;">Status</span>
<span style="font-size: 0.85rem; color: #48bb78; font-weight: 600;">🟢 Online</span>
<span style="font-size: 0.78rem; color: var(--text-secondary);"><span id="duration-${prefix}-${client.id}" style="font-family: var(--font-mono); font-weight: 500; color: var(--text-primary);">0s</span> uptime</span>
</div>
<!-- Column 2: CONNECTIVITY -->
<div style="display: flex; flex-direction: column; gap: 0.18rem;">
<span style="font-size: 0.62rem; color: var(--text-secondary); opacity: 0.6; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;">Connectivity</span>
<span class="wifi-quality-text" style="font-size: 0.85rem; color: var(--text-primary); font-weight: 500;">Wi-Fi ${wifiQuality}</span>
<span class="wifi-rssi-text" style="font-size: 0.78rem; color: var(--text-secondary); font-family: var(--font-mono);">${wifiRssiVal}</span>
</div>
<!-- Column 3: SYSTEM -->
<div style="display: flex; flex-direction: column; gap: 0.18rem;">
<span style="font-size: 0.62rem; color: var(--text-secondary); opacity: 0.6; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px;">System</span>
<span class="heap-text" style="font-size: 0.85rem; color: var(--text-primary); font-weight: 500;">Heap ${heapVal}</span>
<span class="msg-value" style="font-size: 0.78rem; color: var(--text-secondary);"><span class="msg-count-val" style="font-family: var(--font-mono); font-weight: 500; color: var(--text-primary);">${client.messageCount}</span> message${client.messageCount === 1 ? '' : 's'}</span>
</div>
</div>
`;
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 = `
<div class="empty-state">
<i class="fa-solid fa-plug-circle-exclamation"></i>
<p>No matching nodes connected.</p>
</div>
`;
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 = '<div class="console-line system-line">[SYSTEM] Logs cleared.</div>';
if (consoleContainerTerminal) consoleContainerTerminal.innerHTML = '<div class="console-line system-line">[SYSTEM] Dedicated terminal logger active.</div>';
});
}
});
// 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 = '<i class="fa-solid fa-circle-notch fa-spin"></i><p>Fetching catalog releases...</p>';
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 = '<i class="fa-solid fa-folder-open" style="font-size:2rem;color:var(--text-secondary);margin-bottom:0.5rem;"></i><p>No builds cataloged yet. Run scripts/release.ps1 to build and publish a release.</p>';
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 => `
<div style="display:flex;gap:0.4rem;font-size:0.75rem;margin-bottom:0.35rem;line-height:1.4;">
<span style="color:#63b3ed;opacity:0.8;">•</span>
<span style="color:var(--text-secondary);">${l}</span>
</div>
`).join('');
const buttonsHtml = entry.builds.map(b => {
const kb = b.size ? (b.size / 1024).toFixed(1) : '?';
return `
<a href="${devServerHttpUrl}/releases/${b.filename}" target="_blank" download class="catalog-download-btn" data-model="${b.model}" style="text-decoration:none;display:inline-flex;align-items:center;justify-content:center;gap:0.4rem;background:rgba(99,179,237,0.1);border:1px solid rgba(99,179,237,0.25);color:#63b3ed;padding:0.45rem;border-radius:6px;font-size:0.75rem;font-weight:700;transition:all 0.2s ease;text-transform:uppercase;letter-spacing:0.5px;margin-top:0.4rem;flex:1;min-width:140px;">
<i class="fa-solid fa-download"></i> ${b.model} (${kb} KB)
</a>
`;
}).join('');
card.innerHTML = `
<div>
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;border-bottom:1px solid rgba(255,255,255,0.06);padding-bottom:0.6rem;">
<div style="display:flex;align-items:center;gap:0.5rem;">
<span style="font-size:1rem;font-weight:700;color:var(--text-primary);letter-spacing:0.3px;">${entry.version}</span>
${entry.version === catalog.latest ? '<span style="background:rgba(72,187,120,0.15);border:1px solid rgba(72,187,120,0.3);color:#48bb78;font-size:0.65rem;font-weight:700;border-radius:4px;padding:1px 6px;text-transform:uppercase;letter-spacing:0.3px;">Latest</span>' : ''}
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:0.5rem;font-size:0.7rem;color:var(--text-secondary);opacity:0.75;margin-bottom:0.75rem;">
<div><i class="fa-solid fa-calendar-day" style="width:14px;"></i> ${date}</div>
<div><i class="fa-solid fa-code-commit" style="width:14px;"></i> hash: <code style="background:rgba(0,0,0,0.25);padding:1px 4px;border-radius:3px;font-family:'JetBrains Mono';">${shortHash}</code></div>
</div>
<div style="margin-bottom:1rem;">
<span style="font-size:0.7rem;color:var(--text-secondary);font-weight:700;text-transform:uppercase;letter-spacing:0.5px;display:block;margin-bottom:0.4rem;">Changeset</span>
<div style="background:rgba(0,0,0,0.15);border-radius:6px;padding:0.6rem 0.75rem;max-height:120px;overflow-y:auto;border:1px solid rgba(255,255,255,0.03);">
${logs || '<div style="font-size:0.72rem;opacity:0.5;font-style:italic;">No changes logged.</div>'}
</div>
</div>
</div>
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-top:0.4rem;">
${buttonsHtml}
</div>
`;
// 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 = '<i class="fa-solid fa-triangle-exclamation" style="font-size:2rem;color:var(--accent-red);margin-bottom:0.5rem;"></i><p>Could not load firmware catalog from server.</p>';
});
}
// 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();