diff --git a/frontend/js/api.js b/frontend/js/api.js index 8848ca0..f540677 100644 --- a/frontend/js/api.js +++ b/frontend/js/api.js @@ -91,6 +91,15 @@ export const api = { body: JSON.stringify({ order }) }), + // Device Groups + getGroups: () => request('/groups'), + createGroup: (name, color) => request('/groups', { method: 'POST', body: JSON.stringify({ name, color }) }), + deleteGroup: (id) => request(`/groups/${id}`, { method: 'DELETE' }), + getGroupDevices: (id) => request(`/groups/${id}/devices`), + addDeviceToGroup: (groupId, device_id) => request(`/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_id }) }), + removeDeviceFromGroup: (groupId, deviceId) => request(`/groups/${groupId}/devices/${deviceId}`, { method: 'DELETE' }), + sendGroupCommand: (groupId, type, payload) => request(`/groups/${groupId}/command`, { method: 'POST', body: JSON.stringify({ type, payload }) }), + // Admin - Users getUsers: () => request('/auth/users'), deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }), diff --git a/frontend/js/views/dashboard.js b/frontend/js/views/dashboard.js index cbb7290..7f3b9b3 100644 --- a/frontend/js/views/dashboard.js +++ b/frontend/js/views/dashboard.js @@ -2,6 +2,16 @@ import { api } from '../api.js'; import { on, off, requestScreenshot } from '../socket.js'; import { showToast } from '../components/toast.js'; +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; @@ -94,6 +104,33 @@ function renderDeviceCard(device) { `; } +function renderGroupSection(group, devices) { + const onlineCount = devices.filter(d => d.status === 'online').length; + return ` +
+
+
+ ${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 = ` - +
+ + +
@@ -117,16 +157,7 @@ export function render(container) {
-
-
- - - - - -

Loading displays...

-
-
+
`; const addBtn = container.querySelector('#addDeviceBtn'); @@ -166,41 +197,51 @@ export function render(container) { await api.pairDevice(code, name || undefined); document.getElementById('addDeviceModal').style.display = 'none'; showToast('Display paired successfully!', 'success'); - loadDevices(); + loadDashboard(); } catch (err) { showToast(err.message, 'error'); } }; - // Load devices - loadDevices(); + // 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 card = document.querySelector(`[data-device-id="${data.device_id}"]`); - if (card) { + const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`); + cards.forEach(card => { const statusEl = card.querySelector('.device-card-status'); - statusEl.innerHTML = `${data.status}`; - } + if (statusEl) statusEl.innerHTML = `${data.status}`; + }); }; screenshotHandler = (data) => { - const preview = document.getElementById(`preview-${data.device_id}`); - if (preview) { + // 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 { - preview.innerHTML = `Screenshot` + - preview.querySelector('.device-card-status').outerHTML; + const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || ''; + preview.innerHTML = `Screenshot${statusHtml}`; } - } + }); }; - // Device added/removed - refresh the whole list - const deviceAddedHandler = () => loadDevices(); - const deviceRemovedHandler = () => loadDevices(); + const deviceAddedHandler = () => loadDashboard(); + const deviceRemovedHandler = () => loadDashboard(); on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); @@ -214,7 +255,6 @@ export function render(container) { }); }, 2000); - // Refresh screenshots periodically refreshInterval = setInterval(() => { document.querySelectorAll('.device-card').forEach(card => { requestScreenshot(card.dataset.deviceId); @@ -222,14 +262,14 @@ export function render(container) { }, 30000); } -async function loadDevices() { - const grid = document.getElementById('deviceGrid'); - if (!grid) return; +async function loadDashboard() { + const main = document.getElementById('groupedDevices'); + if (!main) return; try { - const devices = await api.getDevices(); + const [devices, groups] = await Promise.all([api.getDevices(), api.getGroups()]); - // Stats cards + // 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; @@ -256,9 +296,9 @@ async function loadDevices() { `; } - if (devices.length === 0) { - grid.innerHTML = ` -
+ if (devices.length === 0 && groups.length === 0) { + main.innerHTML = ` +
@@ -268,14 +308,161 @@ async function loadDevices() {

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

`; - } else { - grid.innerHTML = devices.map(renderDeviceCard).join(''); + 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) { - grid.innerHTML = `

Failed to load displays

${err.message}

`; + 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 = ` +
+

${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); diff --git a/frontend/js/views/device-detail.js b/frontend/js/views/device-detail.js index 2628cfc..ceecda2 100644 --- a/frontend/js/views/device-detail.js +++ b/frontend/js/views/device-detail.js @@ -200,6 +200,7 @@ async function loadDevice(deviceId, activeTab = null) {
IP Address
${device.ip_address || '--'}
+ ${device.android_version && !device.android_version.startsWith('Web/') ? `
Battery
${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}
@@ -218,27 +219,38 @@ async function loadDevice(deviceId, activeTab = null) { style="width:${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb * 100)}%">
` : ''} + ` : ` +
+
Player Type
+
Web Player
+
+ `} + ${device.android_version && !device.android_version.startsWith('Web/') ? `
WiFi
${latestTelemetry.wifi_ssid || '--'}
${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}
+ ` : ''}
Uptime
${formatUptime(latestTelemetry.uptime_seconds)}
+ ${device.android_version && !device.android_version.startsWith('Web/') ? `
Android Version
-
${device.android_version || '--'}
+
${device.android_version}
App Version
${device.app_version || '--'}
+ ` : ''}
Screen Resolution
${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}
+ ${device.android_version && !device.android_version.startsWith('Web/') ? `
RAM
${latestTelemetry.ram_free_mb ? formatBytes(latestTelemetry.ram_free_mb) + ' free' : '--'}
@@ -247,6 +259,7 @@ async function loadDevice(deviceId, activeTab = null) {
CPU Usage
${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}
+ ` : ''} diff --git a/server/routes/device-groups.js b/server/routes/device-groups.js index 36260c4..654e844 100644 --- a/server/routes/device-groups.js +++ b/server/routes/device-groups.js @@ -85,4 +85,38 @@ router.post('/:id/assign-content', (req, res) => { res.json({ success: true, devices_updated: devices.length }); }); +// Send command to all devices in a group +router.post('/:id/command', (req, res) => { + const { type, payload } = req.body; + if (!type) return res.status(400).json({ error: 'command type required' }); + + // Verify group belongs to user + const group = db.prepare('SELECT * FROM device_groups WHERE id = ? AND user_id = ?').get(req.params.id, req.user.id); + if (!group) return res.status(404).json({ error: 'group not found' }); + + const devices = db.prepare(` + SELECT d.id, d.name, d.status FROM devices d + JOIN device_group_members dgm ON d.id = dgm.device_id + WHERE dgm.group_id = ? + `).all(req.params.id); + + const deviceNs = req.app.get('io').of('/device'); + const results = []; + + for (const device of devices) { + const room = deviceNs.adapter.rooms.get(device.id); + if (room && room.size > 0) { + deviceNs.to(device.id).emit('device:command', { type, payload: payload || {} }); + results.push({ device_id: device.id, name: device.name, status: 'sent' }); + } else { + results.push({ device_id: device.id, name: device.name, status: 'offline' }); + } + } + + const sent = results.filter(r => r.status === 'sent').length; + const offline = results.filter(r => r.status === 'offline').length; + console.log(`Group command '${type}' sent to group '${group.name}': ${sent} sent, ${offline} offline`); + res.json({ success: true, sent, offline, total: devices.length, results }); +}); + module.exports = router; diff --git a/server/server.js b/server/server.js index 75102de..e11f689 100644 --- a/server/server.js +++ b/server/server.js @@ -13,6 +13,7 @@ const config = require('./config'); }); const app = express(); +app.set('trust proxy', true); // Determine if SSL certs are available const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey); diff --git a/server/ws/deviceSocket.js b/server/ws/deviceSocket.js index 0a7c15e..47e55f1 100644 --- a/server/ws/deviceSocket.js +++ b/server/ws/deviceSocket.js @@ -9,6 +9,12 @@ const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription' // In-memory store for latest screenshot per device (avoids disk writes during streaming) let lastScreenshots = {}; +function getClientIp(socket) { + const forwarded = socket.handshake.headers['x-forwarded-for']; + if (forwarded) return forwarded.split(',')[0].trim(); + return socket.handshake.address; +} + function logDeviceStatus(deviceId, status) { try { db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(deviceId, status); @@ -141,7 +147,7 @@ module.exports = function setupDeviceSocket(io) { if (device) { currentDeviceId = device_id; db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE id = ?") - .run(socket.handshake.address, device_id); + .run(getClientIp(socket), device_id); if (device_info) { db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?') @@ -181,7 +187,7 @@ module.exports = function setupDeviceSocket(io) { INSERT INTO devices (id, pairing_code, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat) VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now')) `).run( - id, pairing_code, socket.handshake.address, + id, pairing_code, getClientIp(socket), device_info?.android_version || null, device_info?.app_version || null, device_info?.screen_width || null,