import { api } from '../api.js'; import { on, off, requestScreenshot, startRemote, stopRemote, sendTouch, sendSwipe, sendKey, sendCommand } from '../socket.js'; import { showToast } from '../components/toast.js'; import { esc, livenessBadge, hydrateAuthImages } from '../utils.js'; import { t, tn } from '../i18n.js'; import { showDeviceOwnerQRModal } from '../components/device-owner-qr-modal.js'; let currentDevice = null; let statusHandler = null; let screenshotHandler = null; let playbackHandler = null; let logHandler = null; let shellHandler = null; let diagPollTimer = null; // polls a diag-smoothness widget's reported frame stats while the page is open let screenshotInterval = null; let remoteActive = false; // Belt for the orphaned-stream fix: if the tab is hidden/closed/backgrounded while a Remote session // is live, stop it (the server also auto-stops on socket drop, but bfcache keeps the socket alive). if (typeof window !== 'undefined') { window.addEventListener('pagehide', () => { if (remoteActive && currentDevice) { remoteActive = false; try { stopRemote(currentDevice.id); } catch (e) {} } }); } // #161 device-owner Terminal presets. Commands chosen to work at the APP UID (not root) — getprop, // /proc + /sys reads, df, pm list, ip. dumpsys/settings are deliberately avoided (OS-denied to apps). const TERMINAL_PRESETS = [ { label: 'Device info', cmd: 'getprop ro.product.manufacturer; getprop ro.product.model; echo "Android $(getprop ro.build.version.release) (sdk $(getprop ro.build.version.sdk))"' }, { label: 'Build', cmd: 'getprop ro.build.fingerprint; echo "serial=$(getprop ro.serialno)"' }, { label: 'Memory', cmd: 'head -3 /proc/meminfo' }, { label: 'CPU', cmd: 'grep -iE "hardware|processor" /proc/cpuinfo | head; echo "cores=$(cat /proc/cpuinfo | grep -c ^processor)"' }, { label: 'Storage', cmd: 'df -h /data 2>/dev/null; df -h /storage/emulated/0 2>/dev/null' }, { label: 'Uptime', cmd: 'echo "up $(cut -d. -f1 /proc/uptime)s"' }, { label: 'Date / TZ', cmd: 'date; echo "tz=$(getprop persist.sys.timezone)"' }, { label: 'Display', cmd: 'getprop | grep -iE "lcd_density|ro.sf.lcd|ro.hwui|ro.surface_flinger" | head' }, { label: '3rd-party apps', cmd: 'pm list packages -3 2>/dev/null | sed s/package:// | head -40 || echo "pm list denied at app uid"' }, { label: 'Props', cmd: 'getprop | grep -iE "model|version.release|serialno|wifi.interface|timezone"' }, { label: 'Whoami', cmd: 'id' }, ]; function formatBytes(mb) { if (mb === null || mb === undefined) return '--'; if (mb >= 1024) return `${(mb / 1024).toFixed(1)} GB`; return `${mb} MB`; } function formatUptime(seconds) { if (!seconds) return '--'; const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); if (d > 0) return `${d}d ${h}h ${m}m`; if (h > 0) return `${h}h ${m}m`; return `${m}m`; } // #74/#75: device clock + skew indicator. Compares the device's reported UTC to the // server's receipt time; a gap > 2 min means the device clock is wrong, so per-item // schedules will fire at the wrong local time — surface it instead of a support mystery. function renderDeviceClock(device) { const tz = device.reported_timezone || device.timezone || '--'; if (!device.reported_utc || !device.reported_at) return tz; const skewSec = Math.abs(Math.round(device.reported_utc / 1000) - device.reported_at); let local = ''; try { local = new Date(device.reported_utc).toLocaleString(undefined, { timeZone: device.reported_timezone || undefined, hour: '2-digit', minute: '2-digit', month: 'short', day: 'numeric' }); } catch (e) { /* bad tz id -> skip local render */ } const warn = skewSec > 120 ? `
${t('device.clock.skew', { amount: skewSec >= 3600 ? Math.round(skewSec / 3600) + 'h' : Math.round(skewSec / 60) + 'm' })}
` : ''; return `${tz}${local ? `
${t('device.clock.reported', { time: local })}
` : ''}${warn}`; } export function render(container, deviceId) { container.innerHTML = `
${t('device.back')}

${t('common.loading')}

`; loadDevice(deviceId); // Real-time updates statusHandler = (data) => { if (data.device_id !== deviceId) return; const badge = document.querySelector('.device-status-badge'); if (badge) { const b = livenessBadge(data); // v4: 3-state liveness when present, else binary status badge.className = `device-status-badge ${b.state}`; badge.textContent = b.label; badge.title = b.title || ''; // exit-reason hover (empty for non-offline / no-reason) } if (data.telemetry) updateTelemetryDisplay(data.telemetry); }; screenshotHandler = (data) => { if (data.device_id !== deviceId) return; // Use inline base64 data if available, otherwise fall back to URL const imgSrc = data.image_data || (() => { const token = localStorage.getItem('token'); return data.url + (data.url.includes('?') ? '&' : '?') + 'token=' + token; })(); // Update screenshot in Now Playing tab const screenshotEl = document.getElementById('currentScreenshot'); if (screenshotEl) { if (screenshotEl.tagName === 'IMG') { screenshotEl.src = imgSrc; } else { // Replace placeholder div with actual image const img = document.createElement('img'); img.id = 'currentScreenshot'; img.src = imgSrc; img.alt = 'Current screen'; img.style.cssText = 'width:100%;height:100%;object-fit:contain'; screenshotEl.replaceWith(img); } } // Update remote canvas const canvas = document.getElementById('remoteCanvas'); if (canvas && remoteActive) { const ctx = canvas.getContext('2d'); const img = new Image(); img.onload = () => { canvas.width = img.width; canvas.height = img.height; ctx.drawImage(img, 0, 0); }; img.src = imgSrc; } }; playbackHandler = (data) => { if (data.device_id !== deviceId) return; const el = document.getElementById('nowPlayingInfo'); if (el && data.current_content_id) { el.textContent = t('device.now_playing_id', { id: data.current_content_id }); } }; // Live debug log lines streamed from the device (when the Debug logging // checkbox is on). Appended via textContent — no HTML injection. logHandler = (data) => { if (data.device_id !== deviceId) return; const panel = document.getElementById('debugLogPanel'); if (!panel) return; const line = document.createElement('div'); const time = new Date(data.ts || Date.now()).toLocaleTimeString(); line.textContent = `${time} [${data.tag || ''}] ${data.message || ''}`; panel.appendChild(line); while (panel.childElementCount > 500) panel.removeChild(panel.firstChild); panel.scrollTop = panel.scrollHeight; }; on('device-status', statusHandler); on('screenshot-ready', screenshotHandler); on('playback-state', playbackHandler); on('device-log', logHandler); } async function loadDevice(deviceId, activeTab = null) { const contentEl = document.getElementById('deviceContent'); try { const device = await api.getDevice(deviceId); currentDevice = device; const latestTelemetry = device.telemetry?.[0] || {}; const diagWidget = (device.assignments || []).find(a => a && a.widget_type === 'diag-smoothness'); contentEl.innerHTML = `

${device.name}

