feat(ui): devices tcp status, tabs send, preset websocket hooks
Made-with: Cursor
This commit is contained in:
@@ -2,6 +2,100 @@
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
|
||||
let lastTcpSnapshotIps = null;
|
||||
|
||||
/** Match server-side ``normalize_tcp_peer_ip`` for WS events vs registry rows. */
|
||||
function normalizeWifiAddressForMatch(addr) {
|
||||
let s = String(addr || '').trim();
|
||||
if (s.toLowerCase().startsWith('::ffff:')) {
|
||||
s = s.slice(7);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const DEVICES_MODAL_POLL_MS = 1000;
|
||||
|
||||
let devicesModalLiveTimer = null;
|
||||
|
||||
function stopDevicesModalLiveRefresh() {
|
||||
if (devicesModalLiveTimer != null) {
|
||||
clearInterval(devicesModalLiveTimer);
|
||||
devicesModalLiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch registry and re-render the list (no loading spinner). Keeps scroll position.
|
||||
* Used while the devices modal stays open so new TCP devices, renames, and removals appear live.
|
||||
*/
|
||||
async function refreshDevicesListQuiet() {
|
||||
const modal = document.getElementById('devices-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
const prevTop = container.scrollTop;
|
||||
try {
|
||||
const res = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderDevicesList(data || {});
|
||||
container.scrollTop = prevTop;
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function startDevicesModalLiveRefresh() {
|
||||
stopDevicesModalLiveRefresh();
|
||||
devicesModalLiveTimer = setInterval(() => {
|
||||
refreshDevicesListQuiet();
|
||||
}, DEVICES_MODAL_POLL_MS);
|
||||
}
|
||||
|
||||
function updateWifiRowDot(row, connected) {
|
||||
const dot = row.querySelector('.device-status-dot');
|
||||
if (!dot) return;
|
||||
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
|
||||
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
|
||||
if (connected) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
}
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
function applyTcpSnapshot(ips) {
|
||||
const set = new Set(
|
||||
(ips || []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
const addr = normalizeWifiAddressForMatch(row.dataset.deviceAddress);
|
||||
updateWifiRowDot(row, set.has(addr));
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep cached snapshot aligned with incremental WS events (connect/disconnect). */
|
||||
function mergeTcpSnapshotPresence(ip, connected) {
|
||||
const n = normalizeWifiAddressForMatch(ip);
|
||||
if (!n) return;
|
||||
const prev = lastTcpSnapshotIps;
|
||||
const set = new Set(
|
||||
(Array.isArray(prev) ? prev : []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
if (connected) {
|
||||
set.add(n);
|
||||
} else {
|
||||
set.delete(n);
|
||||
}
|
||||
lastTcpSnapshotIps = Array.from(set);
|
||||
}
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
@@ -75,6 +169,9 @@ function getAddressForPayload(transport) {
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
@@ -101,31 +198,82 @@ function renderDevicesList(devices) {
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const t = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
const addrRaw = (dev && dev.address) != null ? String(dev.address).trim() : '';
|
||||
const addrDisplay = addrRaw || '—';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
row.dataset.deviceId = devId;
|
||||
row.dataset.deviceTransport = tr;
|
||||
row.dataset.deviceAddress = addrRaw;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'device-status-dot';
|
||||
dot.setAttribute('role', 'img');
|
||||
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
|
||||
if (live === true) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else if (live === false) {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--unknown');
|
||||
dot.title = 'ESP-NOW — TCP status does not apply';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const macEl = document.createElement('code');
|
||||
macEl.className = 'device-row-mac';
|
||||
macEl.textContent = devId;
|
||||
macEl.title = 'MAC (registry id)';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
const t = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
const addr = (dev && dev.address) ? dev.address : '—';
|
||||
meta.textContent = `${t} · ${tr} · ${addr}`;
|
||||
meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const identifyBtn = document.createElement('button');
|
||||
identifyBtn.className = 'btn btn-primary btn-small';
|
||||
identifyBtn.type = 'button';
|
||||
identifyBtn.textContent = 'Identify';
|
||||
identifyBtn.title = 'Red blink at 10 Hz (~50% brightness) for 2 s, then off (not saved as a preset)';
|
||||
identifyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}/identify`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Identify failed');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Identify failed');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
@@ -144,12 +292,18 @@ function renderDevicesList(devices) {
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(dot);
|
||||
row.appendChild(label);
|
||||
row.appendChild(macEl);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(identifyBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
// Do not re-apply lastTcpSnapshotIps here: it is only updated on WS open and
|
||||
// device_tcp events; re-applying after each /devices poll overwrites correct
|
||||
// API "connected" with a stale list and leaves Wi-Fi rows stuck online.
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
@@ -201,6 +355,29 @@ async function updateDevice(devId, name, type, transport, address) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.addEventListener('deviceTcpStatus', (ev) => {
|
||||
const { ip, connected } = ev.detail || {};
|
||||
if (ip == null || typeof connected !== 'boolean') return;
|
||||
mergeTcpSnapshotPresence(ip, connected);
|
||||
const norm = normalizeWifiAddressForMatch(ip);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
if (normalizeWifiAddressForMatch(row.dataset.deviceAddress) === norm) {
|
||||
updateWifiRowDot(row, connected);
|
||||
}
|
||||
});
|
||||
});
|
||||
window.addEventListener('deviceTcpSnapshot', (ev) => {
|
||||
const ips = ev.detail && ev.detail.connectedIps;
|
||||
lastTcpSnapshotIps = ips;
|
||||
applyTcpSnapshot(ips);
|
||||
});
|
||||
|
||||
window.addEventListener('deviceTcpWsOpen', () => {
|
||||
refreshDevicesListQuiet();
|
||||
});
|
||||
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const transportEdit = document.getElementById('edit-device-transport');
|
||||
@@ -220,11 +397,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
devicesModal.classList.add('active');
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
loadDevicesModal();
|
||||
startDevicesModalLiveRefresh();
|
||||
});
|
||||
}
|
||||
if (devicesCloseBtn) {
|
||||
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||
devicesCloseBtn.addEventListener('click', () => {
|
||||
if (devicesModal) devicesModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
const devicesModalEl = document.getElementById('devices-modal');
|
||||
if (devicesModalEl) {
|
||||
new MutationObserver(() => {
|
||||
if (!devicesModalEl.classList.contains('active')) {
|
||||
stopDevicesModalLiveRefresh();
|
||||
}
|
||||
}).observe(devicesModalEl, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
if (editForm) {
|
||||
|
||||
Reference in New Issue
Block a user