// 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 = '
Platform-admin role required.
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.
Loading...
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 = `| 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 || '')} |
Failed to load: ' + esc(err.message || err) + '
'; } }