${(() => { const b = livenessBadge(device); return `${esc(b.label)}`; })()} ${device.owner_name || device.owner_email ? `${t('device.owner_label', { owner: device.owner_name || device.owner_email })}` : ''}
${device.android_version && !device.android_version.startsWith('Web/') ? ` ` : ''}
${device.tier === 2 ? `
${t('device.tier2.label')}
` : ''}
${t('device.tab.now_playing')} ?
${t('device.tab.playlist')} ?
${t('device.tab.info')} ?
${t('device.tab.remote')} ?
${(device.client_type === 'apk' || device.android_version) ? `
${t('device.tab.controls')} ?
` : ''} ${device.tier === 2 ? `
${t('device.tab.terminal')} ?
` : ''}
${device.screenshot ? `Current screen` : `
${t('device.no_screenshot')}
` }

${device.assignments?.length ? tn('device.playlist_count', device.assignments.length) : t('device.no_content_assigned')}

${device.playlist_status === 'draft' ? `
${t('device.draft.banner_title')}
${device.playlist_has_published ? t('device.draft.devices_showing_published') : t('device.draft.never_published')}
${device.playlist_has_published ? `` : ''}
` : ''}
${t('device.layout.label')}

${t('device.playlist.label')}

${renderPlaylist(device.assignments || [])}
${diagWidget ? renderDiagPanel(diagWidget) : ''}
${t('device.info.status')}
${device.status}
${t('device.info.ip_address')}
${device.ip_address || '--'}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.battery')}
${latestTelemetry.battery_level != null ? latestTelemetry.battery_level + '%' : '--'}
${latestTelemetry.battery_level != null ? `
` : ''}
${t('device.info.storage')}
${latestTelemetry.storage_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.storage_free_mb) }) : '--'}
${latestTelemetry.storage_total_mb ? `
` : ''}
` : `
${t('device.info.player_type')}
${t('device.info.web_player')}
`} ${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.wifi')}
${latestTelemetry.wifi_ssid || '--'}
${latestTelemetry.wifi_rssi ? latestTelemetry.wifi_rssi + ' dBm' : ''}
` : ''}
${t('device.info.uptime')}
${formatUptime(latestTelemetry.uptime_seconds)}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.android_version')}
${device.android_version}
${t('device.info.app_version')}
${device.app_version || '--'}
${t('device.info.settings_pin')}
${device.settings_pin || '--'}
${t('device.info.settings_pin_hint')}
` : ''}
${t('device.info.screen_resolution')}
${device.screen_width && device.screen_height ? device.screen_width + 'x' + device.screen_height + // #134: show the UI render surface alongside the HDMI output when they differ // (TV boxes that render at 720p and upscale to a 1080p signal). (device.render_width && device.render_height && (device.render_width !== device.screen_width || device.render_height !== device.screen_height) ? ` (UI ${device.render_width}x${device.render_height})` : '') : '--'}
${t('device.clock.label')}
${renderDeviceClock(device)}
${device.android_version && !device.android_version.startsWith('Web/') ? `
${t('device.info.ram')}
${latestTelemetry.ram_free_mb ? t('device.info.size_free', { size: formatBytes(latestTelemetry.ram_free_mb) }) : '--'}
${t('device.info.cpu_usage')}
${latestTelemetry.cpu_usage != null ? latestTelemetry.cpu_usage.toFixed(1) + '%' : '--'}
` : ''}

${t('device.timeline.title')}

${t('device.timeline.h24_ago')} ${t('device.timeline.now')}
${t('device.timeline.online')} ${t('device.timeline.offline')} ${t('device.timeline.no_data')}

${t('device.incidents.title')}

${t('device.ota.hint')}
${t('device.reboot_schedule.hint')}
${t('device.debug.hint')}
Overlay (PiP) — test

${t('device.remote.start_prompt')}






${device.tier === 2 ? ` ${t('device.remote.system_view_owner')} ` : ` ${t('device.remote.system_view_hint')}`}
${(device.client_type === 'apk' || device.android_version) ? `
${t('device.sysctl.subtitle')}
${(device.can_write_settings || device.tier === 2) ? ` ` : `
${t('device.sysctl.write_hint')}
`}
` : ''} ${device.tier === 2 ? `
${TERMINAL_PRESETS.map(p => ``).join('')}
${t('device.terminal.welcome')}\n
$
${t('device.terminal.uid_note')}

${t('device.terminal.push_apk')}
${t('device.terminal.push_apk_hint')}
` : ''} `; // If this device is assigned the smoothness-diagnostic widget, poll THIS device's reported stats. if (diagWidget) startDiagPoll(diagWidget.widget_id, deviceId); // Hydrate authenticated thumbnail images in the playlist tab const pc = document.getElementById('playlistContainer'); if (pc) hydrateAuthImages(pc); // Global key/command handlers for remote window._sendKey = (keycode) => { if (currentDevice) sendKey(currentDevice.id, keycode); }; window._sendCmd = (type) => { if (currentDevice) sendCommand(currentDevice.id, type, {}); }; window._enableSystemView = () => { if (!currentDevice) return; sendCommand(currentDevice.id, 'enable_system_capture', {}); // Unlock the system controls after a short delay (user needs to tap "Start now" on device) const btn = document.getElementById('enableSystemCaptureBtn'); const hint = document.getElementById('systemViewHint'); if (btn) { btn.textContent = t('device.remote.waiting_for_approval'); btn.disabled = true; } // Check periodically if the device granted it (we'll know because screenshots keep coming even after Home) setTimeout(() => { const controls = document.getElementById('systemViewControls'); if (controls) { controls.style.opacity = '1'; controls.style.pointerEvents = 'auto'; } if (btn) { btn.textContent = t('device.remote.system_view_enabled'); btn.style.background = 'var(--success)'; } if (hint) hint.textContent = t('device.remote.unlocked_hint'); }, 5000); }; // #161 device-owner Terminal tab (tier 2 only): a real scrollback shell + preset commands + push-APK. if (device.tier === 2) { const termOut = document.getElementById('termOut'); const append = (text) => { if (!termOut) return; termOut.textContent += text; termOut.scrollTop = termOut.scrollHeight; }; const runCmd = (cmd) => { if (!cmd) return; append('\n$ ' + cmd + '\n'); sendCommand(device.id, 'shell', { cmd }); }; const termCmd = document.getElementById('termCmd'); document.getElementById('termRun')?.addEventListener('click', () => { const c = termCmd?.value?.trim(); if (c) { runCmd(c); termCmd.value = ''; } }); termCmd?.addEventListener('keydown', (e) => { if (e.key === 'Enter') { const c = e.target.value.trim(); if (c) { runCmd(c); e.target.value = ''; } } }); document.querySelectorAll('.term-preset').forEach(b => b.addEventListener('click', () => runCmd(b.dataset.cmd))); document.getElementById('termClear')?.addEventListener('click', () => { if (termOut) termOut.textContent = ''; }); document.getElementById('apkInstall')?.addEventListener('click', () => { const url = document.getElementById('apkUrl')?.value?.trim(); if (!url) return; if (!/^https?:\/\//.test(url)) { showToast(t('device.owner_tools.bad_url'), 'error'); return; } sendCommand(device.id, 'install_apk', { url }); append('\n# push apk → ' + url + ' (installs silently on a device owner)\n'); showToast(t('device.owner_tools.apk_sent'), 'success'); }); if (shellHandler) off('shell-result', shellHandler); shellHandler = (data) => { if (data.device_id !== device.id) return; append((data.output || '') + (data.exit != null && data.exit !== 0 ? '\n[exit ' + data.exit + ']\n' : '\n')); }; on('shell-result', shellHandler); } // Render uptime timeline renderUptimeTimeline(device.uptimeData || [], device.statusLog || []); // Render the Recent incidents panel (merges typed device_events with // offline→online transitions derived from the status log). renderIncidents(device.deviceEvents || [], device.statusLog || []); setupTabs(); setupActions(device); setupRemote(device); setupPlaylistActions(device); // Restore active tab if specified (e.g. after layout change) if (activeTab) { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); const tab = document.querySelector(`.tab[data-tab="${activeTab}"]`); if (tab) tab.classList.add('active'); const content = document.getElementById(`tab-${activeTab}`); if (content) content.classList.add('active'); } // Request a fresh screenshot on page load + poll periodically. #159: the preview used to go stale // because nothing re-requested it — the device only sends a frame on an explicit request or during // a live Remote session. Poll every 5s while this page is open so the Now Playing preview stays // current (cleared on view teardown). A Remote session streams at its own faster rate on top. if (device.status === 'online') { requestScreenshot(deviceId); if (screenshotInterval) clearInterval(screenshotInterval); screenshotInterval = setInterval(() => { if (!document.hidden) requestScreenshot(deviceId); }, 5000); } } catch (err) { contentEl.innerHTML = `

