import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; function esc(str) { if (!str) return ''; return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; const GROUP_COMMANDS = [ { type: 'screen_on', label: 'Screen On' }, { type: 'screen_off', label: 'Screen Off' }, { type: 'launch', label: 'Restart App' }, { type: 'update', label: 'Check Update' }, { type: 'reboot', label: 'Reboot', destructive: true }, { type: 'shutdown', label: 'Shutdown', destructive: true }, ]; let statusHandler = null; let screenshotHandler = null; let refreshInterval = null; function formatTimeAgo(timestamp) { if (!timestamp) return 'Never'; const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return 'Just now'; if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; return `${Math.floor(seconds / 86400)}d ago`; } function formatBytes(mb) { if (mb === null || mb === undefined) return '--'; if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb} MB`; } function renderDeviceCard(device) { const token = localStorage.getItem('token'); const screenshotUrl = device.screenshot_path ? `/api/devices/${device.id}/screenshot?t=${device.screenshot_at || ''}&token=${token}` : null; return `
${screenshotUrl ? `Screenshot` : `
No preview available
` }
${device.status === 'provisioning' ? 'Awaiting Pairing' : device.status}
${device.status === 'provisioning' && device.pairing_code ? `
${device.pairing_code}
` : ''}
${esc(device.name)}
${device.owner_name || device.owner_email ? `
${esc(device.owner_name || device.owner_email)}
` : ''}
${formatTimeAgo(device.last_heartbeat)}
${device.battery_level !== null && device.battery_level !== undefined ? `
${device.battery_level}%
` : ''} ${device.wifi_rssi ? `
${device.wifi_rssi} dBm
` : ''} ${device.storage_free_mb ? `
${formatBytes(device.storage_free_mb)} free
` : ''}
`; } function renderGroupSection(group, devices) { const onlineCount = devices.filter(d => d.status === 'online').length; return `
${esc(group.name)} ${devices.length} device${devices.length !== 1 ? 's' : ''} · ${onlineCount} online
${devices.length > 0 ? ` ` : ''}
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '
No devices in this group. Click Manage to add some.
'}
`; } export function render(container) { container.innerHTML = `
`; const addBtn = container.querySelector('#addDeviceBtn'); addBtn.addEventListener('click', () => { document.getElementById('addDeviceModal').style.display = 'flex'; document.getElementById('pairingCodeInput').value = ''; document.getElementById('deviceNameInput').value = ''; document.getElementById('pairingCodeInput').focus(); }); // Search and filter document.getElementById('deviceSearch').oninput = () => filterDevices(); document.getElementById('deviceFilter').onchange = () => filterDevices(); function filterDevices() { const search = document.getElementById('deviceSearch').value.toLowerCase(); const status = document.getElementById('deviceFilter').value; document.querySelectorAll('.device-card').forEach(card => { const name = card.querySelector('.device-card-name')?.textContent.toLowerCase() || ''; const deviceStatus = card.querySelector('.device-card-status span:last-child')?.textContent || ''; const matchSearch = !search || name.includes(search); const matchStatus = !status || deviceStatus === status; card.style.display = (matchSearch && matchStatus) ? '' : 'none'; }); } // Setup pairing const pairBtn = document.getElementById('pairDeviceBtn'); pairBtn.onclick = async () => { const code = document.getElementById('pairingCodeInput').value.trim(); const name = document.getElementById('deviceNameInput').value.trim(); if (!code || code.length !== 6) { showToast('Enter a valid 6-digit pairing code', 'error'); return; } try { await api.pairDevice(code, name || undefined); document.getElementById('addDeviceModal').style.display = 'none'; showToast('Display paired successfully!', 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }; // Create group container.querySelector('#createGroupBtn').addEventListener('click', async () => { const name = prompt('Group name:'); if (!name) return; try { await api.createGroup(name); showToast('Group created', 'success'); loadDashboard(); } catch (e) { showToast(e.message, 'error'); } }); // Load everything loadDashboard(); // Real-time updates statusHandler = (data) => { const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`); cards.forEach(card => { const statusEl = card.querySelector('.device-card-status'); if (statusEl) statusEl.innerHTML = `${data.status}`; }); }; screenshotHandler = (data) => { // Update all instances of this device's preview (may appear in multiple groups) document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => { const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token')); const img = preview.querySelector('img'); if (img) { img.src = imgSrc; } else { const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || ''; preview.innerHTML = `Screenshot${statusHtml}`; } }); }; const deviceAddedHandler = () => loadDashboard(); const deviceRemovedHandler = () => loadDashboard(); on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('device-added', deviceAddedHandler); on('device-removed', deviceRemovedHandler); // Request fresh screenshots on load setTimeout(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); }); }, 2000); refreshInterval = setInterval(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); }); }, 30000); } async function loadDashboard() { const main = document.getElementById('groupedDevices'); if (!main) return; try { const [devices, groups] = await Promise.all([api.getDevices(), api.getGroups()]); // Stats const online = devices.filter(d => d.status === 'online').length; const offline = devices.filter(d => d.status === 'offline').length; const provisioning = devices.filter(d => d.status === 'provisioning').length; const statsEl = document.getElementById('dashStats'); if (statsEl) { statsEl.innerHTML = `
Total Displays
${devices.length}
Online
${online}
Offline
${offline}
${provisioning > 0 ? `
Awaiting Pairing
${provisioning}
` : ''} `; } if (devices.length === 0 && groups.length === 0) { main.innerHTML = `

No displays yet

Install the ScreenTinker app on your Apolosign TV and pair it using the button above.

`; return; } // Fetch group memberships const groupsWithDevices = await Promise.all(groups.map(async g => { const members = await api.getGroupDevices(g.id); const memberIds = new Set(members.map(m => m.id)); // Use full device data from the main devices list (has telemetry/screenshots) const fullDevices = devices.filter(d => memberIds.has(d.id)); return { ...g, devices: fullDevices, memberIds }; })); // Find ungrouped devices const allGroupedIds = new Set(); groupsWithDevices.forEach(g => g.memberIds.forEach(id => allGroupedIds.add(id))); const ungrouped = devices.filter(d => !allGroupedIds.has(d.id)); let html = ''; // Render each group with its devices for (const g of groupsWithDevices) { html += renderGroupSection(g, g.devices); } // Render ungrouped devices if (ungrouped.length > 0) { html += `
${groups.length > 0 ? `
Ungrouped ${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}
` : ''}
${ungrouped.map(renderDeviceCard).join('')}
`; } main.innerHTML = html; attachGroupHandlers(groupsWithDevices, devices); } catch (err) { main.innerHTML = `

Failed to load displays

${err.message}

`; } } function attachGroupHandlers(groupsWithDevices, allDevices) { // Command select handlers document.querySelectorAll('.group-cmd-select').forEach(select => { select.addEventListener('change', async (e) => { const type = e.target.value; if (!type) return; const groupId = e.target.dataset.groupId; const groupName = e.target.dataset.groupName; const count = e.target.dataset.deviceCount; if (DESTRUCTIVE_COMMANDS.includes(type)) { if (!confirm(`${type.toUpperCase()} all ${count} device${count !== '1' ? 's' : ''} in "${groupName}"?\n\nThis cannot be undone.`)) { e.target.value = ''; return; } } try { const result = await api.sendGroupCommand(groupId, type); showToast(`${type} sent to ${result.sent}/${result.total} devices${result.offline > 0 ? ` (${result.offline} offline)` : ''}`, result.offline > 0 ? 'warning' : 'success'); } catch (err) { showToast(err.message, 'error'); } e.target.value = ''; }); }); // Delete group document.querySelectorAll('[data-group-delete]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.groupDelete; if (!confirm('Delete this group? Devices will not be affected.')) return; try { await api.deleteGroup(id); showToast('Group deleted', 'success'); loadDashboard(); } catch (e) { showToast(e.message, 'error'); } }); }); // Manage group (add/remove devices) document.querySelectorAll('[data-group-manage]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const groupId = btn.dataset.groupManage; const group = groupsWithDevices.find(g => g.id === groupId); const memberIds = new Set(group.devices.map(d => d.id)); // Get all groups for multi-group warning const otherGroups = groupsWithDevices.filter(g => g.id !== groupId); const modal = document.createElement('div'); modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;z-index:1000'; modal.innerHTML = `

${esc(group.name)}

Check devices to add them to this group

${allDevices.filter(d => d.status !== 'provisioning').map(d => { const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name); return ` `; }).join('')}
`; document.body.appendChild(modal); modal.querySelector('#manageGroupClose').onclick = () => { modal.remove(); loadDashboard(); }; modal.addEventListener('click', (ev) => { if (ev.target === modal) { modal.remove(); loadDashboard(); } }); modal.querySelectorAll('input[type="checkbox"]').forEach(cb => { cb.addEventListener('change', async () => { const deviceId = cb.dataset.deviceId; const existingGroups = cb.dataset.inGroups; try { if (cb.checked && existingGroups) { if (!confirm(`This device is already in: ${existingGroups}\n\nAdd it to "${group.name}" too?`)) { cb.checked = false; return; } } if (cb.checked) { await api.addDeviceToGroup(groupId, deviceId); } else { await api.removeDeviceFromGroup(groupId, deviceId); } } catch (err) { showToast(err.message, 'error'); cb.checked = !cb.checked; } }); }); }); }); } export function cleanup() { if (statusHandler) off('device-status', statusHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler); off('device-added', () => {}); off('device-removed', () => {}); if (refreshInterval) clearInterval(refreshInterval); statusHandler = null; screenshotHandler = null; refreshInterval = null; }