screentinker/frontend/js/views/admin-player-debug.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

337 lines
15 KiB
JavaScript

// Admin view for the player_debug_logs telemetry sink. Platform-admin only.
// Mounted at #/admin/player-debug. Reads from /api/player-debug/list,
// /api/player-debug/summary, /api/player-debug/older-than (DELETE).
//
// Server-side pagination - we never render all 10k rows at once. Page param
// in the URL hash so refresh preserves position.
//
// IMPORTANT: device_id is whatever the player POSTed. The submitter is
// unauthenticated by design (so unpaired players can also send), which means
// device_id is self-reported, NOT server-verified. Surfaced via column label
// "device_id (self-reported)" and the help-text caption below the filters.
import { isPlatformAdmin } from '../utils.js';
import { showToast } from '../components/toast.js';
const headers = () => ({ Authorization: `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' });
const API = (url, opts = {}) => fetch('/api' + url, { headers: headers(), ...opts });
// Parse a query string from a hash like '#/admin/player-debug?page=2&ua=Tizen'.
// Returns a plain object - no URLSearchParams since the hash format isn't
// a standard URL.
function parseHashParams() {
const h = window.location.hash || '';
const qi = h.indexOf('?');
if (qi < 0) return {};
const out = {};
const qs = h.substring(qi + 1);
for (const part of qs.split('&')) {
if (!part) continue;
const eq = part.indexOf('=');
const k = eq >= 0 ? part.substring(0, eq) : part;
const v = eq >= 0 ? part.substring(eq + 1) : '';
try { out[decodeURIComponent(k)] = decodeURIComponent(v); } catch { out[k] = v; }
}
return out;
}
function setHashParams(updates) {
const base = '#/admin/player-debug';
const merged = { ...parseHashParams(), ...updates };
// Strip empty values so the URL stays tidy
const pairs = [];
for (const [k, v] of Object.entries(merged)) {
if (v == null || v === '') continue;
pairs.push(encodeURIComponent(k) + '=' + encodeURIComponent(v));
}
// Replace, don't push - we don't want every filter keystroke in browser history
history.replaceState(null, '', pairs.length ? base + '?' + pairs.join('&') : base);
}
// Pretty-print JSON for the expanded-row display. Returns the original string
// if parsing fails so we don't lose data when the field isn't JSON-shaped.
function prettyJson(s) {
if (s == null || s === '') return '(empty)';
try {
return JSON.stringify(JSON.parse(s), null, 2);
} catch {
return String(s);
}
}
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#39;');
}
function fmtTime(unixSec) {
if (!unixSec) return '';
try { return new Date(unixSec * 1000).toLocaleString(); } catch { return String(unixSec); }
}
function uaShort(ua) {
if (!ua) return '';
// Keep just the part most useful for at-a-glance scanning. Full UA in the
// expanded row.
return ua.length > 60 ? ua.substring(0, 60) + '...' : ua;
}
export async function render(container) {
const user = JSON.parse(localStorage.getItem('user') || '{}');
if (!isPlatformAdmin(user)) {
container.innerHTML = '<div class="empty-state"><h3>Access denied</h3><p>Platform-admin role required.</p></div>';
return;
}
const params = parseHashParams();
const currentPage = parseInt(params.page) || 1;
const currentUa = params.ua || '';
const currentSince = params.since || '';
const currentUntil = params.until || '';
const currentHasError = params.has_error === '1';
container.innerHTML = `
<div class="page-header">
<div>
<h1>Player Debug Logs</h1>
<div class="subtitle">Captured errors and state from player clients. Mostly smart TVs we can't reach with devtools.</div>
</div>
</div>
<div class="settings-section">
<h3>Summary</h3>
<div id="pdSummary" style="display:flex;gap:16px;flex-wrap:wrap;font-size:13px;color:var(--text-secondary)">Loading...</div>
</div>
<div class="settings-section">
<h3>Filters</h3>
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:flex-end">
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">User agent contains</label>
<input class="input" id="pdFilterUa" value="${esc(currentUa)}" placeholder="Tizen, WebOS, AFTS..." style="width:220px">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Since (YYYY-MM-DD)</label>
<input class="input" id="pdFilterSince" value="${esc(currentSince)}" placeholder="2026-05-01" style="width:140px">
</div>
<div>
<label style="display:block;font-size:12px;color:var(--text-muted);margin-bottom:4px">Until (YYYY-MM-DD)</label>
<input class="input" id="pdFilterUntil" value="${esc(currentUntil)}" placeholder="2026-05-31" style="width:140px">
</div>
<div>
<label style="display:flex;align-items:center;gap:6px;font-size:13px;cursor:pointer">
<input type="checkbox" id="pdFilterHasError" ${currentHasError ? 'checked' : ''}> Has error data
</label>
</div>
<button class="btn btn-primary btn-sm" id="pdApplyFilters">Apply</button>
<button class="btn btn-secondary btn-sm" id="pdClearFilters">Clear</button>
<div style="flex:1"></div>
<button class="btn btn-danger btn-sm" id="pdDeleteOld">Delete older than 30 days</button>
</div>
<div style="font-size:12px;color:var(--text-muted);margin-top:10px">
Note: <code>device_id</code> is self-reported by the player and is not server-verified. The submission endpoint is unauthenticated by design so unpaired players can also report errors.
</div>
</div>
<div class="settings-section">
<h3>Logs <span id="pdRowMeta" style="font-size:13px;color:var(--text-muted);font-weight:400"></span></h3>
<div id="pdList"><p style="color:var(--text-muted)">Loading...</p></div>
<div id="pdPagination" style="display:flex;gap:8px;align-items:center;justify-content:center;margin-top:14px"></div>
</div>
`;
// ---- handlers ----
document.getElementById('pdApplyFilters').onclick = () => {
const ua = document.getElementById('pdFilterUa').value.trim();
const since = document.getElementById('pdFilterSince').value.trim();
const until = document.getElementById('pdFilterUntil').value.trim();
const hasError = document.getElementById('pdFilterHasError').checked ? '1' : '';
setHashParams({ page: 1, ua, since, until, has_error: hasError });
loadList();
};
document.getElementById('pdClearFilters').onclick = () => {
document.getElementById('pdFilterUa').value = '';
document.getElementById('pdFilterSince').value = '';
document.getElementById('pdFilterUntil').value = '';
document.getElementById('pdFilterHasError').checked = false;
setHashParams({ page: 1, ua: '', since: '', until: '', has_error: '' });
loadList();
};
document.getElementById('pdDeleteOld').onclick = async () => {
if (!confirm('Delete all logs older than 30 days? This cannot be undone.')) return;
try {
const res = await API('/player-debug/older-than?days=30', { method: 'DELETE' });
const data = await res.json();
showToast(`Deleted ${data.deleted} log${data.deleted === 1 ? '' : 's'} older than 30 days`, 'success');
loadSummary();
loadList();
} catch (err) {
showToast('Delete failed: ' + (err.message || err), 'error');
}
};
loadSummary();
loadList();
}
async function loadSummary() {
const el = document.getElementById('pdSummary');
if (!el) return;
try {
const res = await API('/player-debug/summary');
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const families = [
['Tizen', data.byFamily.tizen, '#3b82f6'],
['WebOS', data.byFamily.webos, '#a3e635'],
['Fire TV', data.byFamily.fire_tv, '#f97316'],
['Bravia', data.byFamily.bravia, '#a855f7'],
['Edge', data.byFamily.edge, '#06b6d4'],
['Chrome', data.byFamily.chrome, '#fbbf24'],
['Firefox', data.byFamily.firefox, '#ef4444'],
['Safari', data.byFamily.safari, '#64748b'],
['Other', data.byFamily.other, '#94a3b8'],
];
el.innerHTML = `
<div style="font-weight:600;color:var(--text-primary)">Total: ${data.total}</div>
${families.map(([name, count, color]) => `
<div style="display:flex;align-items:center;gap:6px">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:${color}"></span>
<span>${name}: <strong style="color:var(--text-primary)">${count}</strong></span>
</div>
`).join('')}
`;
} catch (err) {
el.innerHTML = '<span style="color:var(--danger)">Failed to load summary: ' + esc(err.message || err) + '</span>';
}
}
function ymdToUnix(s, endOfDay) {
if (!s) return '';
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s);
if (!m) return '';
const [, y, mo, d] = m;
const dt = new Date(Date.UTC(+y, +mo - 1, +d, endOfDay ? 23 : 0, endOfDay ? 59 : 0, endOfDay ? 59 : 0));
return Math.floor(dt.getTime() / 1000);
}
async function loadList() {
const el = document.getElementById('pdList');
const meta = document.getElementById('pdRowMeta');
const pag = document.getElementById('pdPagination');
if (!el) return;
el.innerHTML = '<p style="color:var(--text-muted)">Loading...</p>';
const params = parseHashParams();
const page = Math.max(1, parseInt(params.page) || 1);
const limit = 50;
const qs = new URLSearchParams();
qs.set('page', page);
qs.set('limit', limit);
if (params.ua) qs.set('ua_contains', params.ua);
const since = ymdToUnix(params.since, false);
const until = ymdToUnix(params.until, true);
if (since) qs.set('since', since);
if (until) qs.set('until', until);
if (params.has_error === '1') qs.set('has_error', '1');
try {
const res = await API('/player-debug/list?' + qs.toString());
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
const totalPages = Math.max(1, Math.ceil(data.total / data.limit));
meta.textContent = `(${data.total} total, page ${data.page} of ${totalPages})`;
if (data.rows.length === 0) {
el.innerHTML = '<p style="color:var(--text-muted);padding:14px 0">No logs match the current filters.</p>';
} else {
el.innerHTML = `
<div class="table-wrap">
<table style="width:100%;border-collapse:collapse;font-size:13px;min-width:960px">
<thead><tr style="border-bottom:1px solid var(--border);text-align:left">
<th style="padding:8px;width:50px">ID</th>
<th style="padding:8px;width:140px">Time</th>
<th style="padding:8px;width:180px" title="Self-reported by the player; not server-verified.">device_id (self-reported)</th>
<th style="padding:8px;width:130px">IP</th>
<th style="padding:8px">User agent</th>
<th style="padding:8px;width:130px">Fingerprint</th>
<th style="padding:8px;width:80px"></th>
</tr></thead>
<tbody>
${data.rows.map(r => `
<tr style="border-bottom:1px solid var(--border-light)" data-row-id="${r.id}">
<td style="padding:8px;font-family:monospace;color:var(--text-muted)">${r.id}</td>
<td style="padding:8px;font-size:12px">${esc(fmtTime(r.created_at))}</td>
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-secondary)">${esc(r.device_id || '(none)')}</td>
<td style="padding:8px;font-family:monospace;font-size:12px;color:var(--text-secondary)">${esc(r.ip || '')}</td>
<td style="padding:8px;font-size:12px;color:var(--text-secondary)">${esc(uaShort(r.user_agent))}</td>
<td style="padding:8px;font-family:monospace;font-size:11px;color:var(--text-muted)">${esc(r.error_fingerprint || '')}</td>
<td style="padding:8px;text-align:right">
<button class="btn btn-secondary btn-sm" data-expand="${r.id}" style="font-size:11px;padding:2px 8px">Expand</button>
</td>
</tr>
<tr style="display:none" data-expanded-for="${r.id}">
<td colspan="7" style="padding:12px 16px;background:var(--bg-input)">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">URL</div>
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.url || '(none)')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">Full User Agent</div>
<div style="font-family:monospace;font-size:11px;color:var(--text-secondary);word-break:break-all;margin-bottom:10px">${esc(r.user_agent || '(none)')}</div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">error_data</div>
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:300px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.error_data))}</pre>
</div>
<div>
<div style="font-size:12px;color:var(--text-muted);margin-bottom:4px">context</div>
<pre style="margin:0;padding:8px;background:var(--bg-primary);border-radius:4px;font-family:monospace;font-size:11px;color:var(--text-secondary);overflow:auto;max-height:420px;white-space:pre-wrap;word-break:break-word">${esc(prettyJson(r.context))}</pre>
</div>
</div>
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
`;
el.querySelectorAll('button[data-expand]').forEach(btn => {
btn.onclick = () => {
const id = btn.getAttribute('data-expand');
const exp = el.querySelector(`tr[data-expanded-for="${id}"]`);
if (exp) {
const visible = exp.style.display !== 'none';
exp.style.display = visible ? 'none' : '';
btn.textContent = visible ? 'Expand' : 'Collapse';
}
};
});
}
// ---- pagination ----
pag.innerHTML = '';
if (totalPages > 1) {
const prev = document.createElement('button');
prev.className = 'btn btn-secondary btn-sm';
prev.textContent = '< Prev';
prev.disabled = page <= 1;
prev.onclick = () => { setHashParams({ page: page - 1 }); loadList(); };
pag.appendChild(prev);
const indicator = document.createElement('span');
indicator.style.cssText = 'padding:0 12px;font-size:13px;color:var(--text-muted)';
indicator.textContent = `Page ${page} of ${totalPages}`;
pag.appendChild(indicator);
const next = document.createElement('button');
next.className = 'btn btn-secondary btn-sm';
next.textContent = 'Next >';
next.disabled = page >= totalPages;
next.onclick = () => { setHashParams({ page: page + 1 }); loadList(); };
pag.appendChild(next);
}
} catch (err) {
el.innerHTML = '<p style="color:var(--danger)">Failed to load: ' + esc(err.message || err) + '</p>';
}
}