mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
feat(dashboard): play packet captures back and replay their movement in World 3D
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>
This commit is contained in:
@@ -426,6 +426,11 @@ namespace CaptureReplay {
|
||||
void RegisterRoutes() {
|
||||
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }));
|
||||
|
||||
Route(eHTTPMethod::GET, "/inspector/packets", Perm(PERMISSION), "Packet captures: arm, play back, export",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
RenderPage(reply, context, "packet-captures.jinja2", "inspector");
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/inspector/targets", Perm(PERMISSION),
|
||||
"Accounts and characters to capture, by part of an account or character name, or an account or character ID. Query: ?q=",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
|
||||
@@ -210,7 +210,8 @@
|
||||
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>' + fmt.character(s.characterId, s.characterName) +
|
||||
'<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>' +
|
||||
@@ -218,7 +219,8 @@
|
||||
'<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"><a class="btn btn-sm btn-link p-0 me-2" href="' + sessionUrl(s.id) + '/download" title="Download JSON (audited)">JSON</a>' +
|
||||
'<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);
|
||||
@@ -240,7 +242,12 @@
|
||||
return;
|
||||
}
|
||||
var row = e.target.closest('[data-session]');
|
||||
if (row) open(parseInt(row.dataset.session, 10));
|
||||
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) {
|
||||
|
||||
333
dDashboardServer/static/js/packet-captures.js
Normal file
333
dDashboardServer/static/js/packet-captures.js
Normal file
@@ -0,0 +1,333 @@
|
||||
/**
|
||||
* Packet Captures (docs/CaptureReplay.md): arm a capture for an account, a character or everything; the running and
|
||||
* saved ones; and a viewer that plays a capture back on one timeline (play, pause, seek, speed), with each packet's
|
||||
* decoded fields and bytes. While it plays, the time is sent to World 3D pages showing the same capture.
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
var byId = function (id) { return document.getElementById(id); };
|
||||
var nf = new Intl.NumberFormat();
|
||||
var MAX_LOADED = 50000; // packets loaded into the viewer
|
||||
var MAX_ROWS = 400; // table rows drawn at once (the ones up to the playhead)
|
||||
var channel = 'BroadcastChannel' in window ? new BroadcastChannel('dlu-capture-replay') : null;
|
||||
|
||||
var state = {
|
||||
live: [], // running and recently finished, from the server
|
||||
saved: [], // saved packet captures
|
||||
capture: null, // the one in the viewer
|
||||
records: [], // its packets, in time order (without fields)
|
||||
duration: 0, // ms
|
||||
playhead: 0, // ms from its start
|
||||
playing: false,
|
||||
selected: -1,
|
||||
loading: false
|
||||
};
|
||||
|
||||
function bytesText(n) {
|
||||
if (n < 1024) return n + ' B';
|
||||
if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB';
|
||||
return (n / 1024 / 1024).toFixed(1) + ' MB';
|
||||
}
|
||||
function timeText(ms) {
|
||||
var s = Math.max(0, ms) / 1000, m = Math.floor(s / 60);
|
||||
return m + ':' + (s - m * 60).toFixed(3).padStart(6, '0');
|
||||
}
|
||||
function url(id) { return '/api/inspector/sessions/' + id; }
|
||||
|
||||
// ---- target picker ----
|
||||
|
||||
function target() { return document.querySelector('input[name="target"]:checked').value; }
|
||||
|
||||
var picker = SearchSelect(byId('targetPicker'), {
|
||||
search: function (q) {
|
||||
return api.get('/api/inspector/targets?q=' + encodeURIComponent(q)).then(function (d) {
|
||||
var items = [];
|
||||
(d.accounts || []).forEach(function (a) {
|
||||
if (target() === 'account') {
|
||||
items.push({
|
||||
value: String(a.id), label: a.name + ' (account ' + a.id + ')' + (a.online ? ' · online' : ''),
|
||||
detail: a.characters.map(function (c) { return c.name + (c.online ? ' (online)' : ''); }).join(', ') || 'No characters'
|
||||
});
|
||||
} else {
|
||||
a.characters.forEach(function (c) {
|
||||
items.push({ value: c.id, label: c.name + ' (' + c.id + ')' + (c.online ? ' · online' : ''), detail: 'Account ' + a.name + ' (' + a.id + ')' });
|
||||
});
|
||||
}
|
||||
});
|
||||
return items;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
function onTarget() {
|
||||
var t = target();
|
||||
byId('pickerWrap').classList.toggle('d-none', t === 'everything');
|
||||
byId('everythingHint').classList.toggle('d-none', t !== 'everything');
|
||||
byId('pickerLabel').textContent = t === 'account' ? 'Account' : 'Character';
|
||||
picker.set('', '');
|
||||
}
|
||||
document.querySelectorAll('input[name="target"]').forEach(function (r) { r.addEventListener('change', onTarget); });
|
||||
|
||||
byId('armForm').addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
var t = target(), body = { target: t, seconds: parseInt(byId('armSeconds').value, 10) };
|
||||
if (t !== 'everything') {
|
||||
var value = byId('targetPicker').dataset.value;
|
||||
if (!value) { toast('Pick ' + (t === 'account' ? 'an account' : 'a character') + ' from the list', 'warning'); return; }
|
||||
body[t] = value;
|
||||
}
|
||||
api.action('/api/inspector/packet-captures', body, 'Capture armed on every server').then(function (d) {
|
||||
upsert(d.capture);
|
||||
open(d.capture.id);
|
||||
}).catch(function () {});
|
||||
});
|
||||
|
||||
// ---- list ----
|
||||
|
||||
function upsert(capture) {
|
||||
var i = state.live.findIndex(function (c) { return c.id === capture.id; });
|
||||
if (i >= 0) state.live[i] = capture; else state.live.unshift(capture);
|
||||
if (state.capture && state.capture.id === capture.id) { state.capture = capture; renderHeader(); }
|
||||
renderList();
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
var seen = {}, all = [];
|
||||
state.live.concat(state.saved).forEach(function (c) { if (!seen[c.id]) { seen[c.id] = true; all.push(c); } });
|
||||
byId('captureList').innerHTML = all.length ? all.map(function (c) {
|
||||
var active = state.capture && state.capture.id === c.id;
|
||||
var badge = c.state === 'ended' ? '' : ' <span class="badge text-bg-success">capturing</span>';
|
||||
return '<div class="list-group-item list-group-item-action' + (active ? ' active' : '') + '" data-id="' + c.id + '">' +
|
||||
'<div class="d-flex justify-content-between gap-2"><span class="text-truncate">' + esc(c.describe || c.target) + '</span>' + badge + '</div>' +
|
||||
'<div class="small text-body-secondary">' + esc(fmt.unix(c.startedAt)) + ' · ' + nf.format(c.received) + ' packets · ' + esc(bytesText(c.bytes)) +
|
||||
(c.dropped ? ' · <span class="text-warning">' + nf.format(c.dropped) + ' lost</span>' : '') + '</div></div>';
|
||||
}).join('') : '<div class="list-group-item text-body-secondary small">No packet captures yet.</div>';
|
||||
}
|
||||
|
||||
byId('captureList').addEventListener('click', function (e) {
|
||||
var item = e.target.closest('[data-id]');
|
||||
if (item) open(parseInt(item.dataset.id, 10));
|
||||
});
|
||||
|
||||
function refreshList() {
|
||||
return Promise.all([
|
||||
api.get('/api/inspector/packet-captures').then(function (d) { state.live = d.captures || []; }),
|
||||
api.get('/api/inspector/sessions?limit=100').then(function (d) {
|
||||
state.saved = (d.sessions || []).filter(function (s) { return s.kind === 1; }).map(function (s) {
|
||||
if (!s.describe) s.describe = s.target === 'everything' ? 'everything' : s.target === 'character' ? s.characterName : s.accountName;
|
||||
return s;
|
||||
});
|
||||
})
|
||||
]).then(renderList).catch(function () {});
|
||||
}
|
||||
|
||||
// ---- viewer ----
|
||||
|
||||
function renderHeader() {
|
||||
var c = state.capture;
|
||||
if (!c) return;
|
||||
byId('viewTitle').textContent = 'Capture ' + c.id + ': ' + (c.describe || c.target);
|
||||
byId('viewSubtitle').textContent = 'Armed by ' + c.startedBy + ' ' + fmt.unix(c.startedAt) + ' · ' + nf.format(c.received) + ' packets, ' + bytesText(c.bytes) +
|
||||
(c.dropped ? ' · ' + nf.format(c.dropped) + ' lost on the servers' : '') + ' · ' + (c.state === 'ended' ? (c.endReason || 'ended') : 'capturing until ' + fmt.unix(c.endsAt));
|
||||
byId('stopBtn').classList.toggle('d-none', c.state === 'ended');
|
||||
byId('deleteBtn').classList.toggle('d-none', c.state !== 'ended');
|
||||
byId('bundleBtn').href = url(c.id) + '/bundle';
|
||||
byId('fixtureBtn').href = url(c.id) + '/bundle?anonymise=1';
|
||||
}
|
||||
|
||||
function open(id) {
|
||||
stop();
|
||||
state.capture = state.live.concat(state.saved).filter(function (c) { return c.id === id; })[0] || { id: id, received: 0, bytes: 0 };
|
||||
state.records = [];
|
||||
state.selected = -1;
|
||||
history.replaceState(null, '', '#capture=' + id);
|
||||
byId('viewer').classList.remove('d-none');
|
||||
byId('emptyViewer').classList.add('d-none');
|
||||
byId('detail').classList.add('d-none');
|
||||
byId('grid').classList.add('no-detail');
|
||||
renderHeader();
|
||||
renderList();
|
||||
load(true);
|
||||
}
|
||||
|
||||
// Loads the capture's packets (the new ones, when `fresh` is false and it is still running)
|
||||
function load(fresh) {
|
||||
var c = state.capture;
|
||||
if (!c || state.loading) return Promise.resolve();
|
||||
state.loading = true;
|
||||
var offset = fresh ? 0 : state.records.length;
|
||||
var step = function () {
|
||||
return api.get(url(c.id) + '/packets?fields=0&limit=2000&offset=' + offset).then(function (d) {
|
||||
if (!state.capture || state.capture.id !== c.id) return;
|
||||
if (d.success === false) { toast(d.error || 'Could not load the capture', 'danger'); return; }
|
||||
if (offset === 0) state.records = [];
|
||||
Array.prototype.push.apply(state.records, d.records);
|
||||
state.duration = d.duration || 0;
|
||||
state.capture = Object.assign(state.capture, d.capture);
|
||||
offset += d.records.length;
|
||||
if (d.records.length === 2000 && state.records.length < MAX_LOADED) return step();
|
||||
});
|
||||
};
|
||||
return step().then(function () {
|
||||
state.loading = false;
|
||||
var worlds = state.records.filter(function (r) { return r.source === 'world' && r.zone; });
|
||||
var zone = worlds.length ? worlds[0].zone : '';
|
||||
byId('world3dBtn').href = '/world3d?capture=' + c.id + (zone ? '&zone=' + zone : '');
|
||||
byId('scrub').max = String(Math.max(1, Math.ceil(state.duration)));
|
||||
if (fresh) setPlayhead(state.duration);
|
||||
else render();
|
||||
renderHeader();
|
||||
}, function () { state.loading = false; });
|
||||
}
|
||||
|
||||
function filtered() {
|
||||
var name = byId('nameFilter').value.trim().toUpperCase(), source = byId('sourceFilter').value;
|
||||
if (!name && !source) return state.records;
|
||||
return state.records.filter(function (r) {
|
||||
return (!source || r.source === source) && (!name || String(r.name || '').indexOf(name) >= 0);
|
||||
});
|
||||
}
|
||||
|
||||
// The rows up to the playhead (the last MAX_ROWS of them)
|
||||
function render() {
|
||||
var list = filtered(), until = 0;
|
||||
while (until < list.length && list[until].t <= state.playhead) until++;
|
||||
var from = Math.max(0, until - MAX_ROWS);
|
||||
byId('rows').innerHTML = list.slice(from, until).map(function (r) {
|
||||
var cls = (r.gap ? 'gap ' : '') + (r.toServer ? 'to-server ' : '') + (r.i === state.selected ? 'selected' : '');
|
||||
return '<tr class="' + cls + '" data-i="' + r.i + '"><td>' + r.i + '</td><td class="font-monospace">' + timeText(r.t) + '</td>' +
|
||||
'<td>' + esc(r.source + (r.source === 'world' && r.zone ? ' ' + r.zone : '')) + '</td>' +
|
||||
'<td>' + esc(r.gap ? '' : (r.from + ' → ' + r.to)) + '</td>' +
|
||||
'<td title="' + esc(r.name || '') + '">' + esc(r.name || '') + (r.cut ? ' <span class="badge text-bg-warning">cut</span>' : '') + (r.unreadable ? ' <span class="badge text-bg-danger">unreadable</span>' : '') + '</td>' +
|
||||
'<td class="text-end">' + nf.format(r.bytes) + '</td></tr>';
|
||||
}).join('');
|
||||
byId('countText').textContent = nf.format(until) + ' of ' + nf.format(list.length) + ' shown' + (list.length !== state.records.length ? ' (filtered from ' + nf.format(state.records.length) + ')' : '') +
|
||||
(state.records.length >= MAX_LOADED ? '; the first ' + nf.format(MAX_LOADED) + ' are loaded' : '');
|
||||
var wrap = byId('tableWrap');
|
||||
if (state.playing || state.playhead >= state.duration) wrap.scrollTop = wrap.scrollHeight;
|
||||
}
|
||||
|
||||
function setPlayhead(ms) {
|
||||
state.playhead = Math.max(0, Math.min(state.duration, ms));
|
||||
byId('scrub').value = String(Math.round(state.playhead));
|
||||
byId('timeText').textContent = timeText(state.playhead) + ' / ' + timeText(state.duration);
|
||||
render();
|
||||
if (channel && state.capture) channel.postMessage({ capture: state.capture.id, t: state.playhead / 1000 });
|
||||
}
|
||||
|
||||
var lastFrame = 0;
|
||||
function frame(now) {
|
||||
if (!state.playing) return;
|
||||
var dt = lastFrame ? Math.min(now - lastFrame, 250) : 0;
|
||||
lastFrame = now;
|
||||
setPlayhead(state.playhead + dt * Number(byId('speed').value));
|
||||
if (state.playhead >= state.duration) { stop(); return; }
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function play() {
|
||||
if (!state.records.length) return;
|
||||
if (state.playhead >= state.duration) setPlayhead(0);
|
||||
state.playing = true;
|
||||
lastFrame = 0;
|
||||
byId('playBtn').textContent = 'Pause';
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
function stop() {
|
||||
state.playing = false;
|
||||
byId('playBtn').textContent = 'Play';
|
||||
}
|
||||
|
||||
byId('playBtn').addEventListener('click', function () { if (state.playing) stop(); else play(); });
|
||||
byId('endBtn').addEventListener('click', function () { stop(); setPlayhead(state.duration); });
|
||||
byId('scrub').addEventListener('input', function () { stop(); setPlayhead(Number(byId('scrub').value)); });
|
||||
byId('nameFilter').addEventListener('input', render);
|
||||
byId('sourceFilter').addEventListener('change', render);
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (e.key !== ' ' || !state.capture || /INPUT|SELECT|TEXTAREA|BUTTON/.test(document.activeElement.tagName)) return;
|
||||
e.preventDefault();
|
||||
if (state.playing) stop(); else play();
|
||||
});
|
||||
|
||||
// ---- one packet ----
|
||||
|
||||
function hexdump(hex) {
|
||||
var lines = [];
|
||||
for (var at = 0; at < hex.length; at += 32) {
|
||||
var chunk = hex.slice(at, at + 32), bytes = [], text = '';
|
||||
for (var i = 0; i < chunk.length; i += 2) {
|
||||
var b = parseInt(chunk.substr(i, 2), 16);
|
||||
bytes.push(chunk.substr(i, 2));
|
||||
text += b >= 32 && b < 127 ? String.fromCharCode(b) : '.';
|
||||
}
|
||||
lines.push((at / 2).toString(16).padStart(6, '0') + ' ' + bytes.join(' ').padEnd(47, ' ') + ' ' + text);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
byId('rows').addEventListener('click', function (e) {
|
||||
var row = e.target.closest('[data-i]');
|
||||
if (!row || !state.capture) return;
|
||||
state.selected = parseInt(row.dataset.i, 10);
|
||||
render();
|
||||
api.get(url(state.capture.id) + '/packets/' + state.selected).then(function (d) {
|
||||
if (!d.record) { toast(d.error || 'Could not load the packet', 'danger'); return; }
|
||||
var r = d.record;
|
||||
byId('detail').classList.remove('d-none');
|
||||
byId('grid').classList.remove('no-detail');
|
||||
byId('detailTitle').textContent = r.name || 'Packet';
|
||||
byId('detailMeta').textContent = '#' + r.i + ' at ' + timeText(r.t) + ' · ' + (r.from || '') + ' → ' + (r.to || '') + ' · ' + r.source +
|
||||
(r.zone ? ' ' + r.zone + ':' + r.instance + (r.clone ? ':' + r.clone : '') : '') + ' · ' + nf.format(r.bytes) + ' bytes' +
|
||||
(r.account ? ' · account ' + r.account : '') + (r.character !== '0' ? ' · character ' + r.character : '') + (r.peer ? ' · ' + r.peer : '');
|
||||
byId('detailFields').textContent = r.fields ? JSON.stringify(r.fields, null, 2) : (r.gap ? r.gap + ' packets were lost here (a server buffer was full)' : 'No decoder for this packet; its bytes are below.');
|
||||
byId('detailHex').textContent = hexdump(r.hex || '');
|
||||
}).catch(function () {});
|
||||
});
|
||||
byId('detailClose').addEventListener('click', function () {
|
||||
byId('detail').classList.add('d-none');
|
||||
byId('grid').classList.add('no-detail');
|
||||
state.selected = -1;
|
||||
render();
|
||||
});
|
||||
|
||||
byId('stopBtn').addEventListener('click', function () {
|
||||
if (!state.capture) return;
|
||||
api.action('/api/inspector/packet-captures/' + state.capture.id + '/stop', {}, 'Capture stopped').then(function (d) { upsert(d.capture); }).catch(function () {});
|
||||
});
|
||||
byId('deleteBtn').addEventListener('click', function () {
|
||||
var c = state.capture;
|
||||
if (!c) return;
|
||||
Decide({ title: 'Delete capture ' + c.id, text: 'Deletes the capture and its ' + nf.format(c.received) + ' packets for good.', reasonLabel: 'Why (for the audit log)', action: 'Delete' })
|
||||
.then(function (choice) {
|
||||
if (!choice) return;
|
||||
return api.action(url(c.id) + '/delete', { reason: choice.reason }, 'Capture deleted').then(function () {
|
||||
state.live = state.live.filter(function (x) { return x.id !== c.id; });
|
||||
state.saved = state.saved.filter(function (x) { return x.id !== c.id; });
|
||||
state.capture = null;
|
||||
byId('viewer').classList.add('d-none');
|
||||
byId('emptyViewer').classList.remove('d-none');
|
||||
renderList();
|
||||
});
|
||||
}).catch(function () {});
|
||||
});
|
||||
|
||||
// ---- live ----
|
||||
|
||||
var reloadTimer = null;
|
||||
Live.onTopic('packet_capture', function (message) {
|
||||
upsert(message.capture);
|
||||
// New packets of the open capture: load them (at most every 2 seconds)
|
||||
if (state.capture && message.capture.id === state.capture.id && message.recent && message.recent.length && !reloadTimer) {
|
||||
reloadTimer = setTimeout(function () {
|
||||
reloadTimer = null;
|
||||
var atEnd = state.playhead >= state.duration;
|
||||
load(false).then(function () { if (atEnd && !state.playing) setPlayhead(state.duration); });
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
|
||||
onTarget();
|
||||
refreshList().then(function () {
|
||||
var match = /capture=(\d+)/.exec(window.location.hash);
|
||||
if (match) open(parseInt(match[1], 10));
|
||||
});
|
||||
})();
|
||||
@@ -874,6 +874,25 @@ async function loadReplay() {
|
||||
const data = await api.get('/api/world3d/history?zone=' + state.zone + '&instance=' + state.instance + '&from=' + range.from + '&to=' + range.to).catch(() => null);
|
||||
setStatus('');
|
||||
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the replay', 'danger'); return; }
|
||||
startReplay(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* A packet capture's movement (the Packet Captures page's "World 3D" button: /world3d?capture=<id>&zone=<zone>): the
|
||||
* captured characters' position updates, replayed like recorded positions. The capture page's timeline drives the
|
||||
* time here while it plays (BroadcastChannel "dlu-capture-replay").
|
||||
*/
|
||||
async function loadCaptureReplay(capture) {
|
||||
setStatus('Loading the capture…');
|
||||
const data = await api.get('/api/inspector/sessions/' + encodeURIComponent(capture) + '/positions?zone=' + state.zone).catch(() => null);
|
||||
setStatus('');
|
||||
if (!data || data.success === false) { toast((data && data.error) || 'Could not load the capture', 'danger'); return; }
|
||||
startReplay(data);
|
||||
state.replay.capture = String(capture);
|
||||
if (!data.players.length) toast('No movement was captured in this zone' + (data.zones && data.zones.length ? ' (captured in: ' + data.zones.map(zoneName).join(', ') + ')' : ''), 'info');
|
||||
}
|
||||
|
||||
function startReplay(data) {
|
||||
stopReplay();
|
||||
const gap = Math.max(data.idleSeconds * 1.6, data.bucket * 2.5, data.interval * 3);
|
||||
const replay = { from: data.from, to: data.to, players: data.players, gap, hold: data.idleSeconds, t: 0, playing: false, trails: null };
|
||||
@@ -935,6 +954,16 @@ if (canHistory) {
|
||||
$('replayScrub').addEventListener('input', () => setReplayTime(Number($('replayScrub').value)));
|
||||
}
|
||||
|
||||
if ('BroadcastChannel' in window) {
|
||||
new BroadcastChannel('dlu-capture-replay').addEventListener('message', (e) => {
|
||||
const r = state.replay, m = e.data || {};
|
||||
if (!r || !r.capture || String(m.capture) !== r.capture) return;
|
||||
r.playing = false;
|
||||
$('replayPlay').textContent = 'Play';
|
||||
setReplayTime(Number(m.t) || 0);
|
||||
});
|
||||
}
|
||||
|
||||
// ---- heat map timelapse ----
|
||||
|
||||
// On a property zone every property is its own instance: show them all together, or one (its clone), as the reports do
|
||||
@@ -1149,5 +1178,14 @@ function animate() {
|
||||
// Running worlds and their player counts change; refresh them now and then
|
||||
setInterval(() => api.get('/api/world3d/meta').then((m) => { state.meta.worlds = m.worlds; renderInstances(); }).catch(() => {}), 30000);
|
||||
animate();
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (params.get('zone') && $('zoneSelect').querySelector('option[value="' + CSS.escape(params.get('zone')) + '"]')) $('zoneSelect').value = params.get('zone');
|
||||
if ($('zoneSelect').value) await loadZone(Number($('zoneSelect').value));
|
||||
if (params.get('capture') && canHistory) {
|
||||
$('modeReplay').checked = true;
|
||||
await setMode('replay');
|
||||
await loadCaptureReplay(params.get('capture'));
|
||||
} else if (params.get('capture')) {
|
||||
toast('Replaying a capture here needs the players_history permission', 'warning');
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
{% block content %}
|
||||
<div id="inspectorPage">
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">Game Message Inspector</h2>
|
||||
<h2 class="mb-1">Game Message Inspector <a class="btn btn-sm btn-outline-primary align-middle ms-2" href="/inspector/packets">Packet captures</a></h2>
|
||||
<p class="text-body-secondary mb-0">Capture the game messages one online player sends and receives, live, to debug scripts and behaviors, and look
|
||||
at them again later. The world server the player is in captures them; messages the server has a typed struct for show their fields, all
|
||||
show their bytes. A capture follows the player to other zones and always stops at its time limit. Every capture is saved (deleted after
|
||||
|
||||
137
dDashboardServer/templates/packet-captures.jinja2
Normal file
137
dDashboardServer/templates/packet-captures.jinja2
Normal file
@@ -0,0 +1,137 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Packet Captures - DarkflameServer{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
.pc-layout { display: grid; grid-template-columns: 19rem minmax(0, 1fr); gap: 1rem; align-items: start; }
|
||||
.pc-side { position: sticky; top: 4rem; }
|
||||
.pc-side .list-group-item { cursor: pointer; }
|
||||
.pc-side .list-group-item.active .text-body-secondary { color: inherit !important; opacity: .85; }
|
||||
.pc-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(22rem, 30rem); gap: 1rem; align-items: start; }
|
||||
.pc-grid.no-detail { grid-template-columns: minmax(0, 1fr); }
|
||||
.pc-table-wrap { height: calc(100vh - 24rem); min-height: 18rem; overflow: auto; }
|
||||
.pc-table { font-size: .8rem; table-layout: fixed; width: 100%; }
|
||||
.pc-table td, .pc-table th { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.pc-table tbody tr { cursor: pointer; }
|
||||
.pc-table tbody tr.selected > td { background: var(--bs-primary-bg-subtle); }
|
||||
.pc-table tbody tr.gap > td { background: var(--bs-warning-bg-subtle); }
|
||||
.pc-table tbody tr.to-server > td:first-child { box-shadow: inset 3px 0 0 var(--bs-primary); }
|
||||
.pc-table .c-i { width: 4.5rem; }
|
||||
.pc-table .c-t { width: 6.5rem; }
|
||||
.pc-table .c-src { width: 7.5rem; }
|
||||
.pc-table .c-dir { width: 9rem; }
|
||||
.pc-table .c-b { width: 4.5rem; }
|
||||
.pc-detail { position: sticky; top: 4rem; max-height: calc(100vh - 5rem); overflow: auto; }
|
||||
.pc-hex { font-family: var(--bs-font-monospace); font-size: .75rem; white-space: pre; overflow-x: auto; margin: 0; }
|
||||
.pc-fields { font-family: var(--bs-font-monospace); font-size: .75rem; white-space: pre-wrap; word-break: break-all; margin: 0; }
|
||||
@media (max-width: 1599.98px) { .pc-grid { grid-template-columns: minmax(0, 1fr); } .pc-detail { position: static; max-height: none; } }
|
||||
@media (max-width: 991.98px) { .pc-layout { grid-template-columns: minmax(0, 1fr); } .pc-side { position: static; } }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="packetCapturePage">
|
||||
<div class="mb-3">
|
||||
<h2 class="mb-1">Packet Captures</h2>
|
||||
<p class="text-body-secondary mb-0">Record every packet of one account (from its next login, or now if it is online), one character (from when
|
||||
it is picked), or everything, on all servers at once: auth, chat, every world and master, on one timeline. Play a capture back here, watch the
|
||||
movement in <a href="/world3d">World 3D</a>, or export it as a bundle for the capture tool's replay against a sandbox server
|
||||
(<code>docs/CaptureReplay.md</code>). Passwords, session keys and user keys are never recorded. Arming, stopping and exporting are audited;
|
||||
captures are kept as long as the <a href="/inspector">Message Inspector</a>'s.</p>
|
||||
</div>
|
||||
|
||||
<div class="pc-layout">
|
||||
<aside class="pc-side">
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><h5 class="mb-0 fs-6">New packet capture</h5></div>
|
||||
<div class="card-body">
|
||||
<form id="armForm" class="d-grid gap-2">
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="What to capture">
|
||||
<input type="radio" class="btn-check" name="target" id="targetAccount" value="account" checked>
|
||||
<label class="btn btn-outline-primary" for="targetAccount">Account</label>
|
||||
<input type="radio" class="btn-check" name="target" id="targetCharacter" value="character">
|
||||
<label class="btn btn-outline-primary" for="targetCharacter">Character</label>
|
||||
<input type="radio" class="btn-check" name="target" id="targetEverything" value="everything">
|
||||
<label class="btn btn-outline-primary" for="targetEverything">Everything</label>
|
||||
</div>
|
||||
<div id="pickerWrap"><label class="form-label small mb-1" for="targetPicker" id="pickerLabel">Account</label>
|
||||
<input class="form-control form-control-sm" id="targetPicker" placeholder="Part of a name, or an ID">
|
||||
<div class="form-text small" id="pickerHint">Account or character name, account ID or character object ID. Online ones are marked.</div></div>
|
||||
<div id="everythingHint" class="small text-warning d-none">Everything on every server: a busy server makes large files. Keep it short.</div>
|
||||
<div><label class="form-label small mb-1" for="armSeconds">For</label>
|
||||
<select class="form-select form-select-sm" id="armSeconds" data-pref="packets.seconds">
|
||||
<option value="60">1 minute</option>
|
||||
<option value="300" selected>5 minutes</option>
|
||||
<option value="600">10 minutes</option>
|
||||
<option value="900">15 minutes</option>
|
||||
</select></div>
|
||||
<button class="btn btn-sm btn-primary">Arm capture</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0 fs-6">Captures</h5>
|
||||
<a class="small" href="/inspector">Game messages</a></div>
|
||||
<div class="list-group list-group-flush" id="captureList"><div class="list-group-item text-body-secondary small">Loading…</div></div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div style="min-width: 0">
|
||||
<div class="card d-none" id="viewer">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<div style="min-width: 0"><h5 class="mb-0" id="viewTitle"></h5><div class="small text-body-secondary" id="viewSubtitle"></div></div>
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<a class="btn btn-sm btn-outline-secondary" id="world3dBtn" target="_blank" rel="noopener" title="The captured movement in World 3D; this page's timeline drives it while it plays">World 3D</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" id="bundleBtn" title="A portable bundle for the capture tool (audited)">Export bundle</a>
|
||||
<a class="btn btn-sm btn-outline-secondary" id="fixtureBtn" title="Names and chat replaced, for a local test fixture (audited)">Export anonymised</a>
|
||||
<button class="btn btn-sm btn-outline-danger d-none" id="stopBtn">Stop</button>
|
||||
<button class="btn btn-sm btn-outline-danger d-none" id="deleteBtn">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2 mb-2 small" aria-label="Playback">
|
||||
<button type="button" class="btn btn-sm btn-primary" id="playBtn" title="Play or pause (space)">Play</button>
|
||||
<select class="form-select form-select-sm w-auto" id="speed" data-pref="packets.speed" title="Speed">
|
||||
<option value="0.25">0.25×</option><option value="0.5">0.5×</option><option value="1" selected>1×</option>
|
||||
<option value="2">2×</option><option value="4">4×</option><option value="16">16×</option><option value="64">64×</option>
|
||||
</select>
|
||||
<input type="range" class="form-range flex-grow-1 w-auto" id="scrub" min="0" max="1" step="1" value="0" aria-label="Time in the capture">
|
||||
<span class="font-monospace text-nowrap" id="timeText"></span>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" id="endBtn" title="Show everything">End</button>
|
||||
</div>
|
||||
<div class="row g-2 align-items-end mb-2">
|
||||
<div class="col-md-5"><input class="form-control form-control-sm" id="nameFilter" placeholder="Packet name part, e.g. POSITION, GAME_MSG REQUEST_USE"></div>
|
||||
<div class="col-md-3"><select class="form-select form-select-sm" id="sourceFilter" aria-label="Server">
|
||||
<option value="">Every server</option><option value="auth">Auth</option><option value="chat">Chat</option><option value="world">Worlds</option><option value="master">Master</option></select></div>
|
||||
<div class="col-md-4 small text-body-secondary" id="countText"></div>
|
||||
</div>
|
||||
<div class="pc-grid no-detail" id="grid">
|
||||
<div class="pc-table-wrap border rounded" id="tableWrap">
|
||||
<table class="table table-sm table-hover mb-0 pc-table">
|
||||
<thead class="sticky-top"><tr><th class="c-i">#</th><th class="c-t">Time</th><th class="c-src">Server</th><th class="c-dir">From → to</th><th>Packet</th><th class="c-b text-end">Bytes</th></tr></thead>
|
||||
<tbody id="rows"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card pc-detail d-none" id="detail">
|
||||
<div class="card-header d-flex justify-content-between align-items-center gap-2"><h5 class="mb-0 fs-6 text-truncate" id="detailTitle"></h5>
|
||||
<button type="button" class="btn-close" id="detailClose" aria-label="Close"></button></div>
|
||||
<div class="card-body">
|
||||
<div class="small text-body-secondary mb-2" id="detailMeta"></div>
|
||||
<pre class="pc-fields border rounded p-2 mb-2" id="detailFields"></pre>
|
||||
<h6 class="small text-uppercase text-body-secondary">Bytes (as sent over RakNet)</h6>
|
||||
<pre class="pc-hex border rounded p-2" id="detailHex"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-body-secondary small" id="emptyViewer">Arm a capture, or pick one on the left.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/packet-captures.js"></script>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user