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 = `
` +
- preview.querySelector('.device-card-status').outerHTML;
+ const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
+ preview.innerHTML = `
${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 = `
+
`;
- } 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 || '--'}
+ ` : ''}
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,