mirror of
https://github.com/screentinker/screentinker.git
synced 2026-05-15 07:32:23 -06:00
Add device groups UI, group commands, proxy IP fix, and web player detection
- Dashboard now organizes devices by group with colored section headers - Group command endpoint (POST /groups/:id/command) sends to all members - Manage modal with multi-group confirmation prompt - Destructive commands (reboot/shutdown) require confirmation - Ungrouped devices shown separately at bottom - trust proxy + X-Forwarded-For for real client IPs behind Nginx - Hide Android-only telemetry (battery/storage/RAM/CPU/WiFi) for web players Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
e7081a579c
commit
faa437881f
|
|
@ -91,6 +91,15 @@ export const api = {
|
||||||
body: JSON.stringify({ order })
|
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
|
// Admin - Users
|
||||||
getUsers: () => request('/auth/users'),
|
getUsers: () => request('/auth/users'),
|
||||||
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
deleteUser: (id) => request(`/auth/users/${id}`, { method: 'DELETE' }),
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,16 @@ import { api } from '../api.js';
|
||||||
import { on, off, requestScreenshot } from '../socket.js';
|
import { on, off, requestScreenshot } from '../socket.js';
|
||||||
import { showToast } from '../components/toast.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 statusHandler = null;
|
||||||
let screenshotHandler = null;
|
let screenshotHandler = null;
|
||||||
let refreshInterval = 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 `
|
||||||
|
<div class="group-section" data-group-id="${group.id}" style="margin-bottom:24px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid ${group.color || '#3B82F6'}">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px">
|
||||||
|
<strong style="font-size:15px">${group.name}</strong>
|
||||||
|
<span style="color:var(--text-muted);font-size:12px">${devices.length} device${devices.length !== 1 ? 's' : ''} · ${onlineCount} online</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:6px;align-items:center">
|
||||||
|
${devices.length > 0 ? `
|
||||||
|
<select class="input group-cmd-select" data-group-id="${group.id}" data-group-name="${group.name}" data-device-count="${devices.length}" style="width:150px;padding:4px 8px;font-size:12px;background:var(--bg-input)">
|
||||||
|
<option value="">Send Command...</option>
|
||||||
|
${GROUP_COMMANDS.map(c => `<option value="${c.type}" ${c.destructive ? 'style="color:var(--danger)"' : ''}>${c.label}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
` : ''}
|
||||||
|
<button class="btn" data-group-manage="${group.id}" style="padding:4px 10px;font-size:12px" title="Add/remove devices">Manage</button>
|
||||||
|
<button class="btn" data-group-delete="${group.id}" style="padding:4px 8px;font-size:12px;color:var(--danger)" title="Delete group">✕</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="device-grid">
|
||||||
|
${devices.length > 0 ? devices.map(renderDeviceCard).join('') : '<div style="color:var(--text-muted);font-size:13px;padding:8px 12px">No devices in this group. Click Manage to add some.</div>'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
export function render(container) {
|
export function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
|
|
@ -101,12 +138,15 @@ export function render(container) {
|
||||||
<h1>Displays <span class="help-tip" data-tip="Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.">?</span></h1>
|
<h1>Displays <span class="help-tip" data-tip="Your paired display devices. Green = online, red = offline. Click a device to manage its playlist, view telemetry, or use remote control.">?</span></h1>
|
||||||
<div class="subtitle">Manage your remote displays</div>
|
<div class="subtitle">Manage your remote displays</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" id="addDeviceBtn">
|
<div style="display:flex;gap:8px">
|
||||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<button class="btn" id="createGroupBtn">+ Group</button>
|
||||||
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
<button class="btn btn-primary" id="addDeviceBtn">
|
||||||
</svg>
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
Add Display
|
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
||||||
</button>
|
</svg>
|
||||||
|
Add Display
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="dashStats" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
<div id="dashStats" style="display:flex;gap:12px;margin-bottom:16px"></div>
|
||||||
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
<div style="display:flex;gap:12px;margin-bottom:16px;align-items:center">
|
||||||
|
|
@ -117,16 +157,7 @@ export function render(container) {
|
||||||
<option value="offline">Offline</option>
|
<option value="offline">Offline</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="device-grid" id="deviceGrid">
|
<div id="groupedDevices"></div>
|
||||||
<div class="empty-state">
|
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
|
||||||
<line x1="12" y1="17" x2="12" y2="21"/>
|
|
||||||
</svg>
|
|
||||||
<h3>Loading displays...</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const addBtn = container.querySelector('#addDeviceBtn');
|
const addBtn = container.querySelector('#addDeviceBtn');
|
||||||
|
|
@ -166,41 +197,51 @@ export function render(container) {
|
||||||
await api.pairDevice(code, name || undefined);
|
await api.pairDevice(code, name || undefined);
|
||||||
document.getElementById('addDeviceModal').style.display = 'none';
|
document.getElementById('addDeviceModal').style.display = 'none';
|
||||||
showToast('Display paired successfully!', 'success');
|
showToast('Display paired successfully!', 'success');
|
||||||
loadDevices();
|
loadDashboard();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showToast(err.message, 'error');
|
showToast(err.message, 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load devices
|
// Create group
|
||||||
loadDevices();
|
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
|
// Real-time updates
|
||||||
statusHandler = (data) => {
|
statusHandler = (data) => {
|
||||||
const card = document.querySelector(`[data-device-id="${data.device_id}"]`);
|
const cards = document.querySelectorAll(`[data-device-id="${data.device_id}"]`);
|
||||||
if (card) {
|
cards.forEach(card => {
|
||||||
const statusEl = card.querySelector('.device-card-status');
|
const statusEl = card.querySelector('.device-card-status');
|
||||||
statusEl.innerHTML = `<span class="status-dot ${data.status}"></span><span>${data.status}</span>`;
|
if (statusEl) statusEl.innerHTML = `<span class="status-dot ${data.status}"></span><span>${data.status}</span>`;
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
screenshotHandler = (data) => {
|
screenshotHandler = (data) => {
|
||||||
const preview = document.getElementById(`preview-${data.device_id}`);
|
// Update all instances of this device's preview (may appear in multiple groups)
|
||||||
if (preview) {
|
document.querySelectorAll(`#preview-${data.device_id}`).forEach(preview => {
|
||||||
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
const imgSrc = data.image_data || (data.url + '&token=' + localStorage.getItem('token'));
|
||||||
const img = preview.querySelector('img');
|
const img = preview.querySelector('img');
|
||||||
if (img) {
|
if (img) {
|
||||||
img.src = imgSrc;
|
img.src = imgSrc;
|
||||||
} else {
|
} else {
|
||||||
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">` +
|
const statusHtml = preview.querySelector('.device-card-status')?.outerHTML || '';
|
||||||
preview.querySelector('.device-card-status').outerHTML;
|
preview.innerHTML = `<img src="${imgSrc}" alt="Screenshot" loading="lazy">${statusHtml}`;
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Device added/removed - refresh the whole list
|
const deviceAddedHandler = () => loadDashboard();
|
||||||
const deviceAddedHandler = () => loadDevices();
|
const deviceRemovedHandler = () => loadDashboard();
|
||||||
const deviceRemovedHandler = () => loadDevices();
|
|
||||||
|
|
||||||
on('device-status', statusHandler);
|
on('device-status', statusHandler);
|
||||||
on('screenshot-ready', screenshotHandler);
|
on('screenshot-ready', screenshotHandler);
|
||||||
|
|
@ -214,7 +255,6 @@ export function render(container) {
|
||||||
});
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
|
|
||||||
// Refresh screenshots periodically
|
|
||||||
refreshInterval = setInterval(() => {
|
refreshInterval = setInterval(() => {
|
||||||
document.querySelectorAll('.device-card').forEach(card => {
|
document.querySelectorAll('.device-card').forEach(card => {
|
||||||
requestScreenshot(card.dataset.deviceId);
|
requestScreenshot(card.dataset.deviceId);
|
||||||
|
|
@ -222,14 +262,14 @@ export function render(container) {
|
||||||
}, 30000);
|
}, 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadDevices() {
|
async function loadDashboard() {
|
||||||
const grid = document.getElementById('deviceGrid');
|
const main = document.getElementById('groupedDevices');
|
||||||
if (!grid) return;
|
if (!main) return;
|
||||||
|
|
||||||
try {
|
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 online = devices.filter(d => d.status === 'online').length;
|
||||||
const offline = devices.filter(d => d.status === 'offline').length;
|
const offline = devices.filter(d => d.status === 'offline').length;
|
||||||
const provisioning = devices.filter(d => d.status === 'provisioning').length;
|
const provisioning = devices.filter(d => d.status === 'provisioning').length;
|
||||||
|
|
@ -256,9 +296,9 @@ async function loadDevices() {
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0 && groups.length === 0) {
|
||||||
grid.innerHTML = `
|
main.innerHTML = `
|
||||||
<div class="empty-state" style="grid-column: 1/-1">
|
<div class="empty-state">
|
||||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
|
||||||
<line x1="8" y1="21" x2="16" y2="21"/>
|
<line x1="8" y1="21" x2="16" y2="21"/>
|
||||||
|
|
@ -268,14 +308,161 @@ async function loadDevices() {
|
||||||
<p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</p>
|
<p>Install the ScreenTinker app on your Apolosign TV and pair it using the button above.</p>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
} else {
|
return;
|
||||||
grid.innerHTML = devices.map(renderDeviceCard).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 += `
|
||||||
|
<div style="margin-bottom:24px">
|
||||||
|
${groups.length > 0 ? `
|
||||||
|
<div style="display:flex;align-items:center;margin-bottom:10px;padding:8px 12px;background:var(--bg-secondary);border-radius:8px;border-left:4px solid var(--text-muted)">
|
||||||
|
<strong style="font-size:15px;color:var(--text-muted)">Ungrouped</strong>
|
||||||
|
<span style="color:var(--text-muted);font-size:12px;margin-left:10px">${ungrouped.length} device${ungrouped.length !== 1 ? 's' : ''}</span>
|
||||||
|
</div>` : ''}
|
||||||
|
<div class="device-grid">
|
||||||
|
${ungrouped.map(renderDeviceCard).join('')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
main.innerHTML = html;
|
||||||
|
attachGroupHandlers(groupsWithDevices, devices);
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
grid.innerHTML = `<div class="empty-state" style="grid-column: 1/-1"><h3>Failed to load displays</h3><p>${err.message}</p></div>`;
|
main.innerHTML = `<div class="empty-state"><h3>Failed to load displays</h3><p>${err.message}</p></div>`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 = `
|
||||||
|
<div style="background:var(--bg-card);border-radius:12px;padding:24px;max-width:400px;width:90%;max-height:70vh;overflow-y:auto">
|
||||||
|
<h3 style="margin:0 0 4px">${group.name}</h3>
|
||||||
|
<p style="margin:0 0 16px;font-size:12px;color:var(--text-muted)">Check devices to add them to this group</p>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px">
|
||||||
|
${allDevices.filter(d => d.status !== 'provisioning').map(d => {
|
||||||
|
const inOther = otherGroups.filter(g => g.memberIds.has(d.id)).map(g => g.name);
|
||||||
|
return `
|
||||||
|
<label style="display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:6px;cursor:pointer;background:var(--bg-secondary)">
|
||||||
|
<input type="checkbox" data-device-id="${d.id}" data-in-groups="${inOther.join(',')}" ${memberIds.has(d.id) ? 'checked' : ''}>
|
||||||
|
<span class="status-dot ${d.status}" style="width:8px;height:8px"></span>
|
||||||
|
<span style="font-size:13px;flex:1">${d.name}</span>
|
||||||
|
${inOther.length > 0 ? `<span style="font-size:10px;color:var(--text-muted);background:var(--bg-primary);padding:1px 6px;border-radius:8px">${inOther.join(', ')}</span>` : ''}
|
||||||
|
</label>
|
||||||
|
`;
|
||||||
|
}).join('')}
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;margin-top:16px;justify-content:flex-end">
|
||||||
|
<button class="btn" id="manageGroupClose">Done</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
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() {
|
export function cleanup() {
|
||||||
if (statusHandler) off('device-status', statusHandler);
|
if (statusHandler) off('device-status', statusHandler);
|
||||||
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
if (screenshotHandler) off('screenshot-ready', screenshotHandler);
|
||||||
|
|
|
||||||
|
|
@ -200,6 +200,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
||||||
<div class="info-card-label">IP Address</div>
|
<div class="info-card-label">IP Address</div>
|
||||||
<div class="info-card-value small">${device.ip_address || '--'}</div>
|
<div class="info-card-value small">${device.ip_address || '--'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Battery</div>
|
<div class="info-card-label">Battery</div>
|
||||||
<div class="info-card-value" id="telBattery">${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}</div>
|
<div class="info-card-value" id="telBattery">${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}</div>
|
||||||
|
|
@ -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)}%"></div>
|
style="width:${((latestTelemetry.storage_total_mb - latestTelemetry.storage_free_mb) / latestTelemetry.storage_total_mb * 100)}%"></div>
|
||||||
</div>` : ''}
|
</div>` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
` : `
|
||||||
|
<div class="info-card">
|
||||||
|
<div class="info-card-label">Player Type</div>
|
||||||
|
<div class="info-card-value small">Web Player</div>
|
||||||
|
</div>
|
||||||
|
`}
|
||||||
|
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">WiFi</div>
|
<div class="info-card-label">WiFi</div>
|
||||||
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
|
<div class="info-card-value small" id="telWifi">${latestTelemetry.wifi_ssid || '--'}</div>
|
||||||
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
|
<div style="font-size:11px;color:var(--text-muted);margin-top:2px" id="telRssi">${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}</div>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Uptime</div>
|
<div class="info-card-label">Uptime</div>
|
||||||
<div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div>
|
<div class="info-card-value small" id="telUptime">${formatUptime(latestTelemetry.uptime_seconds)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Android Version</div>
|
<div class="info-card-label">Android Version</div>
|
||||||
<div class="info-card-value small">${device.android_version || '--'}</div>
|
<div class="info-card-value small">${device.android_version}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">App Version</div>
|
<div class="info-card-label">App Version</div>
|
||||||
<div class="info-card-value small">${device.app_version || '--'}</div>
|
<div class="info-card-value small">${device.app_version || '--'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">Screen Resolution</div>
|
<div class="info-card-label">Screen Resolution</div>
|
||||||
<div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div>
|
<div class="info-card-value small">${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height : '--'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
${device.android_version && !device.android_version.startsWith('Web/') ? `
|
||||||
<div class="info-card">
|
<div class="info-card">
|
||||||
<div class="info-card-label">RAM</div>
|
<div class="info-card-label">RAM</div>
|
||||||
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? formatBytes(latestTelemetry.ram_free_mb) + ' free' : '--'}</div>
|
<div class="info-card-value small" id="telRam">${latestTelemetry.ram_free_mb ? formatBytes(latestTelemetry.ram_free_mb) + ' free' : '--'}</div>
|
||||||
|
|
@ -247,6 +259,7 @@ async function loadDevice(deviceId, activeTab = null) {
|
||||||
<div class="info-card-label">CPU Usage</div>
|
<div class="info-card-label">CPU Usage</div>
|
||||||
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
|
<div class="info-card-value small" id="telCpu">${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}</div>
|
||||||
</div>
|
</div>
|
||||||
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Uptime Timeline (24h) -->
|
<!-- Uptime Timeline (24h) -->
|
||||||
|
|
|
||||||
|
|
@ -85,4 +85,38 @@ router.post('/:id/assign-content', (req, res) => {
|
||||||
res.json({ success: true, devices_updated: devices.length });
|
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;
|
module.exports = router;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ const config = require('./config');
|
||||||
});
|
});
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
app.set('trust proxy', true);
|
||||||
|
|
||||||
// Determine if SSL certs are available
|
// Determine if SSL certs are available
|
||||||
const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey);
|
const hasSsl = fs.existsSync(config.sslCert) && fs.existsSync(config.sslKey);
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,12 @@ const { getUserPlan, getUserDeviceCount } = require('../middleware/subscription'
|
||||||
// In-memory store for latest screenshot per device (avoids disk writes during streaming)
|
// In-memory store for latest screenshot per device (avoids disk writes during streaming)
|
||||||
let lastScreenshots = {};
|
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) {
|
function logDeviceStatus(deviceId, status) {
|
||||||
try {
|
try {
|
||||||
db.prepare('INSERT INTO device_status_log (device_id, status) VALUES (?, ?)').run(deviceId, status);
|
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) {
|
if (device) {
|
||||||
currentDeviceId = device_id;
|
currentDeviceId = device_id;
|
||||||
db.prepare("UPDATE devices SET status = 'online', last_heartbeat = strftime('%s','now'), ip_address = ?, updated_at = strftime('%s','now') WHERE 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) {
|
if (device_info) {
|
||||||
db.prepare('UPDATE devices SET android_version = ?, app_version = ?, screen_width = ?, screen_height = ? WHERE id = ?')
|
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)
|
INSERT INTO devices (id, pairing_code, status, ip_address, android_version, app_version, screen_width, screen_height, last_heartbeat)
|
||||||
VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
|
VALUES (?, ?, 'provisioning', ?, ?, ?, ?, ?, strftime('%s','now'))
|
||||||
`).run(
|
`).run(
|
||||||
id, pairing_code, socket.handshake.address,
|
id, pairing_code, getClientIp(socket),
|
||||||
device_info?.android_version || null,
|
device_info?.android_version || null,
|
||||||
device_info?.app_version || null,
|
device_info?.app_version || null,
|
||||||
device_info?.screen_width || null,
|
device_info?.screen_width || null,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue