From 88a97b893e344cdd49d038e8a3871ed9256052b7 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 08:48:25 -0500 Subject: [PATCH] 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=&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 --- dDashboardServer/routes/CaptureReplay.cpp | 5 + dDashboardServer/static/js/inspector.js | 13 +- dDashboardServer/static/js/packet-captures.js | 333 ++++++++++++++++++ dDashboardServer/static/js/world3d.js | 38 ++ dDashboardServer/templates/inspector.jinja2 | 2 +- .../templates/packet-captures.jinja2 | 137 +++++++ 6 files changed, 524 insertions(+), 4 deletions(-) create mode 100644 dDashboardServer/static/js/packet-captures.js create mode 100644 dDashboardServer/templates/packet-captures.jinja2 diff --git a/dDashboardServer/routes/CaptureReplay.cpp b/dDashboardServer/routes/CaptureReplay.cpp index 19d30bd84..0ee9101d8 100644 --- a/dDashboardServer/routes/CaptureReplay.cpp +++ b/dDashboardServer/routes/CaptureReplay.cpp @@ -426,6 +426,11 @@ namespace CaptureReplay { void RegisterRoutes() { Game::web.RegisterWSSubscription(TOPIC, std::function([] { 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) { diff --git a/dDashboardServer/static/js/inspector.js b/dDashboardServer/static/js/inspector.js index 58a97d69b..fad9117b5 100644 --- a/dDashboardServer/static/js/inspector.js +++ b/dDashboardServer/static/js/inspector.js @@ -210,7 +210,8 @@ var end = s.state === 'ended' ? s.endedAt : Date.now() / 1000; return '' + '' + esc(fmt.unix(s.startedAt)) + '' + - '' + fmt.character(s.characterId, s.characterName) + + '' + (s.kind === 1 ? 'Packets' + esc(s.target === 'everything' ? 'everything' : s.target === 'account' ? s.accountName : s.characterName) : + fmt.character(s.characterId, s.characterName)) + (s.accountId ? '
account ' + fmt.link('/accounts/' + s.accountId, s.accountName || String(s.accountId)) + '
' : '') + '' + '' + esc(s.startedBy) + '' + '' + esc(durationText(end - s.startedAt)) + '' + @@ -218,7 +219,8 @@ '' + nf.format(s.received) + (s.dropped ? ' +' + nf.format(s.dropped) + '' : '') + '' + '' + esc(bytesText(s.bytes)) + '' + '' + (s.state === 'ended' ? esc(s.endReason) : stateBadge(s)) + '' + - 'JSON' + + '' + (s.kind === 1 ? 'Bundle' : + 'JSON') + (s.state === 'ended' ? '' : '') + ''; }).join('') : 'No saved captures' + (filtered() ? ' match these filters' : ' yet') + '.'; 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) { diff --git a/dDashboardServer/static/js/packet-captures.js b/dDashboardServer/static/js/packet-captures.js new file mode 100644 index 000000000..689a4bac5 --- /dev/null +++ b/dDashboardServer/static/js/packet-captures.js @@ -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' ? '' : ' capturing'; + return '
' + + '
' + esc(c.describe || c.target) + '' + badge + '
' + + '
' + esc(fmt.unix(c.startedAt)) + ' · ' + nf.format(c.received) + ' packets · ' + esc(bytesText(c.bytes)) + + (c.dropped ? ' · ' + nf.format(c.dropped) + ' lost' : '') + '
'; + }).join('') : '
No packet captures yet.
'; + } + + 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 '' + r.i + '' + timeText(r.t) + '' + + '' + esc(r.source + (r.source === 'world' && r.zone ? ' ' + r.zone : '')) + '' + + '' + esc(r.gap ? '' : (r.from + ' → ' + r.to)) + '' + + '' + esc(r.name || '') + (r.cut ? ' cut' : '') + (r.unreadable ? ' unreadable' : '') + '' + + '' + nf.format(r.bytes) + ''; + }).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)); + }); +})(); diff --git a/dDashboardServer/static/js/world3d.js b/dDashboardServer/static/js/world3d.js index 41637b7ee..486e8454f 100644 --- a/dDashboardServer/static/js/world3d.js +++ b/dDashboardServer/static/js/world3d.js @@ -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=&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'); + } })(); diff --git a/dDashboardServer/templates/inspector.jinja2 b/dDashboardServer/templates/inspector.jinja2 index d26ff514b..7a2b8c742 100644 --- a/dDashboardServer/templates/inspector.jinja2 +++ b/dDashboardServer/templates/inspector.jinja2 @@ -58,7 +58,7 @@ {% block content %}
-

Game Message Inspector

+

Game Message Inspector Packet captures

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 diff --git a/dDashboardServer/templates/packet-captures.jinja2 b/dDashboardServer/templates/packet-captures.jinja2 new file mode 100644 index 000000000..225daa279 --- /dev/null +++ b/dDashboardServer/templates/packet-captures.jinja2 @@ -0,0 +1,137 @@ +{% extends "base.jinja2" %} + +{% block title %}Packet Captures - DarkflameServer{% endblock %} + +{% block css %} + +{% endblock %} + +{% block content %} +

+
+

Packet Captures

+

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 World 3D, or export it as a bundle for the capture tool's replay against a sandbox server + (docs/CaptureReplay.md). Passwords, session keys and user keys are never recorded. Arming, stopping and exporting are audited; + captures are kept as long as the Message Inspector's.

+
+ +
+ + +
+
+
+
+
+ World 3D + Export bundle + Export anonymised + + +
+
+
+
+ + + + + +
+
+
+
+
+
+
+
+ + + +
#TimeServerFrom → toPacketBytes
+
+
+
+
+
+
+

+								
Bytes (as sent over RakNet)
+

+							
+
+
+
+
+
Arm a capture, or pick one on the left.
+
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %}