import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; import { esc } from '../utils.js'; import { t, tn } from '../i18n.js'; const DESTRUCTIVE_COMMANDS = ['reboot', 'shutdown']; // Command types only — labels resolved through t('dashboard.cmd.') const GROUP_COMMANDS = [ { type: 'screen_on' }, { type: 'screen_off' }, { type: 'launch' }, { type: 'update' }, { type: 'reboot', destructive: true }, { type: 'shutdown', destructive: true }, ]; const CMD_LABEL_KEY = { screen_on: 'dashboard.cmd.screen_on', screen_off: 'dashboard.cmd.screen_off', launch: 'dashboard.cmd.restart_app', update: 'dashboard.cmd.check_update', reboot: 'dashboard.cmd.reboot', shutdown: 'dashboard.cmd.shutdown', }; let statusHandler = null; let screenshotHandler = null; let refreshInterval = null; function formatTimeAgo(timestamp) { if (!timestamp) return t('common.never'); const seconds = Math.floor(Date.now() / 1000 - timestamp); if (seconds < 60) return t('common.just_now'); if (seconds < 3600) return t('common.minutes_ago', { n: Math.floor(seconds / 60) }); if (seconds < 86400) return t('common.hours_ago', { n: Math.floor(seconds / 3600) }); return t('common.days_ago', { n: Math.floor(seconds / 86400) }); } 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` : `
${t('dashboard.no_preview')}
` }
${device.status === 'provisioning' ? t('dashboard.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 getGroupPlaylistLabel(devices, playlists) { const playlistMap = new Map((playlists || []).map(p => [p.id, p])); const assigned = devices.filter(d => d.playlist_id).map(d => d.playlist_id); if (assigned.length === 0) return ''; const unique = [...new Set(assigned)]; if (unique.length === 1) { const pl = playlistMap.get(unique[0]); return pl ? esc(pl.name) : t('dashboard.unknown_playlist'); } return t('dashboard.mixed_playlists'); } function renderGroupSection(group, devices, playlists) { const onlineCount = devices.filter(d => d.status === 'online').length; const playlistLabel = getGroupPlaylistLabel(devices, playlists); return `
${esc(group.name)} ${tn('dashboard.devices_count', devices.length)} · ${t('dashboard.online_count', { n: onlineCount })} ${playlistLabel ? `${t('dashboard.playlist_label', { name: playlistLabel })}` : ''}
${devices.length > 0 ? ` ` : ''}
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : `
${t('dashboard.no_devices_in_group')}
`}
`; } 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(t('dashboard.error_pairing_code'), 'error'); return; } try { await api.pairDevice(code, name || undefined); document.getElementById('addDeviceModal').style.display = 'none'; showToast(t('dashboard.toast.display_paired'), 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }; // Create group container.querySelector('#createGroupBtn').addEventListener('click', async () => { const name = prompt(t('dashboard.prompt_group_name')); if (!name) return; try { await api.createGroup(name); showToast(t('dashboard.toast.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) => { 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 [rawDevices, groups, playlists] = await Promise.all([api.getDevices(), api.getGroups(), api.getPlaylists()]); // Deduplicate devices by id — a stale reconnect race can briefly cause the same // device to appear twice in the list. Last-write-wins keeps the freshest state. const seen = new Map(); for (const d of rawDevices) seen.set(d.id, d); const devices = Array.from(seen.values()); // 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 = `
${t('dashboard.total_displays')}
${devices.length}
${t('dashboard.online')}
${online}
${t('dashboard.offline')}
${offline}
${provisioning > 0 ? `
${t('dashboard.awaiting_pairing')}
${provisioning}
` : ''} `; } if (devices.length === 0 && groups.length === 0) { main.innerHTML = `

${t('dashboard.no_displays')}

${t('dashboard.no_displays_desc')}

`; 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 }; })); // Render each device exactly once: the first group it belongs to wins. // memberIds is preserved for the Manage modal so multi-group membership info stays accurate. const renderedIds = new Set(); for (const g of groupsWithDevices) { g.devices = g.devices.filter(d => { if (renderedIds.has(d.id)) return false; renderedIds.add(d.id); return true; }); } const ungrouped = devices.filter(d => !renderedIds.has(d.id)); let html = ''; // Render each group with its devices for (const g of groupsWithDevices) { html += renderGroupSection(g, g.devices, playlists); } // Render ungrouped devices. The wrapper is tagged data-ungrouped="1" so // attachGroupHandlers can wire it as a drop target — dropping a device here // removes it from every group it currently belongs to. if (ungrouped.length > 0) { html += `
${groups.length > 0 ? `
${t('dashboard.ungrouped')} ${tn('dashboard.devices_count', ungrouped.length)}
` : ''}
${ungrouped.map(renderDeviceCard).join('')}
`; } main.innerHTML = html; attachGroupHandlers(groupsWithDevices, devices); } catch (err) { main.innerHTML = `

${t('dashboard.failed_to_load')}

${esc(err.message)}

