/**
* The Game Message Inspector page: start and stop captures of a player's game messages, browse the saved ones, and open
* one (running or saved) in the viewer. Running captures are followed live (message_capture socket topic).
* The open capture is kept in the address (#capture=3).
*
* The viewer keeps every message of the open capture and draws only the rows in view (the table is virtualized), so it
* stays quick with tens of thousands of messages. Filters, the counts by message and the search run in the browser.
*/
(function () {
'use strict';
var rowHeight = 26; // px: the .msg-table row height in the template, measured once rows are drawn
var OVERSCAN = 20; // rows drawn above and below the visible ones
var PAGE = 5000; // messages per request when opening a capture
var STATES = { waiting: ['Waiting for the player', 'warning'], capturing: ['Capturing', 'success'], ended: ['Ended', 'secondary'] };
var nf = new Intl.NumberFormat();
var state = {
captures: [], // running and recent (the side list)
capture: null, // summary of the open capture
entries: [], // its messages, in order
bySeq: {}, // seq -> true, to drop repeats
view: [], // indexes into entries that pass the filters
counts: {}, // message id -> {id, name, toServer, toClient}
selected: -1, // index into entries
loading: false,
paused: false,
held: [], // messages that arrived while paused
filter: null // the compiled view filters
};
function byId(id) { return document.getElementById(id); }
function sessionUrl(id) { return '/api/inspector/sessions/' + id; }
function bytesText(bytes) {
if (bytes < 1024) return nf.format(bytes) + ' B';
if (bytes < 1024 * 1024) return nf.format(Math.round(bytes / 102.4) / 10) + ' KB';
return nf.format(Math.round(bytes / 104857.6) / 10) + ' MB';
}
function durationText(seconds) {
seconds = Math.max(0, Math.round(seconds));
return seconds >= 60 ? Math.floor(seconds / 60) + ' min ' + (seconds % 60) + ' s' : seconds + ' s';
}
function stateBadge(capture) {
var s = STATES[capture.state] || [capture.state, 'secondary'];
return fmt.badge(s[0], s[1]);
}
function worlds(capture) {
if (!capture.zones || !capture.zones.length) return '-';
return capture.zones.map(function (z) {
return '' + esc(z.zoneName || ('Zone ' + z.zone)) + ' ' + esc(z.zone) + ':' + esc(z.instance) + (z.clone ? ':' + esc(z.clone) : '') + '';
}).join(' → ');
}
// ---- message names for the filters ----
api.get('/api/inspector/messages').then(function (d) {
if (!d.success) return;
byId('messageNames').innerHTML = d.messages.map(function (m) { return ''; }).join('');
});
function messageList(text) {
return text.split(/[\s,]+/).filter(Boolean);
}
// ---- starting and stopping ----
byId('startForm').addEventListener('submit', function (e) {
e.preventDefault();
api.action('/api/inspector/captures', {
character: byId('captureCharacter').value,
seconds: parseInt(byId('captureSeconds').value, 10),
toServer: byId('captureToServer').checked,
toClient: byId('captureToClient').checked,
only: messageList(byId('captureOnly').value),
skip: messageList(byId('captureSkip').value)
}, 'Capture started').then(function (d) {
upsert(d.capture);
open(d.capture.id);
loadSessions();
}).catch(function () {});
});
byId('stopBtn').addEventListener('click', function () {
if (!state.capture) return;
api.action('/api/inspector/captures/' + state.capture.id + '/stop', {}, 'Capture stopped').then(function (d) { upsert(d.capture); }).catch(function () {});
});
byId('deleteBtn').addEventListener('click', function () {
var capture = state.capture;
if (!capture) return;
deleteSession(capture).then(function (deleted) {
if (!deleted) return;
closeViewer();
});
});
function deleteSession(capture) {
return Decide({
title: 'Delete capture ' + capture.id,
text: 'Deletes the capture of ' + capture.characterName + ' and its ' + nf.format(capture.received) + ' messages for good.',
reasonLabel: 'Why (for the audit log)',
action: 'Delete'
}).then(function (choice) {
if (!choice) return false;
return api.action(sessionUrl(capture.id) + '/delete', { reason: choice.reason }, 'Capture deleted').then(function () {
state.captures = state.captures.filter(function (c) { return c.id !== capture.id; });
renderList();
loadSessions();
return true;
}).catch(function () { return false; });
});
}
// ---- running and recent captures (side list) ----
function upsert(capture) {
var index = state.captures.findIndex(function (c) { return c.id === capture.id; });
if (index === -1) state.captures.unshift(capture); else state.captures[index] = capture;
renderList();
if (state.capture && capture.id === state.capture.id) {
state.capture = capture;
renderHeader();
}
}
function timeLeft(capture) {
return durationText(capture.endsAt - Date.now() / 1000) + ' left';
}
function renderList() {
var list = byId('captureList');
if (!state.captures.length) {
list.innerHTML = '
None in the last hour. Saved captures are in the list on the right.
';
return;
}
var open = state.capture && state.capture.id;
list.innerHTML = state.captures.map(function (c) {
return '' +
'
No saved captures' + (filtered() ? ' match these filters' : ' yet') + '.
';
var limit = parseInt(byId('sessionPageSize').value, 10);
byId('sessionInfo').textContent = sessions.total ? (nf.format(sessions.offset + 1) + '–' + nf.format(sessions.offset + rows.length) + ' of ' + nf.format(sessions.total)) : '';
byId('sessionPrev').disabled = sessions.offset === 0;
byId('sessionNext').disabled = sessions.offset + limit >= sessions.total;
document.querySelectorAll('.sessions-table th[data-sort]').forEach(function (th) {
th.classList.toggle('sorted-asc', th.dataset.sort === sessions.order && sessions.dir === 'asc');
th.classList.toggle('sorted-desc', th.dataset.sort === sessions.order && sessions.dir === 'desc');
});
}
byId('sessionRows').addEventListener('click', function (e) {
if (e.target.closest('a')) return;
var del = e.target.closest('[data-delete]');
if (del) {
var s = sessions.rows.filter(function (r) { return r.id === parseInt(del.dataset.delete, 10); })[0];
if (s) deleteSession(s);
return;
}
var row = e.target.closest('[data-session]');
if (!row) return;
var id = parseInt(row.dataset.session, 10);
var picked = sessions.rows.filter(function (r) { return r.id === id; })[0];
// Packet captures have their own viewer
if (picked && picked.kind === 1) { window.location.href = '/inspector/packets#capture=' + id; return; }
open(id);
});
document.querySelectorAll('.sessions-table th[data-sort]').forEach(function (th) {
th.addEventListener('click', function () {
if (sessions.order === th.dataset.sort) sessions.dir = sessions.dir === 'asc' ? 'desc' : 'asc';
else { sessions.order = th.dataset.sort; sessions.dir = th.dataset.sort === 'character' || th.dataset.sort === 'started_by' ? 'asc' : 'desc'; }
sessions.offset = 0;
loadSessions();
});
});
var reloadSessions = Live.throttle(function () { sessions.offset = 0; loadSessions(); }, 300);
['filterCharacter', 'filterAccount', 'filterStaff'].forEach(function (id) { byId(id).addEventListener('input', reloadSessions); });
['filterSince', 'filterUntil', 'sessionPageSize'].forEach(function (id) { byId(id).addEventListener('change', reloadSessions); });
byId('sessionFilters').addEventListener('submit', function (e) { e.preventDefault(); reloadSessions(); });
byId('filterClear').addEventListener('click', function () {
['filterCharacter', 'filterAccount', 'filterStaff', 'filterSince', 'filterUntil'].forEach(function (id) { byId(id).value = ''; });
reloadSessions();
});
byId('sessionPrev').addEventListener('click', function () {
sessions.offset = Math.max(0, sessions.offset - parseInt(byId('sessionPageSize').value, 10));
loadSessions();
});
byId('sessionNext').addEventListener('click', function () {
sessions.offset += parseInt(byId('sessionPageSize').value, 10);
loadSessions();
});
// ---- tabs ----
function showTab(viewer) {
byId('inspectorLayout').classList.toggle('viewing', viewer);
byId('sessionsPane').classList.toggle('d-none', viewer);
byId('viewerPane').classList.toggle('d-none', !viewer);
byId('sessionsTab').classList.toggle('active', !viewer);
byId('viewerTab').classList.toggle('active', viewer);
byId('sessionsTab').setAttribute('aria-selected', String(!viewer));
byId('viewerTab').setAttribute('aria-selected', String(viewer));
if (viewer) drawRows(); else loadSessions();
}
byId('sessionsTab').addEventListener('click', function () { showTab(false); history.replaceState(null, '', '#'); });
byId('viewerTab').addEventListener('click', function () {
if (!state.capture) return;
showTab(true);
history.replaceState(null, '', '#capture=' + state.capture.id);
});
function closeViewer() {
state.capture = null;
state.entries = [];
state.view = [];
byId('viewerTab').disabled = true;
byId('viewerTab').textContent = 'Viewer';
renderList();
showTab(false);
history.replaceState(null, '', '#');
}
// ---- the open capture ----
var openRequest = 0;
function open(id) {
var request = ++openRequest;
state.capture = null;
state.entries = [];
state.bySeq = {};
state.view = [];
state.counts = {};
state.selected = -1;
state.held = [];
state.paused = false;
state.loading = true;
history.replaceState(null, '', '#capture=' + id);
hideDetail();
byId('viewerTab').disabled = false;
byId('viewerTab').textContent = 'Capture ' + id;
byId('captureTitle').textContent = 'Loading capture ' + id + '…';
byId('captureSubtitle').textContent = '';
showTab(true);
renderList();
loadPage(id, 0, request);
}
// Pages of messages until all are here
function loadPage(id, after, request) {
api.get(sessionUrl(id) + '/entries?after=' + after + '&limit=' + PAGE).then(function (d) {
if (request !== openRequest) return;
if (!d.success) {
state.loading = false;
byId('captureTitle').textContent = d.error || 'Could not open the capture';
return;
}
state.capture = d.capture;
renderHeader();
renderList();
addEntries(d.entries);
if (d.more && d.entries.length) {
byId('rowInfo').textContent = 'Loading… ' + nf.format(state.entries.length) + ' of ' + nf.format(d.capture.received);
loadPage(id, d.entries[d.entries.length - 1].seq, request);
} else {
state.loading = false;
updateInfo();
}
}).catch(function () { state.loading = false; });
}
function renderHeader() {
var capture = state.capture;
if (!capture) return;
byId('captureTitle').innerHTML = fmt.character(capture.characterId, capture.characterName) + ' capture ' + esc(capture.id) + ' ' + stateBadge(capture);
var parts = [];
if (capture.state === 'waiting') parts.push('Not in a world right now (logged out or changing zones); it carries on when they are');
if (capture.zones && capture.zones.length) parts.push(worlds(capture));
if (capture.state !== 'ended') parts.push(esc(timeLeft(capture)));
else parts.push(esc(capture.endReason) + ' after ' + esc(durationText(capture.endedAt - capture.startedAt)));
parts.push(nf.format(capture.received) + ' messages, ' + esc(bytesText(capture.bytes)));
if (capture.dropped) parts.push('' + nf.format(capture.dropped) + ' left out (too many at once)');
var filters = [];
if (!capture.toServer) filters.push('only sent to the client');
if (!capture.toClient) filters.push('only sent by the client');
if (capture.only.length) filters.push('only ' + capture.only.map(esc).join(', '));
if (capture.skip.length) filters.push('not ' + capture.skip.map(esc).join(', '));
if (filters.length) parts.push(filters.join('; '));
parts.push('started by ' + esc(capture.startedBy) + ' ' + esc(fmt.unix(capture.startedAt)));
if (capture.accountId) parts.push('account ' + fmt.link('/accounts/' + capture.accountId, capture.accountName || String(capture.accountId)));
byId('captureSubtitle').innerHTML = parts.join(' · ');
var live = capture.state !== 'ended';
byId('stopBtn').classList.toggle('d-none', !live);
byId('pauseBtn').classList.toggle('d-none', !live);
byId('deleteBtn').classList.toggle('d-none', live);
byId('downloadBtn').href = sessionUrl(capture.id) + '/download';
}
// ---- filters ----
function compileFilter() {
var include = [], exclude = [];
byId('viewFilter').value.split(',').forEach(function (term) {
term = term.trim().toUpperCase();
if (!term) return;
var not = term.charAt(0) === '!';
if (not) term = term.slice(1).trim();
if (!term) return;
var exact = term.charAt(0) === '=';
if (exact) term = term.slice(1);
var test = /^\d+$/.test(term) ? (function (id) { return function (e) { return e.id === id; }; })(parseInt(term, 10))
: exact ? function (e) { return e.name === term; } : function (e) { return e.name.indexOf(term) !== -1; };
(not ? exclude : include).push(test);
});
var search = byId('viewSearch').value.trim().toLowerCase();
return {
dir: byId('viewDirection').value,
object: byId('viewObject').value.trim(),
decoded: byId('viewDecoded').checked,
search: search,
hexSearch: search.replace(/\s+/g, ''),
include: include,
exclude: exclude,
active: !!(byId('viewDirection').value || byId('viewObject').value.trim() || byId('viewDecoded').checked || search || include.length || exclude.length)
};
}
function fieldsText(entry) {
if (entry._text === undefined) entry._text = entry.fields ? JSON.stringify(entry.fields).toLowerCase() : '';
return entry._text;
}
function passes(entry, f) {
if (f.dir && entry.dir !== f.dir) return false;
if (f.object && entry.object !== f.object) return false;
if (f.decoded && !entry.fields) return false;
if (f.include.length && !f.include.some(function (t) { return t(entry); })) return false;
if (f.exclude.some(function (t) { return t(entry); })) return false;
if (f.search && fieldsText(entry).indexOf(f.search) === -1 && (!/^[0-9a-f]+$/.test(f.hexSearch) || entry.hex.indexOf(f.hexSearch) === -1)) return false;
return true;
}
function refilter() {
state.filter = compileFilter();
var view = [];
for (var i = 0; i < state.entries.length; i++) if (passes(state.entries[i], state.filter)) view.push(i);
state.view = view;
byId('clearView').classList.toggle('d-none', !state.filter.active);
renderTypes();
drawRows(true);
updateInfo();
}
['viewFilter', 'viewObject', 'viewSearch'].forEach(function (id) { byId(id).addEventListener('input', Live.throttle(refilter, 200)); });
['viewDirection', 'viewDecoded'].forEach(function (id) { byId(id).addEventListener('change', refilter); });
byId('viewRelative').addEventListener('change', function () { drawRows(true); if (state.selected !== -1) showDetail(state.selected); });
byId('viewFollow').addEventListener('change', function () { if (byId('viewFollow').checked) scrollToEnd(); });
byId('clearView').addEventListener('click', function (e) {
e.preventDefault();
['viewFilter', 'viewObject', 'viewSearch', 'viewDirection'].forEach(function (id) { byId(id).value = ''; });
byId('viewDecoded').checked = false;
refilter();
});
// ---- counts by message ----
function count(entry) {
var c = state.counts[entry.id] || (state.counts[entry.id] = { id: entry.id, name: entry.name, toServer: 0, toClient: 0 });
if (entry.dir === 'to_server') c.toServer++; else c.toClient++;
}
function renderTypes() {
var on = byId('viewTypes').checked;
byId('typesCard').classList.toggle('d-none', !on);
if (!on) return;
var exact = byId('viewFilter').value.trim().toUpperCase();
var rows = Object.keys(state.counts).map(function (k) { return state.counts[k]; })
.sort(function (a, b) { return (b.toServer + b.toClient) - (a.toServer + a.toClient) || a.id - b.id; });
byId('typeRows').innerHTML = rows.length ? rows.map(function (c) {
return '
' + esc(c.name) + '
' + c.id + '
' +
'
' + nf.format(c.toServer) + '
' + nf.format(c.toClient) + '
' + nf.format(c.toServer + c.toClient) + '
';
}).join('') : '
No messages yet.
';
}
var renderTypesSoon = Live.throttle(renderTypes, 500);
byId('viewTypes').addEventListener('change', renderTypes);
byId('typeRows').addEventListener('click', function (e) {
var row = e.target.closest('[data-name]');
if (!row) return;
var value = '=' + row.dataset.name;
byId('viewFilter').value = byId('viewFilter').value.trim().toUpperCase() === value ? '' : value;
refilter();
});
// ---- the message table (virtualized) ----
function timeText(entry) {
if (byId('viewRelative').checked && state.capture) {
var ms = Math.max(0, entry.time - state.capture.startedAt * 1000);
var m = Math.floor(ms / 60000), s = Math.floor(ms / 1000) % 60;
return '+' + m + ':' + String(s).padStart(2, '0') + '.' + String(ms % 1000).padStart(3, '0');
}
var t = new Date(entry.time);
return t.toLocaleTimeString() + '.' + String(t.getMilliseconds()).padStart(3, '0');
}
function fieldsSummary(fields) {
if (!fields) return '';
return Object.keys(fields).map(function (k) {
var v = fields[k];
return k + '=' + (typeof v === 'object' ? JSON.stringify(v) : v);
}).join(' ');
}
function rowHtml(index, previous) {
var entry = state.entries[index];
var classes = [];
if (index === state.selected) classes.push('selected');
if (entry.gap) classes.push('has-gap');
var moved = previous && (previous.zone !== entry.zone || previous.instance !== entry.instance || previous.clone !== entry.clone);
if (moved) classes.push('zone-change');
var arrow = entry.dir === 'to_server'
? '→ server'
: '← client';
var title = [];
if (entry.gap) title.push(nf.format(entry.gap) + ' message(s) left out just before this one: too many at once');
if (moved) title.push('Now in zone ' + entry.zone + ', instance ' + entry.instance + (entry.clone ? ', clone ' + entry.clone : ''));
return '
';
}
var drawn = { first: -1, last: -1, length: -1 };
function drawRows(force) {
if (byId('viewerPane').classList.contains('d-none')) return;
var wrap = byId('tableWrap');
var total = state.view.length;
var height = wrap.clientHeight || 400;
var first = Math.max(0, Math.floor(wrap.scrollTop / rowHeight) - OVERSCAN);
var last = Math.min(total, Math.ceil((wrap.scrollTop + height) / rowHeight) + OVERSCAN);
if (!force && first === drawn.first && last === drawn.last && total === drawn.length) return;
drawn = { first: first, last: last, length: total };
var html = first > 0 ? '
' : '';
for (var i = first; i < last; i++) html += rowHtml(state.view[i], i > 0 ? state.entries[state.view[i - 1]] : null);
if (last < total) html += '
';
if (!total) html = '
' + (state.loading ? 'Loading…' : state.entries.length ? 'No messages match the filters.' : 'No messages yet.') + '
';
byId('messageRows').innerHTML = html;
// The spacers must match the real row height (fonts and zoom can change it)
var row = byId('messageRows').querySelector('tr[data-index]');
var measured = row ? row.getBoundingClientRect().height : 0;
if (measured && Math.abs(measured - rowHeight) > 0.5) {
rowHeight = measured;
drawRows(true);
}
}
byId('tableWrap').addEventListener('scroll', function () {
drawRows(false);
// Scrolling up stops following; back at the bottom follows again
var wrap = byId('tableWrap');
var atEnd = wrap.scrollTop + wrap.clientHeight >= wrap.scrollHeight - rowHeight;
if (state.capture && state.capture.state !== 'ended' && byId('viewFollow').checked !== atEnd && !programmaticScroll) {
byId('viewFollow').checked = atEnd;
}
programmaticScroll = false;
});
window.addEventListener('resize', Live.throttle(function () { drawRows(true); }, 200));
var programmaticScroll = false;
function scrollToEnd() {
var wrap = byId('tableWrap');
programmaticScroll = true;
wrap.scrollTop = wrap.scrollHeight;
drawRows(false);
}
function updateInfo() {
var text = nf.format(state.view.length) + ' of ' + nf.format(state.entries.length) + ' shown';
if (state.loading && state.capture) text = 'Loading… ' + nf.format(state.entries.length) + ' of ' + nf.format(state.capture.received);
byId('rowInfo').textContent = text;
var resume = byId('resumeLink');
resume.classList.toggle('d-none', !state.paused);
resume.textContent = state.paused ? 'Paused: ' + nf.format(state.held.length) + ' new. Resume' : '';
}
function addEntries(entries) {
if (!entries.length) return;
var f = state.filter || (state.filter = compileFilter());
var outOfOrder = false;
var last = state.entries.length ? state.entries[state.entries.length - 1].seq : 0;
entries.forEach(function (entry) {
if (state.bySeq[entry.seq]) return;
state.bySeq[entry.seq] = true;
if (entry.seq < last) outOfOrder = true;
last = Math.max(last, entry.seq);
state.entries.push(entry);
count(entry);
if (!outOfOrder && passes(entry, f)) state.view.push(state.entries.length - 1);
});
if (outOfOrder) {
// Pushed messages can arrive before the ones fetched when the capture was opened: put them in order
var selected = state.selected !== -1 ? state.entries[state.selected] : null;
state.entries.sort(function (a, b) { return a.seq - b.seq; });
state.selected = selected ? state.entries.indexOf(selected) : -1;
refilter();
} else {
drawRows(true);
renderTypesSoon();
}
updateInfo();
if (byId('viewFollow').checked && state.capture && state.capture.state !== 'ended') scrollToEnd();
}
// ---- pause ----
function setPaused(paused) {
state.paused = paused;
byId('pauseBtn').textContent = paused ? 'Resume' : 'Pause';
byId('pauseBtn').classList.toggle('active', paused);
if (!paused && state.held.length) {
var held = state.held;
state.held = [];
addEntries(held);
}
updateInfo();
}
byId('pauseBtn').addEventListener('click', function () { setPaused(!state.paused); });
byId('resumeLink').addEventListener('click', function (e) { e.preventDefault(); setPaused(false); });
// ---- one message ----
function hexDump(hex) {
var lines = [], count = hex.length / 2;
for (var offset = 0; offset < count; offset += 16) {
var bytes = '', text = '';
for (var i = offset; i < offset + 16; i++) {
if (i < count) {
var pair = hex.substr(i * 2, 2), b = parseInt(pair, 16);
bytes += '' + pair + '' + (i % 8 === 7 ? ' ' : ' ');
text += '' + (b >= 32 && b < 127 ? esc(String.fromCharCode(b)) : '.') + '';
} else {
bytes += ' ' + (i % 8 === 7 ? ' ' : '');
}
}
lines.push('' + offset.toString(16).padStart(4, '0') + ' ' + bytes + ' ' + text);
}
return lines.join('\n');
}
function valueHtml(v) {
if (v !== null && typeof v === 'object') {
// Short values (positions, rotations) on one line, longer ones laid out
var line = JSON.stringify(v);
return line.length <= 80 ? esc(line) : '
' + esc(JSON.stringify(v, null, 1)) + '
';
}
return esc(v);
}
function showDetail(index) {
var entry = state.entries[index];
if (!entry) return;
state.selected = index;
byId('viewerGrid').classList.remove('no-detail');
byId('detailCard').classList.remove('d-none');
byId('detailTitle').textContent = '#' + entry.seq + ' ' + entry.name + ' (' + entry.id + ')';
var at = new Date(entry.time);
byId('detailMeta').innerHTML = esc(entry.dir === 'to_server' ? 'Sent by the client' : 'Sent to the client') + ' at ' + esc(at.toLocaleString() + '.' + String(at.getMilliseconds()).padStart(3, '0')) +
(state.capture ? ' (' + esc(timeTextRelative(entry)) + ' into the capture)' : '') + ', to object ' + esc(entry.object) + ', ' +
nf.format(entry.bits) + ' bits' + (entry.truncated ? ' (only the first ' + nf.format(entry.hex.length / 2) + ' bytes were kept)' : '') +
(entry.zone ? ', in zone ' + esc(entry.zone) + ' instance ' + esc(entry.instance) + (entry.clone ? ' clone ' + esc(entry.clone) : '') : '') +
(entry.gap ? '. ' + nf.format(entry.gap) + ' message(s) were left out just before it.' : '');
var fields = byId('detailFields');
if (entry.fields) {
fields.innerHTML = '