screentinker/frontend/js/app.js
ScreenTinker 19f434d05a Add player debug overlay and server-side error telemetry sink
Smart TVs (Tizen, WebOS, Fire TV, Bravia) have no accessible browser
devtools, so when the player misbehaves on those platforms we previously
had zero visibility. This adds two paths to fix that:

- Visible debug overlay rendered on the TV screen for phone-photo capture
- Automatic server-side telemetry sink for hands-off error reporting

Client side (server/player/):
- Inline ES5 error trap as first script in index.html captures errors
  even from parse-time failures in later scripts. Captures into
  window.__debugLog with 200-entry cap.
- debug-overlay.js renders a fixed-position overlay covering the top 40%
  of the screen. Activates via ?debug=1, d-e-b-u-g key sequence, Samsung
  red button (keyCode 403), or smart-TV UA + ?autodebug=1. Freeze toggle
  (F key or Samsung green) with visible FROZEN badge for phone capture.
  pointer-events: none so touches pass through to the player underneath.
- Reporter machinery posts captured errors to /api/player-debug with
  5-second debounce batching, sendBeacon on unload (with payload size
  capping to stay under 64KB), 5-minute backoff after 429 responses.
  UA-gated: smart-TV allow-list first (handles Tizen-with-Chrome/108),
  modern-desktop deny-list second, default-report for unknown UAs.
- Two-pass djb2 fingerprint (16 hex chars) per error for future grouping.
- Absolute script src (/player/debug-overlay.js) so the script loads
  regardless of trailing-slash on the player URL.

Server side:
- New player_debug_logs table (10000-row FIFO cap, indexed on
  fingerprint + created_at). Schema in schema.sql, idempotent via
  CREATE TABLE IF NOT EXISTS.
- POST /api/player-debug unauthenticated (so unpaired players can also
  report), rate-limited 10/min/IP, per-field length caps to prevent abuse.
- Dynamic /player HTML route injects window.__playerConfig.debugReporting
  based on PLAYER_DEBUG_REPORTING env var (defaults on; =off suppresses
  all client telemetry traffic). Other player assets still served static.
- Admin routes (requireAuth + requireSuperAdmin):
  GET /api/player-debug/list with pagination and filters
  GET /api/player-debug/summary for UA family counts
  DELETE /api/player-debug/older-than for manual purge