${t('device.failed_load')}

${esc(err.message)}

`; } } function renderPlaylist(assignments) { if (!assignments.length) { return `

${t('device.playlist.empty_title')}

${t('device.playlist.empty_desc')}

`; } return assignments.map((a, i) => `
${a.widget_id && !a.content_id ? `
${{clock:'🕓',weather:'⛅',rss:'📰',text:'📝',webpage:'🌐',social:'💬'}[a.widget_type] || '⚙'}
` : a.thumbnail_path ? `` : `
` }
${esc(a.filename || a.widget_name || t('common.unknown'))}
${a.widget_id && !a.content_id ? t('device.pl_item.widget_with_type', { type: a.widget_type || 'custom' }) : a.mime_type === 'video/youtube' ? t('device.pl_item.youtube') : a.mime_type?.startsWith('video/') ? t('device.pl_item.video') : t('device.pl_item.image')} ${a.zone_id ? ` · ${t('device.pl_item.zone_label', { id: a.zone_id.slice(0,8) })}` : ''} ${a.content_duration ? ` · ${Math.floor(a.content_duration / 60)}:${String(Math.floor(a.content_duration % 60)).padStart(2, '0')}` : ''} ${!a.content_duration && !a.mime_type?.startsWith('video/') && a.duration_sec ? ` · ${a.duration_sec}s` : ''} ${a.schedule_start ? ` · ${a.schedule_start}-${a.schedule_end}` : ''}
${a.orphan ? `
${t('device.pl_item.orphan_zone')}
` : ''}
`).join(''); } function setupTabs() { document.querySelectorAll('.tab').forEach(tab => { tab.addEventListener('click', () => { document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active')); tab.classList.add('active'); document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); }); }); } // #104: device preview — reuse the player in device-free preview mode, iframed // same-origin (dashboard CSP frame-src 'self' allows it). Shows the device's CURRENT // playlist in the device's OWN layout/orientation (server payload). wall members // preview full-frame (server forces wall_config:null in v1). function showDevicePreview(device) { const portrait = (device.orientation || '').includes('portrait'); const overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px'; overlay.innerHTML = `
${t('device.preview_btn')} — ${esc(device.name)}
`; document.body.appendChild(overlay); const close = () => overlay.remove(); overlay.querySelector('#dpvClose').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; document.addEventListener('keydown', function esc2(ev) { if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc2); } }); } // #150 re-adopt fallback: browse the workspace's previously-removed device snapshots and // apply one onto THIS (usually blank, just-re-paired) device. Primary restore is the silent // fingerprint-match on re-pair; this is for factory-reset / new-hardware / changed-fingerprint. const ORIENT_LABELS = { 'landscape': 'device.form.orientation.landscape', 'portrait': 'device.form.orientation.portrait', 'landscape-flipped': 'device.form.orientation.landscape_flipped', 'portrait-flipped': 'device.form.orientation.portrait_flipped', }; const orientLabel = (o) => t(ORIENT_LABELS[o] || ORIENT_LABELS.landscape); const fmtTs = (ts) => (ts ? new Date(ts * 1000).toLocaleString() : '—'); // #161: device-owner provisioning helper — QR (scan after factory-reset, tap welcome 6×) + the ADB // one-liner. Device owner is optional; it unlocks silent updates, reboot, kiosk, time control. async function showReAdoptModal(device) { let snapshots, playlists; try { [snapshots, playlists] = await Promise.all([ api.getRemovedDevices(), api.getPlaylists().catch(() => []), // best-effort: only used to label the restored playlist ]); } catch (err) { showToast(err.message || t('device.readopt.error'), 'error'); return; } const plById = new Map((playlists || []).map(p => [p.id, p.name])); const playlistLabel = (s) => !s.playlist_id ? t('device.readopt.playlist_none') : (plById.get(s.playlist_id) || t('device.readopt.playlist_removed')); const rowsHtml = (snapshots || []).map((s, i) => { const blockedBadge = s.blocked ? `${t('device.readopt.blocked')}` : ''; // Fingerprint is the key but not an operator-facing identifier — truncated + on-hover only. const fpShort = (s.fingerprint || '').slice(0, 8); return `
${esc(s.device_name || t('device.readopt.unnamed'))}${blockedBadge}
${t('device.readopt.summary_orientation')}: ${esc(orientLabel(s.orientation))}  ·  ${t('device.readopt.summary_timezone')}: ${esc(s.timezone || 'UTC')}  ·  ${t('device.readopt.summary_playlist')}: ${esc(playlistLabel(s))}
${t('device.readopt.last_seen')}: ${esc(fmtTs(s.last_seen))}  ·  ${t('device.readopt.removed')}: ${esc(fmtTs(s.removed_at))}
`; }).join(''); const emptyHtml = `
${t('device.readopt.empty')}
`; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.style.display = 'flex'; overlay.innerHTML = ` `; document.body.appendChild(overlay); const close = () => overlay.remove(); overlay.querySelector('#readoptClose').onclick = close; overlay.onclick = (e) => { if (e.target === overlay) close(); }; overlay.querySelectorAll('.readopt-apply').forEach((btn) => { btn.addEventListener('click', async () => { const s = snapshots[parseInt(btn.dataset.i, 10)]; let msg = t('device.readopt.confirm', { source: s.device_name || t('device.readopt.unnamed'), target: device.name || '' }); if (s.blocked) msg += '\n\n⚠ ' + t('device.readopt.confirm_blocked'); // explicit: target will go dark if (!confirm(msg)) return; btn.disabled = true; try { await api.reAdoptDevice(device.id, s.fingerprint); showToast(t('device.readopt.success', { orientation: orientLabel(s.orientation) }), 'success'); close(); loadDevice(device.id); // refresh so restored orientation/name/etc show immediately } catch (err) { // Server messages: 404 no snapshot, 403 cross-workspace, 400 bad request. showToast(err.message || t('device.readopt.error'), 'error'); btn.disabled = false; } }); }); } function setupActions(device) { // #104 Preview button document.getElementById('devicePreviewBtn')?.addEventListener('click', () => showDevicePreview(device)); // Screenshot button document.getElementById('screenshotBtn')?.addEventListener('click', () => { requestScreenshot(device.id); showToast(t('device.toast.screenshot_requested'), 'info'); }); // Rename document.getElementById('renameBtn')?.addEventListener('click', async () => { const name = prompt(t('device.prompt_new_name'), device.name); if (name && name !== device.name) { try { await api.updateDevice(device.id, { name }); document.getElementById('deviceName').textContent = name; currentDevice.name = name; showToast(t('device.toast.renamed'), 'success'); } catch (err) { showToast(err.message, 'error'); } } }); // Populate default content dropdown (async, non-blocking — same .then() pattern as the // playlist picker below). setupActions is a SYNCHRONOUS function; awaiting here made the whole // file fail to parse ("Unexpected reserved word") AND would have deferred every listener below // (save, #150 re-adopt, delete) until this fetch resolved. .then() keeps them registering immediately. api.getContent().then(content => { const defaultSelect = document.getElementById('deviceDefaultContent'); if (defaultSelect) { content.forEach(c => { const opt = document.createElement('option'); opt.value = c.id; opt.textContent = c.filename; if (device.default_content_id === c.id) opt.selected = true; defaultSelect.appendChild(opt); }); } }).catch(() => {}); // Save settings (notes + orientation + default content) // Debug logging toggle: sends a transient set_debug command to the device and // reveals the live log panel. State is per-session (resets on device reconnect). document.getElementById('debugLogToggle')?.addEventListener('change', (e) => { const enabled = e.target.checked; const panel = document.getElementById('debugLogPanel'); if (panel) panel.style.display = enabled ? 'block' : 'none'; sendCommand(device.id, 'set_debug', { enabled }); }); document.getElementById('saveNotesBtn')?.addEventListener('click', async () => { try { await api.updateDevice(device.id, { notes: document.getElementById('deviceNotes').value, orientation: document.getElementById('deviceOrientation').value, default_content_id: document.getElementById('deviceDefaultContent').value || null, ota_enabled: document.getElementById('otaToggle')?.checked ? 1 : 0, reboot_schedule: document.getElementById('rebootSchedule')?.value || null, }); showToast(t('device.toast.settings_saved'), 'success'); } catch (err) { showToast(err.message, 'error'); } }); // #150 re-adopt: apply a previously-removed device's saved settings onto THIS device (the // fallback for when the fingerprint changed and automatic restore couldn't fire). document.getElementById('reAdoptBtn')?.addEventListener('click', () => showReAdoptModal(device)); // Publish / Discard from device detail const devicePublishBtn = document.getElementById('devicePublishBtn'); if (devicePublishBtn && device.playlist_id) { devicePublishBtn.addEventListener('click', async () => { try { devicePublishBtn.disabled = true; devicePublishBtn.textContent = t('device.draft.publishing'); await api.publishPlaylist(device.playlist_id); showToast(t('device.toast.published')); loadDevice(device.id, 'playlist'); } catch (err) { devicePublishBtn.disabled = false; devicePublishBtn.textContent = t('device.draft.publish'); showToast(err.message, 'error'); } }); } const deviceDiscardBtn = document.getElementById('deviceDiscardDraftBtn'); if (deviceDiscardBtn && device.playlist_id) { deviceDiscardBtn.addEventListener('click', async () => { if (!confirm(t('device.confirm_discard_draft'))) return; try { await api.discardPlaylistDraft(device.playlist_id); showToast(t('device.toast.draft_discarded')); loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }); } // Populate playlist picker const playlistPicker = document.getElementById('playlistPicker'); if (playlistPicker) { api.getPlaylists().then(playlists => { playlists.forEach(p => { const opt = document.createElement('option'); opt.value = p.id; opt.textContent = p.is_auto_generated ? t('device.playlist_picker.with_auto', { name: p.name, n: p.item_count }) : t('device.playlist_picker.with_count', { name: p.name, n: p.item_count }); if (p.id === device.playlist_id) opt.selected = true; playlistPicker.appendChild(opt); }); // If device has no playlist, keep "No playlist" selected if (!device.playlist_id) playlistPicker.value = ''; }).catch(() => {}); playlistPicker.addEventListener('change', async () => { const newPlaylistId = playlistPicker.value; if (!newPlaylistId) return; // Don't allow deselecting for now try { await api.assignPlaylistToDevice(newPlaylistId, device.id); device.playlist_id = newPlaylistId; const assignments = await api.getAssignments(device.id); const pc = document.getElementById('playlistContainer'); pc.innerHTML = renderPlaylist(assignments); hydrateAuthImages(pc); attachRemoveHandlers(device); showToast(t('device.toast.playlist_changed')); } catch (err) { showToast(err.message, 'error'); } }); } // Copy playlist to another device document.getElementById('copyPlaylistBtn')?.addEventListener('click', async () => { try { const devices = await api.getDevices(); const others = devices.filter(d => d.id !== device.id); if (!others.length) { showToast(t('device.copy.no_other_devices'), 'info'); return; } const targetId = prompt(t('device.copy.prompt', { list: others.map((d, i) => `${i + 1}. ${d.name}`).join('\n') })); if (!targetId) return; const target = others[parseInt(targetId) - 1]; if (!target) { showToast(t('device.copy.invalid_selection'), 'error'); return; } const token = localStorage.getItem('token'); const res = await fetch(`/api/assignments/device/${device.id}/copy-to/${target.id}`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, body: JSON.stringify({ replace: false }) }); const data = await res.json(); if (res.ok) showToast(t('device.copy.toast', { n: data.copied, device: target.name }), 'success'); else showToast(data.error, 'error'); } catch (err) { showToast(err.message, 'error'); } }); // #146 Item D: operator block/unblock — takes effect on the device's next register, // no restart. Server enforces even a device_id-less reconnect via the identity chain. document.getElementById('deviceOwnerBtn')?.addEventListener('click', () => showDeviceOwnerQRModal()); // #161 Tier-2 controls (rendered only for device-owner panels). const t2 = (type, confirm) => { if (confirm && !window.confirm(confirm)) return; sendCommand(device.id, type, {}); showToast(t('device.tier2.sent'), 'success'); }; document.getElementById('t2Reboot')?.addEventListener('click', () => t2('reboot', t('device.tier2.reboot_confirm'))); document.getElementById('t2Lock')?.addEventListener('click', () => t2('lock_now')); document.getElementById('t2KioskOn')?.addEventListener('click', () => t2('kiosk_lock')); document.getElementById('t2KioskOff')?.addEventListener('click', () => t2('kiosk_unlock')); // #160 Track-A system control — send on release ('change', not 'input') so we don't spam the panel. const bindLevel = (id, cmd) => { const el = document.getElementById(id); el?.addEventListener('change', () => sendCommand(device.id, cmd, { level: parseInt(el.value, 10) / 100 })); }; bindLevel('sysVolume', 'set_volume'); bindLevel('sysWinBrightness', 'set_brightness'); bindLevel('sysBrightness', 'set_system_brightness'); document.getElementById('sysTimeout')?.addEventListener('change', (e) => sendCommand(device.id, 'set_screen_timeout', { ms: parseInt(e.target.value, 10) })); const blockBtn = document.getElementById('blockDeviceBtn'); blockBtn?.addEventListener('click', async () => { blockBtn.disabled = true; try { if (device.blocked) { await api.unblockDevice(device.id); device.blocked = 0; showToast('Device unblocked', 'success'); } else { await api.blockDevice(device.id); device.blocked = 1; showToast('Device blocked — refused on next reconnect', 'success'); } blockBtn.textContent = device.blocked ? 'Unblock' : 'Block'; } catch (err) { showToast(err.message, 'error'); } finally { blockBtn.disabled = false; } }); // Delete (double-click to confirm) const deleteBtn = document.getElementById('deleteDeviceBtn'); let deleteConfirming = false; let deleteTimeout = null; deleteBtn?.addEventListener('click', async () => { if (deleteConfirming) { try { deleteBtn.textContent = t('device.toast.removing'); deleteBtn.disabled = true; await api.deleteDevice(device.id); showToast(t('device.toast.removed'), 'success'); window.location.hash = '/'; } catch (err) { showToast(err.message, 'error'); deleteBtn.textContent = t('device.remove'); deleteBtn.disabled = false; deleteConfirming = false; } return; } deleteConfirming = true; deleteBtn.textContent = t('device.click_to_confirm'); deleteBtn.style.background = 'var(--danger)'; deleteBtn.style.color = 'white'; clearTimeout(deleteTimeout); deleteTimeout = setTimeout(() => { deleteConfirming = false; deleteBtn.textContent = t('device.remove'); deleteBtn.style.background = ''; deleteBtn.style.color = ''; }, 3000); }); // Send a command and surface the three-state ack as a toast. // - delivered: device received it (green/success) // - queued: device is offline, will deliver on reconnect (amber/warning) // - no_ack / fallback: server didn't respond or queue unavailable (red/error) function sendWithFeedback(type, cmdLabel, successKey) { sendCommand(device.id, type, {}, (ack) => { if (ack?.delivered) showToast(t(successKey), 'success'); else if (ack?.queued) showToast(t('device.toast.command_queued', { cmd: cmdLabel }), 'warning'); else if (ack?.reason === 'no_ack') showToast(t('device.toast.command_no_ack', { cmd: cmdLabel }), 'error'); else showToast(t('device.toast.command_undeliverable', { cmd: cmdLabel }), 'error'); }); } // Reboot (double-click to confirm) const rebootBtn = document.getElementById('rebootBtn'); let rebootConfirming = false; let rebootTimeout = null; rebootBtn?.addEventListener('click', () => { if (rebootConfirming) { sendWithFeedback('reboot', 'Reboot', 'device.toast.reboot_sent'); rebootConfirming = false; rebootBtn.textContent = t('device.ctl.reboot_device'); return; } rebootConfirming = true; rebootBtn.textContent = t('device.click_to_confirm'); clearTimeout(rebootTimeout); rebootTimeout = setTimeout(() => { rebootConfirming = false; rebootBtn.textContent = t('device.ctl.reboot_device'); }, 3000); }); // Shutdown (double-click to confirm) const shutdownBtn = document.getElementById('shutdownBtn'); let shutdownConfirming = false; let shutdownTimeout = null; shutdownBtn?.addEventListener('click', () => { if (shutdownConfirming) { sendWithFeedback('shutdown', 'Shutdown', 'device.toast.shutdown_sent'); shutdownConfirming = false; shutdownBtn.textContent = t('device.ctl.shutdown'); return; } shutdownConfirming = true; shutdownBtn.textContent = t('device.click_to_confirm'); shutdownBtn.style.background = 'var(--danger)'; shutdownBtn.style.color = 'white'; clearTimeout(shutdownTimeout); shutdownTimeout = setTimeout(() => { shutdownConfirming = false; shutdownBtn.textContent = t('device.ctl.shutdown'); shutdownBtn.style.background = ''; shutdownBtn.style.color = ''; }, 3000); }); // Screen Off document.getElementById('screenOffBtn')?.addEventListener('click', () => { sendWithFeedback('screen_off', 'Screen off', 'device.toast.screen_off_sent'); }); // Screen On document.getElementById('screenOnBtn')?.addEventListener('click', () => { sendWithFeedback('screen_on', 'Screen on', 'device.toast.screen_on_sent'); }); // Launch Player document.getElementById('launchAppBtn')?.addEventListener('click', () => { sendWithFeedback('launch', 'Launch', 'device.toast.launch_sent'); }); // Force Update document.getElementById('forceUpdateBtn')?.addEventListener('click', () => { sendWithFeedback('update', 'Update', 'device.toast.update_triggered'); }); // #109: PiP overlay tester — pushes/clears an overlay via the public API (POST /api/pip). document.getElementById('sendPipBtn')?.addEventListener('click', async () => { const uri = (document.getElementById('pipUri')?.value || '').trim(); if (!uri) { showToast('Enter an overlay URL', 'error'); return; } try { const res = await api.sendPip(device.id, { type: document.getElementById('pipType').value, uri, position: document.getElementById('pipPosition').value, duration: Number(document.getElementById('pipDuration').value) || 0, }); showToast(`Overlay sent (${res.sent} sent, ${res.offline} offline)`, res.sent ? 'success' : 'warning'); } catch (err) { showToast(err.message, 'error'); } }); document.getElementById('clearPipBtn')?.addEventListener('click', async () => { try { await api.clearPip(device.id); showToast('Overlay cleared', 'success'); } catch (err) { showToast(err.message, 'error'); } }); } function setupRemote(device) { const startBtn = document.getElementById('startRemoteBtn'); const stopBtn = document.getElementById('stopRemoteBtn'); const overlay = document.getElementById('remoteOverlay'); const canvas = document.getElementById('remoteCanvas'); startBtn?.addEventListener('click', () => { console.log('Start Remote clicked for device:', device.id); remoteActive = true; startRemote(device.id); requestScreenshot(device.id); startBtn.style.display = 'none'; stopBtn.style.display = ''; overlay.style.display = 'none'; showToast(t('device.toast.remote_started'), 'info'); }); stopBtn?.addEventListener('click', () => { remoteActive = false; stopRemote(device.id); stopBtn.style.display = 'none'; startBtn.style.display = ''; overlay.style.display = 'flex'; }); // #159: mouse-as-finger. A click = tap; a drag = swipe (scroll). Pointer events so a press-move- // release maps to a gesture with the same normalized start/end + duration the device replays. let drag = null; const norm = (e) => { const rect = canvas.getBoundingClientRect(); return { x: (e.clientX - rect.left) / rect.width, y: (e.clientY - rect.top) / rect.height, cx: e.clientX - rect.left, cy: e.clientY - rect.top }; }; const feedback = (cx, cy) => { const ctx = canvas.getContext('2d'); ctx.beginPath(); ctx.arc(cx, cy, 10, 0, Math.PI * 2); ctx.fillStyle = 'rgba(59, 130, 246, 0.5)'; ctx.fill(); }; canvas?.addEventListener('pointerdown', (e) => { if (!remoteActive) return; const p = norm(e); drag = { ...p, t: Date.now() }; try { canvas.setPointerCapture(e.pointerId); } catch (_) {} feedback(p.cx, p.cy); }); canvas?.addEventListener('pointerup', (e) => { if (!remoteActive || !drag) return; const p = norm(e); const dist = Math.hypot(p.x - drag.x, p.y - drag.y); const dur = Math.min(1200, Math.max(120, Date.now() - drag.t)); if (dist > 0.02) { sendSwipe(device.id, drag.x, drag.y, p.x, p.y, dur); // drag → scroll const ctx = canvas.getContext('2d'); ctx.strokeStyle = 'rgba(59,130,246,0.6)'; ctx.lineWidth = 3; ctx.beginPath(); ctx.moveTo(drag.cx, drag.cy); ctx.lineTo(p.cx, p.cy); ctx.stroke(); } else { sendTouch(device.id, drag.x, drag.y, 'tap'); } drag = null; }); canvas?.addEventListener('pointercancel', () => { drag = null; }); } async function setupPlaylistActions(device) { // Load layouts into selector try { const layoutsRes = await fetch('/api/layouts', { headers: { Authorization: `Bearer ${localStorage.getItem('token')}` }}); const layouts = await layoutsRes.json(); const select = document.getElementById('deviceLayoutSelect'); if (select) { layouts.filter(l => !l.is_template).forEach(l => { const opt = document.createElement('option'); opt.value = l.id; opt.textContent = t('device.layout.zones_count', { name: l.name, n: l.zones?.length || 0 }); if (device.layout_id === l.id) opt.selected = true; select.appendChild(opt); }); // Add templates too layouts.filter(l => l.is_template).forEach(l => { const opt = document.createElement('option'); opt.value = l.id; opt.textContent = t('device.layout.template_zones_count', { name: l.name, n: l.zones?.length || 0 }); if (device.layout_id === l.id) opt.selected = true; select.appendChild(opt); }); } } catch (err) { console.warn('Failed to load layouts:', err); } // Apply layout button document.getElementById('applyLayoutBtn')?.addEventListener('click', async () => { const layoutId = document.getElementById('deviceLayoutSelect').value; try { await fetch(`/api/layouts/device/${device.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${localStorage.getItem('token')}` }, body: JSON.stringify({ layout_id: layoutId || null }) }); showToast(layoutId ? t('device.toast.layout_applied') : t('device.toast.switched_to_fullscreen'), 'success'); // Reload the device page to show updated zone selectors, stay on playlist tab loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }); // Add content button document.getElementById('addContentBtn')?.addEventListener('click', async () => { const token = localStorage.getItem('token'); const headers = { Authorization: `Bearer ${token}` }; try { const [content, widgets, kioskPages] = await Promise.all([ api.getContent(), fetch('/api/widgets', { headers }).then(r => r.json()), fetch('/api/kiosk', { headers }).then(r => r.json()), ]); // Get layout zones if device has a layout assigned. We track // zonesFetchFailed separately so the modal can distinguish "fetch // broke" from "fetch succeeded, layout genuinely has no zones" - // both end with zones=[] but the user message differs. // The !res.ok throw is required because fetch only rejects on network // errors; an HTTP 403/404 would otherwise json-parse into {error: ...} // and zones would silently be []. let zones = []; let zonesFetchFailed = false; if (device.layout_id) { try { const res = await fetch(`/api/layouts/${device.layout_id}`, { headers }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const layout = await res.json(); zones = layout.zones || []; } catch (e) { console.warn('Failed to load layout for zone picker:', e.message); zonesFetchFailed = true; } } if (!content.length && !widgets.length && !kioskPages.length) { showToast(t('device.assign.empty_all'), 'error'); return; } const modal = document.createElement('div'); modal.className = 'modal-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); hydrateAuthImages(modal, { eager: true }); // Tab switching modal.querySelectorAll('.assign-tab').forEach(tab => { tab.onclick = () => { modal.querySelectorAll('.assign-tab').forEach(t => { t.style.borderBottomColor = 'transparent'; t.style.color = 'var(--text-secondary)'; }); tab.style.borderBottomColor = 'var(--accent)'; tab.style.color = 'var(--accent)'; document.getElementById('assignMedia').style.display = tab.dataset.tab === 'media' ? '' : 'none'; document.getElementById('assignWidgets').style.display = tab.dataset.tab === 'widgets' ? '' : 'none'; document.getElementById('assignKiosk').style.display = tab.dataset.tab === 'kiosk' ? '' : 'none'; }; }); let selectedId = null; let selectedType = null; modal.querySelectorAll('.assign-content-item').forEach(item => { item.addEventListener('click', () => { modal.querySelectorAll('.assign-content-item').forEach(i => i.classList.remove('selected')); item.classList.add('selected'); selectedId = item.dataset.contentId; selectedType = item.dataset.type; }); }); modal.querySelector('#closeAssignModal').onclick = () => modal.remove(); modal.querySelector('#cancelAssign').onclick = () => modal.remove(); modal.querySelector('#confirmAssign').onclick = async () => { if (!selectedId) { showToast(t('device.assign.select_first'), 'error'); return; } const duration = parseInt(modal.querySelector('#assignDuration').value) || 10; const zoneId = modal.querySelector('#assignZone')?.value || null; try { if (selectedType === 'content') { await api.addAssignment(device.id, { content_id: selectedId, duration_sec: duration, zone_id: zoneId }); } else if (selectedType === 'widget') { await api.addAssignment(device.id, { widget_id: selectedId, duration_sec: duration, zone_id: zoneId }); } else if (selectedType === 'kiosk') { // For kiosk pages, create a webpage widget pointing to the kiosk render URL const serverUrl = window.location.origin; const wRes = await fetch('/api/widgets', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ widget_type: 'webpage', name: t('device.assign.kiosk_widget_name', { name: kioskPages.find(k => k.id === selectedId)?.name || 'Page' }), config: { url: `${serverUrl}/api/kiosk/${selectedId}/render` } }) }); const widget = await wRes.json(); await api.addAssignment(device.id, { widget_id: widget.id, duration_sec: 0 }); } modal.remove(); showToast(t('device.toast.added_to_playlist'), 'success'); loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }; } catch (err) { showToast(err.message, 'error'); } }); attachRemoveHandlers(device); } function attachRemoveHandlers(device) { // Populate zone selectors if device has a layout. The current zone_id for // each assignment is read from data-current-zone-id on the .zone-select // element (stashed at render time from a.zone_id); no DOM-scraping. // Fetch errors are logged - the dropdowns simply stay hidden (display:none // is the default from the render), same end-state as before but no longer // silent. // Inline per-item zone reassign dropdowns. A device WITH a layout always gets them; // visibility is NOT gated on whether the zone list arrived (gating on that silently hid // the selector when the server payload lacked active_layout_zones — e.g. a stale server // during a deploy skew). Only a genuinely fullscreen device (no layout_id) has no zones. if (device.layout_id) { // Show the selectors immediately; they become usable once zones populate below. document.querySelectorAll('.zone-select').forEach(s => { s.style.display = ''; }); // Clicking an item's orphan warning badge scrolls to + focuses its zone-select. document.querySelectorAll('.pl-orphan-warning').forEach(w => { w.addEventListener('click', () => { const sel = document.querySelector('.zone-select[data-assignment-id="' + w.dataset.orphanAssignment + '"]'); if (sel) { sel.scrollIntoView({ block: 'center', behavior: 'smooth' }); sel.focus(); } }); }); const populateZoneSelects = (zones) => { const activeIds = new Set((zones || []).map(z => z.id)); document.querySelectorAll('.zone-select').forEach(select => { select.style.display = ''; while (select.options.length > 1) select.remove(1); // keep the "no zone" placeholder, drop stale options const assignmentId = select.dataset.assignmentId; const currentZoneId = select.dataset.currentZoneId || ''; (zones || []).forEach(z => { const opt = document.createElement('option'); opt.value = z.id; opt.textContent = z.name; select.appendChild(opt); }); const orphan = !!currentZoneId && !activeIds.has(currentZoneId); if (currentZoneId && !orphan) select.value = currentZoneId; // can't select a zone the layout lacks if (orphan) { select.style.borderColor = 'var(--danger)'; select.style.color = 'var(--danger)'; } select.onchange = async () => { try { await api.updateAssignment(assignmentId, { zone_id: select.value || null }); showToast(t('device.toast.zone_updated'), 'success'); loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }; }); }; if (device.active_layout_zones && device.active_layout_zones.length) { // Fast path: zones already in the device payload — no round-trip. populateZoneSelects(device.active_layout_zones); } else { // Fallback: payload field absent/empty (server/frontend version skew, or a payload // change) — fetch the layout so the dropdowns still populate. A missing field must // never make the selector silently vanish. const token = localStorage.getItem('token'); fetch(`/api/layouts/${device.layout_id}`, { headers: { Authorization: `Bearer ${token}` } }) .then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }) .then(layout => populateZoneSelects(layout.zones || [])) .catch(e => console.warn('Zone dropdowns: layout fetch fallback failed:', e.message)); } } // Mute toggle buttons document.querySelectorAll('.mute-toggle').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.muteAssignment; const currentlyMuted = btn.dataset.muted === '1'; try { await api.updateAssignment(id, { muted: !currentlyMuted }); showToast(currentlyMuted ? t('device.toast.unmuted') : t('device.toast.muted'), 'success'); loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }); }); // Remove buttons document.querySelectorAll('[data-remove-assignment]').forEach(btn => { btn.addEventListener('click', async (e) => { e.stopPropagation(); const id = btn.dataset.removeAssignment; try { await api.deleteAssignment(id); showToast(t('device.toast.removed_from_playlist'), 'success'); loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); } }); }); // Drag-and-drop reorder const container = document.getElementById('playlistContainer'); if (!container) return; let dragItem = null; container.querySelectorAll('.playlist-item[draggable]').forEach(item => { item.addEventListener('dragstart', (e) => { dragItem = item; item.style.opacity = '0.4'; e.dataTransfer.effectAllowed = 'move'; }); item.addEventListener('dragend', () => { item.style.opacity = '1'; dragItem = null; container.querySelectorAll('.playlist-item').forEach(i => i.style.borderTop = ''); }); item.addEventListener('dragover', (e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; container.querySelectorAll('.playlist-item').forEach(i => i.style.borderTop = ''); if (item !== dragItem) item.style.borderTop = '2px solid var(--accent)'; }); item.addEventListener('drop', async (e) => { e.preventDefault(); item.style.borderTop = ''; if (!dragItem || dragItem === item) return; // Get new order const items = [...container.querySelectorAll('.playlist-item[data-assignment-id]')]; const fromIdx = items.indexOf(dragItem); const toIdx = items.indexOf(item); if (fromIdx < 0 || toIdx < 0) return; // Reorder in DOM if (fromIdx < toIdx) item.after(dragItem); else item.before(dragItem); // Get new order of assignment IDs const newOrder = [...container.querySelectorAll('.playlist-item[data-assignment-id]')] .map(el => parseInt(el.dataset.assignmentId)); try { await api.reorderAssignments(device.id, newOrder); showToast(t('device.toast.playlist_reordered'), 'success'); loadDevice(device.id, 'playlist'); } catch (err) { showToast(err.message, 'error'); loadDevice(device.id, 'playlist'); } }); }); } function renderUptimeTimeline(uptimeData, statusLog = []) { const timeline = document.getElementById('uptimeTimeline'); const percentEl = document.getElementById('uptimePercent'); if (!timeline) return; const now = Math.floor(Date.now() / 1000); const dayAgo = now - 86400; const slots = 96; // 15-minute slots over 24 hours const slotDuration = 86400 / slots; // 900 seconds = 15 min // Build slot status: 'online', 'offline', or 'unknown' const slotStatus = new Array(slots).fill('unknown'); // Parallel array: for offline slots, the {reason, detail} of the covering offline event // (why the device was offline) — surfaced in the slot's hover title. const slotReason = new Array(slots).fill(null); // First pass: mark slots that have heartbeat telemetry as online for (const ts of uptimeData) { const slotIdx = Math.floor((ts - dayAgo) / slotDuration); if (slotIdx >= 0 && slotIdx < slots) slotStatus[slotIdx] = 'online'; } // Second pass: use status log events to paint ranges // Walk through events and fill slots between online/offline transitions for (let i = 0; i < statusLog.length; i++) { const event = statusLog[i]; const nextEvent = statusLog[i + 1]; const startSlot = Math.max(0, Math.floor((event.timestamp - dayAgo) / slotDuration)); const endSlot = nextEvent ? Math.min(slots - 1, Math.floor((nextEvent.timestamp - dayAgo) / slotDuration)) : (event.status === 'online' ? slots - 1 : startSlot); const isOnline = event.status === 'online'; const reason = isOnline ? null : { reason: event.reason || null, detail: event.detail || null }; for (let s = startSlot; s <= endSlot && s < slots; s++) { if (s >= 0) { slotStatus[s] = isOnline ? 'online' : 'offline'; slotReason[s] = reason; } } } // Mark future slots as unknown const nowSlot = Math.floor((now - dayAgo) / slotDuration); for (let i = nowSlot + 1; i < slots; i++) slotStatus[i] = 'unknown'; // Calculate uptime percentage (only over known slots) const knownSlots = slotStatus.filter(s => s !== 'unknown').length; const onlineSlots = slotStatus.filter(s => s === 'online').length; const uptimePct = knownSlots > 0 ? Math.round((onlineSlots / knownSlots) * 100) : 0; if (percentEl) { percentEl.textContent = knownSlots > 0 ? t('device.timeline.uptime_pct_tracked', { pct: uptimePct, n: knownSlots * 15 }) : t('device.timeline.uptime_pct_no_data', { pct: uptimePct }); } // Color map const colors = { online: 'var(--success)', offline: 'var(--danger)', unknown: 'var(--bg-secondary)' }; const opacities = { online: 0.8, offline: 0.6, unknown: 0.3 }; // Render bars timeline.innerHTML = slotStatus.map((status, i) => { const time = new Date((dayAgo + i * slotDuration) * 1000); const label = time.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' }); const statusLabel = status === 'unknown' ? t('device.timeline.no_data') : status === 'online' ? t('device.timeline.online') : t('device.timeline.offline'); let title = `${label} - ${statusLabel}`; if (status === 'offline' && slotReason[i]) { const r = slotReason[i]; title = `${label} – ${statusLabel} · ${eventLabel(r.reason)}${r.detail ? ` (${r.detail})` : ''}`; } return `
`; }).join(''); } // Map an event/reason token to a friendly label via i18n, falling back to the raw // token if no translation exists. Null → "Unknown cause". function eventLabel(key) { if (!key) return t('device.event.silent'); const full = t('device.event.' + key); return full === ('device.event.' + key) ? key : full; } // Dot color by incident type. Amber (#f59e0b) matches the warning accent used // elsewhere in this view; the rest use the shared CSS vars. function incidentColor(type) { if (type === 'online' || type === 'display_on') return 'var(--success)'; if (type === 'display_off') return 'var(--text-muted)'; if (type === 'reboot') return '#f59e0b'; return 'var(--danger)'; // offline, network, crash, app_error } // Compact duration ("4m", "1h 5m", "2d 3h") for an offline period. function formatDur(seconds) { seconds = Math.max(0, Math.floor(seconds)); if (seconds < 60) return seconds + 's'; const m = Math.floor(seconds / 60); if (m < 60) return m + 'm'; const h = Math.floor(m / 60); const rm = m % 60; if (h < 24) return rm ? `${h}h ${rm}m` : `${h}h`; const d = Math.floor(h / 24); return `${d}d ${h % 24}h`; } // Compact relative time ("2h ago"). function relTime(tsSec, nowSec = Math.floor(Date.now() / 1000)) { const diff = Math.max(0, nowSec - tsSec); if (diff < 60) return diff + 's ago'; const m = Math.floor(diff / 60); if (m < 60) return m + 'm ago'; const h = Math.floor(m / 60); if (h < 24) return h + 'h ago'; const d = Math.floor(h / 24); return d + 'd ago'; } // "Recent incidents" panel: a newest-first, time-sorted merge of typed device_events // (display sleep, crash, reboot, network, app_error) with offline→online periods // derived from the status log (so a device with only server-side offline data still // shows incidents, and downtime carries a duration). function renderIncidents(deviceEvents = [], statusLog = []) { const panel = document.getElementById('incidentsPanel'); if (!panel) return; const nowSec = Math.floor(Date.now() / 1000); const incidents = []; // Offline periods from the status log (server-side ground truth). Start a period // on each offline transition and close it at the next 'online' row. const log = (statusLog || []).slice().sort((a, b) => a.timestamp - b.timestamp); for (let i = 0; i < log.length; i++) { const ev = log[i]; if (ev.status === 'online') continue; // Collapse a repeated offline row (e.g. offline followed by offline_timeout). if (i > 0 && log[i - 1].status !== 'online') continue; let end = null; for (let j = i + 1; j < log.length; j++) { if (log[j].status === 'online') { end = log[j].timestamp; break; } } incidents.push({ type: 'offline', reason: ev.reason || null, detail: ev.detail || null, timestamp: ev.timestamp, durationSec: (end != null ? end : nowSec) - ev.timestamp, ongoing: end == null, }); } // Typed incidents from device_events. offline/online are already represented as // periods above, so skip them here to avoid double-listing the same event. for (const ev of (deviceEvents || [])) { if (!ev || ev.type === 'offline' || ev.type === 'online') continue; incidents.push({ type: ev.type, reason: ev.reason || null, detail: ev.detail || null, timestamp: ev.timestamp, }); } if (!incidents.length) { panel.innerHTML = `
${t('device.incidents.none')}
`; return; } incidents.sort((a, b) => b.timestamp - a.timestamp); panel.innerHTML = incidents.slice(0, 15).map(inc => { const label = eventLabel(inc.reason || inc.type); const detail = inc.detail ? `${esc(inc.detail)}` : ''; const dur = (inc.durationSec != null) ? `${esc(t('device.incidents.down_for', { dur: formatDur(inc.durationSec) }) + (inc.ongoing ? '…' : ''))}` : ''; return `
${esc(label)} ${detail} ${dur} ${esc(relTime(inc.timestamp, nowSec))}
`; }).join(''); } function updateTelemetryDisplay(telemetry) { const update = (id, val) => { const el = document.getElementById(id); if (el) el.textContent = val; }; if (telemetry.battery_level != null) update('telBattery', telemetry.battery_level + '%'); if (telemetry.storage_free_mb) update('telStorage', t('device.info.size_free', { size: formatBytes(telemetry.storage_free_mb) })); if (telemetry.wifi_ssid) update('telWifi', telemetry.wifi_ssid); if (telemetry.wifi_rssi) update('telRssi', telemetry.wifi_rssi + ' dBm'); if (telemetry.uptime_seconds) update('telUptime', formatUptime(telemetry.uptime_seconds)); if (telemetry.ram_free_mb) update('telRam', t('device.info.size_free', { size: formatBytes(telemetry.ram_free_mb) })); if (telemetry.cpu_usage != null) update('telCpu', telemetry.cpu_usage.toFixed(1) + '%'); } // ----- diag-smoothness widget: show the frame stats it reports from the panel ----- function renderDiagPanel(w) { return `
Frame-rate diagnostic${w.widget_name ? ' · ' + esc(w.widget_name) : ''}
waiting for panel…
FPS
--
Refresh
--
Long frames
--
Worst stall
--
The panel running this widget reports its frame timing here every few seconds.
`; } function startDiagPoll(widgetId, deviceId) { if (diagPollTimer) clearInterval(diagPollTimer); const url = `/api/widgets/${encodeURIComponent(widgetId)}/telemetry?device=${encodeURIComponent(deviceId)}`; const tick = async () => { try { const res = await fetch(url, { cache: 'no-store' }); updateDiagPanel(res.ok ? await res.json() : null); } catch (e) { /* transient — keep last shown */ } }; tick(); diagPollTimer = setInterval(tick, 2500); } function updateDiagPanel(d) { const v = document.getElementById('diagVerdict'); if (!v) { if (diagPollTimer) { clearInterval(diagPollTimer); diagPollTimer = null; } return; } // navigated away const set = (id, val, color) => { const el = document.getElementById(id); if (el) { el.textContent = val; if (color) el.style.color = color; } }; if (!d) { v.textContent = 'no report yet'; v.style.color = 'var(--text-muted)'; set('diagMeta', 'No data received yet — make sure the panel is showing this widget, then give it a few seconds.'); return; } const age = d.receivedAt ? Math.max(0, Math.round((Date.now() - d.receivedAt) / 1000)) : null; const stale = age != null && age > 15; const verdict = d.verdict || 'measuring'; const vcolor = verdict === 'SMOOTH' ? 'var(--success)' : (verdict === 'STALLING' ? 'var(--danger)' : 'var(--text-muted)'); // If we haven't heard from THIS panel recently, say so plainly rather than showing stale numbers as live. if (stale) { v.textContent = 'no live report'; v.style.color = 'var(--text-muted)'; } else { v.textContent = verdict; v.style.color = vcolor; } set('diagFps', d.fps != null ? String(d.fps) : '--'); set('diagHz', d.refreshHz != null ? d.refreshHz + ' Hz' : '--'); set('diagLong', d.longFrames != null ? String(d.longFrames) : '--', d.longFrames > 0 ? 'var(--danger)' : 'var(--success)'); set('diagWorst', d.worstStallMs != null ? d.worstStallMs + ' ms' : '--', d.worstStallMs > 50 ? 'var(--danger)' : 'var(--text)'); const meta = []; if (d.vp) meta.push('viewport ' + d.vp); if (d.dpr) meta.push('dpr ' + d.dpr); if (d.elapsedS != null) meta.push('running ' + d.elapsedS + 's'); if (age != null) meta.push('last report ' + age + 's ago'); set('diagMeta', meta.join(' · ')); set('diagStalls', Array.isArray(d.recent) && d.recent.length ? ('recent stalls: ' + d.recent.join(' ')) : ''); } export function cleanup() { if (diagPollTimer) { clearInterval(diagPollTimer); diagPollTimer = null; } if (statusHandler) off('device-status', statusHandler); if (screenshotHandler) off('screenshot-ready', screenshotHandler); if (playbackHandler) off('playback-state', playbackHandler); if (logHandler) off('device-log', logHandler); if (shellHandler) off('shell-result', shellHandler); // #161 owner-tools listener if (screenshotInterval) clearInterval(screenshotInterval); if (remoteActive && currentDevice) stopRemote(currentDevice.id); remoteActive = false; currentDevice = null; window._sendKey = null; }