From bba1dac0118712909fc3b4982eaa1fd71009a46c Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 10:05:10 -0500 Subject: [PATCH] feat(dashboard): Download logs on the System Log page, with a preview Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/routes/DashboardRoutes.cpp | 10 +- dDashboardServer/templates/system_log.jinja2 | 117 +++++++++++++++++++ 2 files changed, 126 insertions(+), 1 deletion(-) diff --git a/dDashboardServer/routes/DashboardRoutes.cpp b/dDashboardServer/routes/DashboardRoutes.cpp index 7786a51c8..43df741ad 100644 --- a/dDashboardServer/routes/DashboardRoutes.cpp +++ b/dDashboardServer/routes/DashboardRoutes.cpp @@ -496,7 +496,15 @@ void RegisterDashboardRoutes() { servers.push_back({ {"name", server}, {"time", files.front().time}, {"files", files.size()} }); types.insert(server.substr(0, server.find('_'))); } - nlohmann::json data{ {"servers", servers}, {"server_types", types}, {"selected_server", selected}, {"files", nlohmann::json::array()} }; + // World zones with log files, for Download logs + std::set zoneIds; + for (const auto& [server, files] : logs) { + if (!server.starts_with("WorldServer_")) continue; + if (const auto zone = GeneralUtils::TryParse(server.substr(12, server.find('_', 12) - 12))) zoneIds.insert(*zone); + } + nlohmann::json zones = nlohmann::json::array(); + for (const auto zone : zoneIds) zones.push_back({ {"id", zone}, {"name", GetZoneNamesJson().value(std::to_string(zone), "")} }); + nlohmann::json data{ {"servers", servers}, {"server_types", types}, {"selected_server", selected}, {"files", nlohmann::json::array()}, {"bundle_zones", zones} }; if (!selected.empty()) { const auto& files = logs.at(selected); const auto wanted = QueryValue(context.queryString, "file"); diff --git a/dDashboardServer/templates/system_log.jinja2 b/dDashboardServer/templates/system_log.jinja2 index b54604831..0042a4b2b 100644 --- a/dDashboardServer/templates/system_log.jinja2 +++ b/dDashboardServer/templates/system_log.jinja2 @@ -46,6 +46,56 @@ +
+
Download logs
+
+

Log files from a date range, as one zip file with a manifest.txt of what's in it. A file is picked when the time from its start to its last write overlaps the range.

+
+
+
+
+ Last + + + + +
+
+ Servers +
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+ + +
+
+
+
+
+ + + +
+
+
+
+
+
Search the logs
@@ -81,6 +131,73 @@ document.querySelectorAll('#logFilePick option').forEach(function (o) { document.getElementById('logServerPick').addEventListener('change', function () { location.href = '/system_log?server=' + encodeURIComponent(this.value); }); document.getElementById('logFilePick').addEventListener('change', function () { this.form.submit(); }); +// Download logs +function bundleLocal(date) { var d = new Date(date.getTime() - date.getTimezoneOffset() * 60000); return d.toISOString().slice(0, 16); } +function bundleRange(hours) { + document.getElementById('bundleTo').value = hours ? bundleLocal(new Date()) : ''; + document.getElementById('bundleFrom').value = hours ? bundleLocal(new Date(Date.now() - hours * 3600000)) : ''; +} +bundleRange(24); +document.querySelectorAll('[data-hours]').forEach(function (b) { b.addEventListener('click', function () { bundleRange(Number(b.dataset.hours)); }); }); +function bundleWorldsShown() { document.getElementById('bundleWorlds').classList.toggle('opacity-50', !document.getElementById('bundleServer_world').checked); } +document.getElementById('bundleServer_world').addEventListener('change', bundleWorldsShown); +function bundleQuery() { + var q = new URLSearchParams(), v; + ['From', 'To'].forEach(function (k) { v = document.getElementById('bundle' + k).value; if (v) q.set(k.toLowerCase(), Math.floor(new Date(v).getTime() / 1000)); }); + var servers = Array.from(document.querySelectorAll('.bundle-server:checked')).map(function (c) { return c.value; }); + if (servers.length < 6) q.set('servers', servers.join(',')); + var zones = Array.from(document.getElementById('bundleZones').selectedOptions).map(function (o) { return o.value; }); + if (zones.length) q.set('zones', zones.join(',')); + ['Clone', 'Instance'].forEach(function (k) { v = document.getElementById('bundle' + k).value; if (v !== '') q.set(k.toLowerCase(), v); }); + if (document.getElementById('bundleCrash').checked) q.set('crash', '1'); + if (document.getElementById('bundleTrim').checked) q.set('trim', '1'); + if (document.getElementById('bundleRedact').checked) q.set('redact', '1'); + v = document.getElementById('bundleText').value.trim(); if (v) q.set('text', v); + return q.toString(); +} +var bundleStatus = document.getElementById('bundleStatus'); +function bundleSay(text, cls) { bundleStatus.className = 'small align-self-center ' + (cls || 'text-body-secondary'); bundleStatus.textContent = text; } +function bundleCheck() { + if (!document.querySelectorAll('.bundle-server:checked').length) { bundleSay('Pick at least one server', 'text-danger'); return false; } + return true; +} +document.getElementById('bundlePreview').addEventListener('click', function () { + if (!bundleCheck()) return; + var out = document.getElementById('bundleFiles'); + bundleSay('Looking\u2026'); + fetch('/api/logs/bundle/preview?' + bundleQuery(), { credentials: 'same-origin' }).then(function (r) { return r.json(); }).then(function (d) { + if (d.error) { bundleSay(d.error, 'text-danger'); out.innerHTML = ''; return; } + var total = fileSize(d.total_size) + ' in ' + d.count + ' file' + (d.count === 1 ? '' : 's'); + bundleSay(d.over_limit ? total + ': over the ' + fileSize(d.max_bytes) + ' limit' : total + (d.filters_lines ? ' before filtering lines' : ''), d.over_limit ? 'text-danger' : 'text-body-secondary'); + if (!d.files.length) { out.innerHTML = '

No log files match.

'; return; } + out.innerHTML = '
' + + d.files.map(function (f) { + return ''; + }).join('') + '
FileServerWorldSizeFromTo
' + esc(f.name) + '' + (f.crash_dump ? ' crash' : '') + '' + esc(f.server || '?') + '' + + (f.zone === null ? '' : esc(f.zone + (f.zone_name ? ' ' + f.zone_name : '') + ' (clone ' + f.clone + ', instance ' + f.instance + ')')) + '' + fileSize(f.size) + + '' + fmt.unix(f.started) + '' + fmt.unix(f.written) + '
' + (d.count > d.files.length ? '

Showing the first ' + d.files.length + '.

' : ''); + }).catch(function () { bundleSay('Preview failed', 'text-danger'); }); +}); +document.getElementById('bundleDownload').addEventListener('click', function () { + if (!bundleCheck()) return; + var button = this; + button.disabled = true; + bundleSay('Building the zip\u2026'); + fetch('/api/logs/bundle?' + bundleQuery(), { credentials: 'same-origin' }).then(function (r) { + if (!r.ok) return r.json().then(function (d) { throw new Error(d.error || ('HTTP ' + r.status)); }); + var name = ((r.headers.get('Content-Disposition') || '').match(/filename="([^"]+)"/) || [])[1] || 'logs.zip'; + return r.blob().then(function (blob) { + var a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); + a.click(); + setTimeout(function () { URL.revokeObjectURL(a.href); a.remove(); }, 1000); + bundleSay('Downloaded ' + name + ' (' + fileSize(blob.size) + ')', 'text-success'); + }); + }).catch(function (e) { bundleSay(e.message, 'text-danger'); }).finally(function () { button.disabled = false; }); +}); + document.getElementById('logSearch').addEventListener('submit', function (e) { e.preventDefault(); var out = document.getElementById('logResults');