`; } } function attachGroupHandlers(groupsWithDevices, allDevices) { // Drag-and-drop: device cards are draggable; group sections + the Ungrouped // wrapper are drop targets. Drop on a group adds membership (mirrors the // Manage modal). Drop on Ungrouped removes the device from every group it's // currently a member of. const groupsByDeviceId = new Map(); for (const g of groupsWithDevices) { g.memberIds.forEach(id => { if (!groupsByDeviceId.has(id)) groupsByDeviceId.set(id, []); groupsByDeviceId.get(id).push({ id: g.id, name: g.name }); }); } document.querySelectorAll('.device-card').forEach(card => { card.addEventListener('dragstart', (e) => { e.dataTransfer.setData('text/device-id', card.dataset.deviceId); e.dataTransfer.setData('text/device-name', card.dataset.deviceName || ''); e.dataTransfer.effectAllowed = 'move'; }); }); function highlightOn(el) { el.style.outline = '2px solid var(--primary)'; el.style.outlineOffset = '2px'; } function highlightOff(el) { el.style.outline = ''; el.style.outlineOffset = ''; } document.querySelectorAll('.group-section').forEach(section => { section.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes('text/device-id')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; highlightOn(section); }); section.addEventListener('dragleave', (e) => { // Avoid flicker when moving across child elements if (e.target === section) highlightOff(section); }); section.addEventListener('drop', async (e) => { e.preventDefault(); highlightOff(section); const deviceId = e.dataTransfer.getData('text/device-id'); const deviceName = e.dataTransfer.getData('text/device-name') || 'this device'; if (!deviceId) return; const groupId = section.dataset.groupId; const targetGroup = groupsWithDevices.find(g => g.id === groupId); if (!targetGroup) return; // Already in this group — no-op. if (targetGroup.memberIds.has(deviceId)) { showToast(t('dashboard.toast.already_in_group', { name: deviceName, group: targetGroup.name }), 'info'); return; } // If the device is in another group, mirror the Manage modal's confirm. const others = (groupsByDeviceId.get(deviceId) || []).map(g => g.name); if (others.length > 0) { if (!confirm(t('dashboard.confirm_add_to_group', { name: deviceName, groups: others.join(', '), target: targetGroup.name }))) return; } try { await api.addDeviceToGroup(groupId, deviceId); showToast(t('dashboard.toast.moved_device', { name: deviceName, group: targetGroup.name }), 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }); }); // Ungrouped wrapper: remove device from every group it's in. document.querySelectorAll('[data-ungrouped="1"]').forEach(section => { section.addEventListener('dragover', (e) => { if (!e.dataTransfer.types.includes('text/device-id')) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; highlightOn(section); }); section.addEventListener('dragleave', (e) => { if (e.target === section) highlightOff(section); }); section.addEventListener('drop', async (e) => { e.preventDefault(); highlightOff(section); const deviceId = e.dataTransfer.getData('text/device-id'); const deviceName = e.dataTransfer.getData('text/device-name') || 'this device'; if (!deviceId) return; const memberships = groupsByDeviceId.get(deviceId) || []; if (memberships.length === 0) return; // already ungrouped try { await Promise.all(memberships.map(m => api.removeDeviceFromGroup(m.id, deviceId))); showToast(tn('dashboard.toast.removed_device', memberships.length, { name: deviceName }), 'success'); loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }); }); // Playlist assignment handlers document.querySelectorAll('.group-playlist-select').forEach(select => { select.addEventListener('change', async (e) => { const playlistId = e.target.value; if (!playlistId) return; const groupId = e.target.dataset.groupId; const groupName = e.target.dataset.groupName; const playlistName = e.target.options[e.target.selectedIndex].textContent; if (!confirm(t('dashboard.confirm_assign_playlist', { playlist: playlistName, group: groupName }))) { e.target.value = ''; return; } try { const result = await api.groupAssignPlaylist(groupId, playlistId); showToast(tn('dashboard.toast.playlist_assigned', result.devices_updated), 'success'); } catch (err) { showToast(err.message, 'error'); } e.target.value = ''; }); }); // 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; const cmdLabel = t(CMD_LABEL_KEY[type] || type); if (DESTRUCTIVE_COMMANDS.includes(type)) { if (!confirm(t('dashboard.confirm_destructive_command', { cmd: cmdLabel.toUpperCase(), n: count, group: groupName }))) { e.target.value = ''; return; } } try { const result = await api.sendGroupCommand(groupId, type); const msg = result.offline > 0 ? t('dashboard.toast.command_sent_with_offline', { cmd: cmdLabel, sent: result.sent, total: result.total, offline: result.offline }) : t('dashboard.toast.command_sent', { cmd: cmdLabel, sent: result.sent, total: result.total }); showToast(msg, 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(t('dashboard.confirm_delete_group'))) return; try { await api.deleteGroup(id); showToast(t('dashboard.toast.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)}

${t('dashboard.manage_group_subtitle')}

${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; const cbName = cb.closest('label')?.querySelector('span:not(.status-dot)')?.textContent || ''; try { if (cb.checked && existingGroups) { if (!confirm(t('dashboard.confirm_add_to_group', { name: cbName, groups: existingGroups, target: group.name }))) { 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; }