Admin view (#/admin/player-debug):
- UA family summary at top (Tizen/WebOS/Fire TV/Bravia/Edge/Chrome/etc)
- Filter row: UA contains, date range, has-error checkbox
- Paginated table with expand-row JSON viewer for error_data and context
- device_id labeled (self-reported) since field is unauthenticated input
- Manual delete-older-than button with confirmation dialog

Verified end-to-end with Playwright + Chromium (17/17 checks pass) plus
manual real-browser verification including UA-spoofed Tizen flow landing
rows in the admin view.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 15:20:42 -05:00

380 lines
15 KiB
JavaScript

import { connectSocket } from './socket.js';
import * as dashboard from './views/dashboard.js';
import * as deviceDetail from './views/device-detail.js';
import * as contentLibrary from './views/content-library.js';
import * as settings from './views/settings.js';
import * as login from './views/login.js';
import * as billing from './views/billing.js';
import * as layoutEditor from './views/layout-editor.js';
import * as schedule from './views/schedule.js';
import * as widgets from './views/widgets.js';
import * as videoWall from './views/video-wall.js';
import * as reports from './views/reports.js';
import * as activity from './views/activity.js';
import * as kiosk from './views/kiosk.js';
import * as onboarding from './views/onboarding.js';
import * as help from './views/help.js';
import * as teams from './views/teams.js';
import * as admin from './views/admin.js';
import * as adminPlayerDebug from './views/admin-player-debug.js';
import * as designer from './views/designer.js';
import * as playlists from './views/playlists.js';
import { applyBranding } from './branding.js';
import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
import { renderWorkspaceSwitcher } from './components/workspace-switcher.js';
const app = document.getElementById('app');
const sidebar = document.querySelector('.sidebar');
let currentView = null;
// Map nav-link data-view to its translation key.
const NAV_LABEL_KEYS = {
dashboard: 'nav.displays',
content: 'nav.content',
playlists: 'nav.playlists',
layouts: 'nav.layouts',
widgets: 'nav.widgets',
schedule: 'nav.schedule',
walls: 'nav.walls',
reports: 'nav.reports',
kiosk: 'nav.kiosk',
designer: 'nav.designer',
activity: 'nav.activity',
teams: 'nav.teams',
help: 'nav.help',
settings: 'nav.settings',
billing: 'nav.subscription',
admin: 'nav.admin',
};
function renderNavLabels() {
document.querySelectorAll('.nav-link').forEach((link) => {
const key = NAV_LABEL_KEYS[link.dataset.view];
if (!key) return;
const span = link.querySelector('span');
if (span) span.textContent = t(key);
});
}
// Translate any element marked with data-i18n / data-i18n-placeholder /
// data-i18n-html. Runs on init and on every language change. Used for static
// HTML in index.html (e.g. the Add-Display modal) where t() can't be inlined
// at template time.
function translateStaticDom(root = document) {
root.querySelectorAll('[data-i18n]').forEach((el) => {
const key = el.getAttribute('data-i18n');
el.textContent = t(key);
});
root.querySelectorAll('[data-i18n-html]').forEach((el) => {
el.innerHTML = t(el.getAttribute('data-i18n-html'));
});
root.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
el.placeholder = t(el.getAttribute('data-i18n-placeholder'));
});
}
function isAuthenticated() {
return !!localStorage.getItem('token');
}
function getCurrentUser() {
try {
return JSON.parse(localStorage.getItem('user'));
} catch { return null; }
}
// Refresh the cached user from the server. The server reads plan_id fresh
// from the DB on every request, but the frontend only wrote `user` into
// localStorage at login — so plan/role changes made by an admin weren't
// visible until the user logged out and back in.
async function refreshCurrentUser() {
const token = localStorage.getItem('token');
if (!token) return;
try {
const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) return;
const fresh = await res.json();
localStorage.setItem('user', JSON.stringify(fresh));
// Re-render the workspace switcher on every /me refresh - cheap, and keeps
// the dropdown in sync if a workspace was added/removed in another tab.
renderWorkspaceSwitcher(fresh);
window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh }));
} catch {}
}
function route() {
// Cleanup previous view
if (currentView && currentView.cleanup) currentView.cleanup();
const hash = window.location.hash || '#/';
// Auth check - redirect to login if not authenticated
if (!isAuthenticated() && hash !== '#/login') {
window.location.hash = '#/login';
return;
}
// If authenticated and on login page, redirect to dashboard or onboarding
if (isAuthenticated() && hash === '#/login') {
window.location.hash = localStorage.getItem('rd_onboarded') ? '#/' : '#/onboarding';
return;
}
// Onboarding for new users
if (hash === '#/onboarding' && isAuthenticated()) {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
currentView = onboarding;
onboarding.render(app);
return;
}
// Login page - hide sidebar
if (hash === '#/login') {
sidebar.style.display = 'none';
app.style.marginLeft = '0';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = 'none';
currentView = login;
login.render(app);
return;
}
// Show sidebar for authenticated views
sidebar.style.display = '';
app.style.marginLeft = '';
const mb = document.getElementById('mobileMenuBtn');
if (mb) mb.style.display = '';
// Update user info in sidebar
updateSidebarUser();
const navLinks = document.querySelectorAll('.nav-link');
navLinks.forEach(link => {
link.classList.remove('active');
if (hash === '#/' && link.dataset.view === 'dashboard') link.classList.add('active');
else if (hash.startsWith('#/content') && link.dataset.view === 'content') link.classList.add('active');
else if (hash.startsWith('#/settings') && link.dataset.view === 'settings') link.classList.add('active');
else if (hash.startsWith('#/billing') && link.dataset.view === 'billing') link.classList.add('active');
else if ((hash.startsWith('#/layout') || hash === '#/layouts') && link.dataset.view === 'layouts') link.classList.add('active');
else if ((hash === '#/playlists' || hash.startsWith('#/playlists/')) && link.dataset.view === 'playlists') link.classList.add('active');
else if (hash === '#/schedule' && link.dataset.view === 'schedule') link.classList.add('active');
else if (hash === '#/widgets' && link.dataset.view === 'widgets') link.classList.add('active');
else if ((hash.startsWith('#/wall') || hash === '#/walls') && link.dataset.view === 'walls') link.classList.add('active');
else if (hash === '#/reports' && link.dataset.view === 'reports') link.classList.add('active');
else if (hash === '#/activity' && link.dataset.view === 'activity') link.classList.add('active');
else if (hash === '#/designer' && link.dataset.view === 'designer') link.classList.add('active');
else if ((hash === '#/kiosk' || hash.startsWith('#/kiosk/')) && link.dataset.view === 'kiosk') link.classList.add('active');
else if (hash === '#/help' && link.dataset.view === 'help') link.classList.add('active');
else if (hash.startsWith('#/device/') && link.dataset.view === 'dashboard') link.classList.add('active');
});
// Route to view
if (hash === '#/' || hash === '#' || hash === '') {
currentView = dashboard;
dashboard.render(app);
} else if (hash.startsWith('#/device/')) {
const deviceId = hash.split('#/device/')[1].split('/')[0];
currentView = deviceDetail;
deviceDetail.render(app, deviceId);
} else if (hash === '#/content') {
currentView = contentLibrary;
contentLibrary.render(app);
} else if (hash === '#/playlists' || hash.startsWith('#/playlists/')) {
currentView = playlists;
playlists.render(app);
} else if (hash === '#/layouts' || hash.startsWith('#/layout/')) {
currentView = layoutEditor;
layoutEditor.render(app);
} else if (hash === '#/schedule') {
currentView = schedule;
schedule.render(app);
} else if (hash === '#/widgets') {
currentView = widgets;
widgets.render(app);
} else if (hash === '#/walls' || hash.startsWith('#/wall/')) {
currentView = videoWall;
videoWall.render(app);
} else if (hash === '#/reports') {
currentView = reports;
reports.render(app);
} else if (hash === '#/kiosk' || hash.startsWith('#/kiosk/')) {
currentView = kiosk;
kiosk.render(app);
} else if (hash === '#/designer') {
currentView = designer;
designer.render(app);
} else if (hash === '#/activity') {
currentView = activity;
activity.render(app);
} else if (hash === '#/teams' || hash.startsWith('#/team/')) {
currentView = teams;
teams.render(app);
} else if (hash === '#/help' || hash.startsWith('#/help')) {
currentView = help;
help.render(app);
} else if (hash.startsWith('#/admin/player-debug')) {
// Match prefix so query params (?page=2&ua=Tizen) route correctly.
currentView = adminPlayerDebug;
adminPlayerDebug.render(app);
} else if (hash === '#/admin') {
currentView = admin;
admin.render(app);
} else if (hash === '#/settings') {
currentView = settings;
settings.render(app);
} else if (hash === '#/billing') {
currentView = billing;
billing.render(app);
} else {
currentView = dashboard;
dashboard.render(app);
}
}
function updateSidebarUser() {
const user = getCurrentUser();
if (!user) return;
// Show admin nav only for platform admins (legacy 'superadmin' or Phase 1 renamed 'platform_admin')
const adminNav = document.getElementById('adminNavItem');
if (adminNav) adminNav.style.display = isPlatformAdmin(user) ? '' : 'none';
let userEl = document.getElementById('sidebarUser');
if (!userEl) {
const footer = document.querySelector('.sidebar-footer');
userEl = document.createElement('div');
userEl.id = 'sidebarUser';
userEl.style.cssText = 'display:flex;align-items:center;gap:8px;margin-bottom:12px;padding-bottom:12px;border-bottom:1px solid var(--border)';
footer.insertBefore(userEl, footer.firstChild);
}
userEl.innerHTML = `
${user.avatar_url ? `<img src="${user.avatar_url}" style="width:28px;height:28px;border-radius:50%">` :
`<div style="width:28px;height:28px;border-radius:50%;background:var(--accent);display:flex;align-items:center;justify-content:center;font-size:12px;font-weight:600;color:white">${(user.name || user.email)[0].toUpperCase()}</div>`}
<div style="flex:1;min-width:0">
<div style="font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${user.name || user.email}</div>
<div style="font-size:10px;color:var(--text-muted)">${user.role}</div>
</div>
<button id="logoutBtn" class="btn-icon" title="${t('auth.sign_out')}" style="flex-shrink:0">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16 17 21 12 16 7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
</button>
`;
document.getElementById('logoutBtn')?.addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
});
}
// Initialize
renderNavLabels();
translateStaticDom();
window.addEventListener('language-changed', () => {
renderNavLabels();
translateStaticDom();
});
if (isAuthenticated()) {
connectSocket();
applyBranding();
refreshCurrentUser().then(() => updateSidebarUser());
}
// Refresh the cached user on every route transition so plan/role changes
// made by an admin propagate without requiring a re-login.
window.addEventListener('hashchange', () => { if (isAuthenticated()) refreshCurrentUser(); });
// Register PWA service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/sw-admin.js').catch(() => {});
}
// Mobile sidebar: open/close via hamburger, backdrop, nav tap, Escape
const sidebarEl = document.querySelector('.sidebar');
const backdropEl = document.getElementById('sidebarBackdrop');
const menuBtn = document.getElementById('mobileMenuBtn');
function setMobileNav(open) {
if (!sidebarEl || !backdropEl) return;
sidebarEl.classList.toggle('open', open);
backdropEl.classList.toggle('open', open);
menuBtn?.setAttribute('aria-expanded', open ? 'true' : 'false');
}
menuBtn?.addEventListener('click', () => {
setMobileNav(!sidebarEl.classList.contains('open'));
});
backdropEl?.addEventListener('click', () => setMobileNav(false));
window.addEventListener('hashchange', () => setMobileNav(false));
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && sidebarEl?.classList.contains('open')) setMobileNav(false);
});
// Auto-reload on frontend update (no more hard refresh needed)
let knownHash = null;
setInterval(async () => {
try {
const res = await fetch('/api/version');
const { hash } = await res.json();
if (knownHash === null) { knownHash = hash; return; }
if (hash !== knownHash) {
knownHash = hash;
const toast = document.getElementById('toastContainer');
if (toast) {
const notice = document.createElement('div');
notice.className = 'toast info';
notice.innerHTML = '<span>Dashboard updated. <a href="javascript:location.reload()" style="color:var(--accent);text-decoration:underline;font-weight:600">Reload now</a></span>';
toast.appendChild(notice);
}
}
} catch {}
}, 15000);
// Session timeout warning - check JWT expiry every minute
if (isAuthenticated()) {
setInterval(() => {
const token = localStorage.getItem('token');
if (!token) return;
try {
const payload = JSON.parse(atob(token.split('.')[1]));
const expiresIn = (payload.exp * 1000) - Date.now();
const minutesLeft = Math.floor(expiresIn / 60000);
if (minutesLeft <= 0) {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
} else if (minutesLeft <= 30 && minutesLeft % 10 === 0) {
// Warn at 30, 20, 10 minutes
const toast = document.getElementById('toastContainer');
if (toast && !toast.querySelector('.session-warn')) {
const warn = document.createElement('div');
warn.className = 'toast info session-warn';
warn.innerHTML = `<span>Session expires in ${minutesLeft} minutes. <a href="#/login" style="color:var(--accent);text-decoration:underline" onclick="localStorage.removeItem('token');localStorage.removeItem('user')">Re-login</a></span>`;
toast.appendChild(warn);
setTimeout(() => warn.remove(), 10000);
}
}
} catch {}
}, 60000);
}
window.addEventListener('hashchange', route);
route();
// Close-modal buttons (replaces inline onclick handlers — required for CSP).
document.addEventListener('click', (e) => {
const closer = e.target.closest('[data-close-modal]');
if (!closer) return;
const id = closer.dataset.closeModal;
const modal = document.getElementById(id);
if (modal) modal.style.display = 'none';
});