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
? `
`
: `
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)}
` : ''}
`;
}
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 ? `
Send Command...
${GROUP_COMMANDS.map(c => `${c.label} `).join('')}
` : ''}
Manage
✕
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '
No devices in this group. Click Manage to add some.
'}
`;
}
export function render(container) {
container.innerHTML = `
All Status
Online
Offline
`;
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 = ` ${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}
${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 `
${esc(d.name)}
${inOther.length > 0 ? `${esc(inOther.join(', '))} ` : ''}
`;
}).join('')}
Done
`;
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;
}