// 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, '&').replace(//g, '>') .replace(/"/g, '"').replace(/'/g, '''); } 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 = '

Access denied

Platform-admin role required.

'; 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 = `

Summary

Loading...

Filters

Note: device_id 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.

Logs

Loading...

`; // ---- 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 = `
Total: ${data.total}
${families.map(([name, count, color]) => `
${name}: ${count}
`).join('')} `; } catch (err) { el.innerHTML = 'Failed to load summary: ' + esc(err.message || err) + ''; } } 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 = '

Loading...

'; 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 = '

No logs match the current filters.

'; } else { el.innerHTML = `
${data.rows.map(r => ` `).join('')}
ID Time device_id (self-reported) IP User agent Fingerprint
${r.id} ${esc(fmtTime(r.created_at))} ${esc(r.device_id || '(none)')} ${esc(r.ip || '')} ${esc(uaShort(r.user_agent))} ${esc(r.error_fingerprint || '')}
URL
${esc(r.url || '(none)')}
Full User Agent
${esc(r.user_agent || '(none)')}
error_data
${esc(prettyJson(r.error_data))}
context
${esc(prettyJson(r.context))}
`; 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 = '

Failed to load: ' + esc(err.message || err) + '

'; } }