1234 lines
61 KiB
JavaScript
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> <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} · ${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;">·</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();
|