mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
The Packet Captures page (/inspector/packets, dev_message_inspector) arms captures with a search picker (part of an account or character name, or a pasted account or object ID; online ones marked; everything needs no pick), lists running and saved ones live (packet_capture topic), and plays one back on its timeline: play, pause, seek and speed, packets appearing in order with their decoded fields and bytes. World 3D takes ?capture=<id>&zone=<zone> and replays the captured position updates in its replay mode; the capture page's playhead drives it over a BroadcastChannel. Saved packet captures show in the Message Inspector's list and open there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
767 lines
34 KiB
JavaScript
767 lines
34 KiB
JavaScript
/**
|
||
* 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 '<span class="text-body-secondary">-</span>';
|
||
return capture.zones.map(function (z) {
|
||
return '<span class="text-nowrap">' + esc(z.zoneName || ('Zone ' + z.zone)) + ' <span class="small text-body-secondary">' + esc(z.zone) + ':' + esc(z.instance) + (z.clone ? ':' + esc(z.clone) : '') + '</span></span>';
|
||
}).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 '<option value="' + esc(m.name) + '">' + esc(m.id) + '</option>'; }).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 = '<div class="list-group-item text-body-secondary small">None in the last hour. Saved captures are in the list on the right.</div>';
|
||
return;
|
||
}
|
||
var open = state.capture && state.capture.id;
|
||
list.innerHTML = state.captures.map(function (c) {
|
||
return '<a class="list-group-item list-group-item-action' + (c.id === open ? ' active' : '') + '" data-capture="' + c.id + '">' +
|
||
'<div class="d-flex justify-content-between align-items-center"><span class="fw-semibold text-truncate">' + esc(c.characterName) + '</span>' + stateBadge(c) + '</div>' +
|
||
'<div class="small text-body-secondary">#' + c.id + ' by ' + esc(c.startedBy) + ', ' + nf.format(c.received) + ' messages' +
|
||
(c.state !== 'ended' ? ', ' + timeLeft(c) : '') + '</div></a>';
|
||
}).join('');
|
||
}
|
||
|
||
byId('captureList').addEventListener('click', function (e) {
|
||
var item = e.target.closest('[data-capture]');
|
||
if (item) open(parseInt(item.dataset.capture, 10));
|
||
});
|
||
|
||
function refreshList() {
|
||
return api.get('/api/inspector/captures').then(function (d) {
|
||
if (!d.success) return;
|
||
state.captures = d.captures;
|
||
renderList();
|
||
});
|
||
}
|
||
|
||
// ---- saved captures ----
|
||
|
||
var sessions = { offset: 0, total: 0, order: 'started', dir: 'desc', rows: [] };
|
||
|
||
function dayStart(input, add) {
|
||
if (!input.value) return '';
|
||
var parts = input.value.split('-');
|
||
return String(Math.floor(new Date(+parts[0], +parts[1] - 1, +parts[2] + (add || 0)).getTime() / 1000));
|
||
}
|
||
|
||
function sessionQuery() {
|
||
var params = new URLSearchParams();
|
||
[['character', 'filterCharacter'], ['account', 'filterAccount'], ['staff', 'filterStaff']].forEach(function (pair) {
|
||
var value = byId(pair[1]).value.trim();
|
||
if (value) params.set(pair[0], value);
|
||
});
|
||
var since = dayStart(byId('filterSince')), until = dayStart(byId('filterUntil'), 1);
|
||
if (since) params.set('since', since);
|
||
if (until) params.set('until', until);
|
||
params.set('order', sessions.order);
|
||
params.set('dir', sessions.dir);
|
||
params.set('offset', sessions.offset);
|
||
params.set('limit', byId('sessionPageSize').value);
|
||
return params.toString();
|
||
}
|
||
|
||
function filtered() {
|
||
return ['filterCharacter', 'filterAccount', 'filterStaff', 'filterSince', 'filterUntil'].some(function (id) { return byId(id).value.trim(); });
|
||
}
|
||
|
||
var sessionRequest = 0;
|
||
function loadSessions() {
|
||
var request = ++sessionRequest;
|
||
return api.get('/api/inspector/sessions?' + sessionQuery()).then(function (d) {
|
||
if (request !== sessionRequest) return;
|
||
if (!d.success) {
|
||
byId('sessionRows').innerHTML = '<tr><td colspan="9" class="text-danger small">' + esc(d.error || 'Could not load the captures') + '</td></tr>';
|
||
return;
|
||
}
|
||
sessions.total = d.total;
|
||
sessions.rows = d.sessions;
|
||
renderSessions();
|
||
});
|
||
}
|
||
|
||
function renderSessions() {
|
||
var rows = sessions.rows;
|
||
byId('sessionRows').innerHTML = rows.length ? rows.map(function (s) {
|
||
var end = s.state === 'ended' ? s.endedAt : Date.now() / 1000;
|
||
return '<tr data-session="' + s.id + '">' +
|
||
'<td class="small">' + esc(fmt.unix(s.startedAt)) + '</td>' +
|
||
'<td>' + (s.kind === 1 ? '<span class="badge text-bg-info me-1">Packets</span>' + esc(s.target === 'everything' ? 'everything' : s.target === 'account' ? s.accountName : s.characterName) :
|
||
fmt.character(s.characterId, s.characterName)) +
|
||
(s.accountId ? '<div class="small text-body-secondary">account ' + fmt.link('/accounts/' + s.accountId, s.accountName || String(s.accountId)) + '</div>' : '') + '</td>' +
|
||
'<td>' + esc(s.startedBy) + '</td>' +
|
||
'<td class="text-nowrap">' + esc(durationText(end - s.startedAt)) + '</td>' +
|
||
'<td class="small">' + worlds(s) + '</td>' +
|
||
'<td class="text-end">' + nf.format(s.received) + (s.dropped ? ' <span class="text-warning" title="Left out by the world (too many at once)">+' + nf.format(s.dropped) + '</span>' : '') + '</td>' +
|
||
'<td class="text-end text-nowrap">' + esc(bytesText(s.bytes)) + '</td>' +
|
||
'<td class="small">' + (s.state === 'ended' ? esc(s.endReason) : stateBadge(s)) + '</td>' +
|
||
'<td class="text-end text-nowrap small">' + (s.kind === 1 ? '<a class="btn btn-sm btn-link p-0 me-2" href="' + sessionUrl(s.id) + '/bundle" title="Portable bundle (audited)">Bundle</a>' :
|
||
'<a class="btn btn-sm btn-link p-0 me-2" href="' + sessionUrl(s.id) + '/download" title="Download JSON (audited)">JSON</a>') +
|
||
(s.state === 'ended' ? '<button type="button" class="btn btn-sm btn-link text-danger p-0" data-delete="' + s.id + '">Delete</button>' : '') + '</td></tr>';
|
||
}).join('') : '<tr><td colspan="9" class="text-body-secondary small">No saved captures' + (filtered() ? ' match these filters' : ' yet') + '.</td></tr>';
|
||
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) + ' <span class="text-body-secondary fs-6">capture ' + esc(capture.id) + '</span> ' + 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('<span class="text-warning">' + nf.format(capture.dropped) + ' left out (too many at once)</span>');
|
||
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 '<tr data-name="' + esc(c.name) + '"' + (exact === '=' + c.name ? ' class="active-filter"' : '') + '><td class="fw-semibold">' + esc(c.name) + '</td><td class="text-end text-body-secondary">' + c.id + '</td>' +
|
||
'<td class="text-end">' + nf.format(c.toServer) + '</td><td class="text-end">' + nf.format(c.toClient) + '</td><td class="text-end">' + nf.format(c.toServer + c.toClient) + '</td></tr>';
|
||
}).join('') : '<tr><td colspan="5" class="text-body-secondary small">No messages yet.</td></tr>';
|
||
}
|
||
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'
|
||
? '<span class="badge text-bg-info" title="Sent by the client">→ server</span>'
|
||
: '<span class="badge text-bg-primary" title="Sent to the client">← client</span>';
|
||
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 '<tr data-index="' + index + '"' + (classes.length ? ' class="' + classes.join(' ') + '"' : '') + (title.length ? ' title="' + esc(title.join('. ')) + '"' : '') + '>' +
|
||
'<td class="text-body-secondary">' + entry.seq + (entry.gap ? ' <span class="text-warning">+' + nf.format(entry.gap) + '</span>' : '') + '</td>' +
|
||
'<td>' + esc(timeText(entry)) + '</td>' +
|
||
'<td>' + arrow + '</td>' +
|
||
'<td><span class="fw-semibold">' + esc(entry.name) + '</span> <span class="text-body-secondary">' + entry.id + '</span></td>' +
|
||
'<td class="font-monospace"><a href="#" data-object="' + esc(entry.object) + '" title="Show only messages to this object">' + esc(entry.object) + '</a></td>' +
|
||
'<td class="text-end">' + nf.format(entry.bytes) + '</td>' +
|
||
'<td class="font-monospace">' + (entry.fields ? esc(fieldsSummary(entry.fields)) : '<span class="text-body-secondary">' + esc(entry.hex.slice(0, 48)) + (entry.hex.length > 48 ? '…' : '') + '</span>') + '</td></tr>';
|
||
}
|
||
|
||
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 ? '<tr class="spacer" style="height:' + (first * rowHeight) + 'px"><td colspan="7"></td></tr>' : '';
|
||
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 += '<tr class="spacer" style="height:' + ((total - last) * rowHeight) + 'px"><td colspan="7"></td></tr>';
|
||
if (!total) html = '<tr><td colspan="7" class="text-body-secondary small">' + (state.loading ? 'Loading…' : state.entries.length ? 'No messages match the filters.' : 'No messages yet.') + '</td></tr>';
|
||
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 += '<span class="b" data-i="' + i + '">' + pair + '</span>' + (i % 8 === 7 ? ' ' : ' ');
|
||
text += '<span class="a" data-i="' + i + '">' + (b >= 32 && b < 127 ? esc(String.fromCharCode(b)) : '.') + '</span>';
|
||
} else {
|
||
bytes += ' ' + (i % 8 === 7 ? ' ' : '');
|
||
}
|
||
}
|
||
lines.push('<span class="off">' + offset.toString(16).padStart(4, '0') + '</span> ' + 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) : '<pre class="mb-0 small">' + esc(JSON.stringify(v, null, 1)) + '</pre>';
|
||
}
|
||
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 <span class="font-monospace">' + esc(entry.object) + '</span>, ' +
|
||
nf.format(entry.bits) + ' bits' + (entry.truncated ? ' <span class="text-warning">(only the first ' + nf.format(entry.hex.length / 2) + ' bytes were kept)</span>' : '') +
|
||
(entry.zone ? ', in zone ' + esc(entry.zone) + ' instance ' + esc(entry.instance) + (entry.clone ? ' clone ' + esc(entry.clone) : '') : '') +
|
||
(entry.gap ? '. <span class="text-warning">' + nf.format(entry.gap) + ' message(s) were left out just before it.</span>' : '');
|
||
var fields = byId('detailFields');
|
||
if (entry.fields) {
|
||
fields.innerHTML = '<table class="table table-sm mb-0 detail-fields"><tbody>' + Object.keys(entry.fields).map(function (k) {
|
||
return '<tr><th class="fw-normal text-body-secondary">' + esc(k) + '</th><td class="font-monospace text-break">' + valueHtml(entry.fields[k]) + '</td></tr>';
|
||
}).join('') + '</tbody></table>';
|
||
} else {
|
||
fields.innerHTML = '<div class="small text-body-secondary">The server reads this message without a typed struct, so only its bytes are shown.</div>';
|
||
}
|
||
byId('detailHex').innerHTML = entry.hex ? hexDump(entry.hex) : '(no bytes)';
|
||
drawRows(true);
|
||
}
|
||
|
||
function timeTextRelative(entry) {
|
||
var ms = Math.max(0, entry.time - state.capture.startedAt * 1000);
|
||
return (ms / 1000).toFixed(3) + ' s';
|
||
}
|
||
|
||
function hideDetail() {
|
||
state.selected = -1;
|
||
byId('detailCard').classList.add('d-none');
|
||
byId('viewerGrid').classList.add('no-detail');
|
||
}
|
||
|
||
// Highlight a byte and its character together
|
||
byId('detailHex').addEventListener('mouseover', function (e) {
|
||
var el = e.target.closest('[data-i]');
|
||
byId('detailHex').querySelectorAll('.hl').forEach(function (h) { h.classList.remove('hl'); });
|
||
if (el) byId('detailHex').querySelectorAll('[data-i="' + el.dataset.i + '"]').forEach(function (h) { h.classList.add('hl'); });
|
||
});
|
||
byId('detailHex').addEventListener('mouseleave', function () {
|
||
byId('detailHex').querySelectorAll('.hl').forEach(function (h) { h.classList.remove('hl'); });
|
||
});
|
||
|
||
function copy(text, what) {
|
||
if (!navigator.clipboard) return toast('Copying needs a secure (https) page', 'warning');
|
||
navigator.clipboard.writeText(text).then(function () { toast(what + ' copied', 'success'); }, function () { toast('Could not copy', 'danger'); });
|
||
}
|
||
byId('copyHex').addEventListener('click', function () {
|
||
var entry = state.entries[state.selected];
|
||
if (entry) copy(entry.hex, 'Hex');
|
||
});
|
||
byId('copyJson').addEventListener('click', function () {
|
||
var entry = state.entries[state.selected];
|
||
if (!entry) return;
|
||
var plain = {};
|
||
Object.keys(entry).forEach(function (k) { if (k.charAt(0) !== '_') plain[k] = entry[k]; });
|
||
copy(JSON.stringify(plain, null, 1), 'Message');
|
||
});
|
||
|
||
byId('messageRows').addEventListener('click', function (e) {
|
||
var object = e.target.closest('[data-object]');
|
||
if (object) {
|
||
e.preventDefault();
|
||
byId('viewObject').value = byId('viewObject').value === object.dataset.object ? '' : object.dataset.object;
|
||
refilter();
|
||
return;
|
||
}
|
||
var row = e.target.closest('tr[data-index]');
|
||
if (!row) return;
|
||
showDetail(parseInt(row.dataset.index, 10));
|
||
byId('detailCard').scrollIntoView({ block: 'nearest' });
|
||
});
|
||
byId('detailClose').addEventListener('click', function () { hideDetail(); drawRows(true); });
|
||
|
||
// Up and down move through the shown messages
|
||
byId('tableWrap').addEventListener('keydown', function (e) {
|
||
if (e.key !== 'ArrowDown' && e.key !== 'ArrowUp' || !state.view.length) return;
|
||
e.preventDefault();
|
||
var at = state.view.indexOf(state.selected);
|
||
at = at === -1 ? (e.key === 'ArrowDown' ? 0 : state.view.length - 1) : Math.max(0, Math.min(state.view.length - 1, at + (e.key === 'ArrowDown' ? 1 : -1)));
|
||
byId('viewFollow').checked = false;
|
||
// Rows sit under the sticky header: keep the selected one between it and the bottom
|
||
var wrap = byId('tableWrap'), top = at * rowHeight, header = wrap.querySelector('thead').getBoundingClientRect().height;
|
||
if (top < wrap.scrollTop) wrap.scrollTop = top;
|
||
else if (header + top + rowHeight > wrap.scrollTop + wrap.clientHeight) wrap.scrollTop = header + top + rowHeight - wrap.clientHeight;
|
||
showDetail(state.view[at]);
|
||
});
|
||
|
||
// ---- live ----
|
||
|
||
Live.onTopic('message_capture', function (message) {
|
||
upsert(message.capture);
|
||
if (!state.capture || message.capture.id !== state.capture.id || !message.entries.length) return;
|
||
if (state.paused) {
|
||
Array.prototype.push.apply(state.held, message.entries);
|
||
updateInfo();
|
||
return;
|
||
}
|
||
addEntries(message.entries);
|
||
});
|
||
// Time left, once a second
|
||
setInterval(function () {
|
||
if (state.captures.some(function (c) { return c.state !== 'ended'; })) {
|
||
renderList();
|
||
if (state.capture && state.capture.state !== 'ended') renderHeader();
|
||
}
|
||
}, 1000);
|
||
|
||
refreshList().then(function () {
|
||
var match = /capture=(\d+)/.exec(window.location.hash);
|
||
if (match) open(parseInt(match[1], 10)); else loadSessions();
|
||
});
|
||
})();
|