Files
DarkflameServer/dDashboardServer/templates/system_log.jinja2
2026-09-27 10:05:10 -05:00

220 lines
16 KiB
Django/Jinja

{% extends "base.jinja2" %}
{% block title %}System Log - DarkflameServer{% endblock %}
{% block content %}
<div class="mb-3">
<h2 class="mb-1">System Log</h2>
<p class="text-body-secondary mb-0">The servers' log files. Pick a server and one of its files; the newest is shown first.</p>
</div>
<div class="card mb-3">
<div class="card-header">
<form method="get" class="row g-2 align-items-end" id="logPick">
<div class="col-sm-4">
<label class="form-label small mb-1" for="logServerPick">Server</label>
<select name="server" id="logServerPick" class="form-select form-select-sm">
{% for server in servers %}
<option value="{{ server.name }}"{% if server.name == selected_server %} selected{% endif %}>{{ server.name }} ({{ server.files }} file{% if server.files != 1 %}s{% endif %})</option>
{% endfor %}
</select>
</div>
<div class="col-sm-5">
<label class="form-label small mb-1" for="logFilePick">File</label>
<select name="file" id="logFilePick" class="form-select form-select-sm">
{% for file in files %}
<option value="{{ file.name }}" data-time="{{ file.time }}" data-size="{{ file.size }}"{% if exists("log_file") %}{% if file.name == log_file %} selected{% endif %}{% endif %}>{{ file.name }}</option>
{% endfor %}
</select>
</div>
<div class="col-sm-3 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-secondary flex-fill" onclick="location.reload()">Refresh</button>
{% if exists("log_file") %}<a class="btn btn-sm btn-outline-secondary flex-fill" href="/api/logs/file?name={{ log_file }}">Download</a>{% endif %}
</div>
</form>
</div>
{% if exists("log_file") %}
<div class="px-3 py-2 border-bottom d-flex flex-wrap align-items-baseline gap-2 bg-body-tertiary">
<span class="fw-semibold">{{ selected_server }}</span>
<code class="small">{{ log_file }}</code>
{% if log_newest %}<span class="badge text-bg-success">newest</span>{% else %}<span class="badge text-bg-secondary">older file</span>{% endif %}
<span class="small text-body-secondary ms-auto" id="logMeta" data-time="{{ log_time }}" data-size="{{ log_size }}"></span>
</div>
{% endif %}
<div class="card-body p-0">
<pre id="logContent" class="log-output mb-0 p-3">{% if exists("log_content") %}{{ log_content }}{% else %}No log files found{% endif %}</pre>
</div>
</div>
<div class="card mb-3">
<div class="card-header"><h5 class="mb-0">Download logs</h5></div>
<div class="card-body">
<p class="small text-body-secondary">Log files from a date range, as one zip file with a <code>manifest.txt</code> of what's in it. A file is picked when the time from its start to its last write overlaps the range.</p>
<form id="bundleForm" class="row g-2">
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="bundleFrom">From</label><input type="datetime-local" class="form-control form-control-sm" id="bundleFrom"></div>
<div class="col-sm-6 col-lg-3"><label class="form-label small mb-1" for="bundleTo">To</label><input type="datetime-local" class="form-control form-control-sm" id="bundleTo"></div>
<div class="col-lg-6 d-flex align-items-end gap-1 flex-wrap">
<span class="small text-body-secondary me-1">Last</span>
<button type="button" class="btn btn-sm btn-outline-secondary" data-hours="1">hour</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-hours="24">day</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-hours="168">week</button>
<button type="button" class="btn btn-sm btn-outline-secondary" data-hours="0">all time</button>
</div>
<div class="col-12">
<span class="form-label small d-block mb-1">Servers</span>
<div class="form-check form-check-inline"><input class="form-check-input bundle-server" type="checkbox" id="bundleServer_master" value="master" checked><label class="form-check-label" for="bundleServer_master">Master</label></div>
<div class="form-check form-check-inline"><input class="form-check-input bundle-server" type="checkbox" id="bundleServer_auth" value="auth" checked><label class="form-check-label" for="bundleServer_auth">Auth</label></div>
<div class="form-check form-check-inline"><input class="form-check-input bundle-server" type="checkbox" id="bundleServer_chat" value="chat" checked><label class="form-check-label" for="bundleServer_chat">Chat</label></div>
<div class="form-check form-check-inline"><input class="form-check-input bundle-server" type="checkbox" id="bundleServer_dashboard" value="dashboard" checked><label class="form-check-label" for="bundleServer_dashboard">Dashboard</label></div>
<div class="form-check form-check-inline"><input class="form-check-input bundle-server" type="checkbox" id="bundleServer_ugc" value="ugc" checked><label class="form-check-label" for="bundleServer_ugc">UGC</label></div>
<div class="form-check form-check-inline"><input class="form-check-input bundle-server" type="checkbox" id="bundleServer_world" value="world" checked><label class="form-check-label" for="bundleServer_world">Worlds</label></div>
</div>
<div class="col-sm-6" id="bundleWorlds">
<label class="form-label small mb-1" for="bundleZones">Worlds (none picked: every zone)</label>
<select class="form-select form-select-sm" id="bundleZones" multiple size="4">
{% for zone in bundle_zones %}<option value="{{ zone.id }}">{{ zone.id }}{% if zone.name != "" %} {{ zone.name }}{% endif %}</option>{% endfor %}
</select>
<div class="row g-2 mt-0">
<div class="col"><input type="number" min="0" class="form-control form-control-sm" id="bundleClone" placeholder="Clone (any)" aria-label="Clone"></div>
<div class="col"><input type="number" min="0" class="form-control form-control-sm" id="bundleInstance" placeholder="Instance (any)" aria-label="Instance"></div>
</div>
</div>
<div class="col-sm-6">
<label class="form-label small mb-1" for="bundleText">Only lines with (optional)</label>
<input class="form-control form-control-sm mb-2" id="bundleText" maxlength="200" placeholder="e.g. a player name or an error">
<div class="form-check"><input class="form-check-input" type="checkbox" id="bundleTrim"><label class="form-check-label" for="bundleTrim">Only lines written in the date range (not whole files)</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="bundleRedact"><label class="form-check-label" for="bundleRedact">Hide IP addresses</label></div>
<div class="form-check"><input class="form-check-input" type="checkbox" id="bundleCrash" checked><label class="form-check-label" for="bundleCrash">Crash dumps too</label></div>
</div>
<div class="col-12 d-flex gap-2">
<button type="button" class="btn btn-sm btn-outline-primary" id="bundlePreview">Preview</button>
<button type="button" class="btn btn-sm btn-primary" id="bundleDownload">Download zip</button>
<span class="small align-self-center" id="bundleStatus" role="status"></span>
</div>
</form>
<div id="bundleFiles" class="mt-3"></div>
</div>
</div>
<div class="card">
<div class="card-header"><h5 class="mb-0">Search the logs</h5></div>
<div class="card-body">
<form class="row g-2 align-items-end" id="logSearch">
<div class="col-sm-5"><label class="form-label small" for="logQuery">Text</label><input class="form-control form-control-sm" id="logQuery" minlength="2" maxlength="200" required placeholder="e.g. a player name, an error, a zone ID"></div>
<div class="col-sm-3"><label class="form-label small" for="logServer">Server</label><select class="form-select form-select-sm" id="logServer"><option value="">All servers</option>
{% for type in server_types %}<option value="{{ type }}">{{ type }}</option>{% endfor %}</select></div>
<div class="col-sm-2"><label class="form-label small" for="logFiles">Newest files</label><select class="form-select form-select-sm" id="logFiles"><option>1</option><option selected>3</option><option>10</option><option>20</option></select></div>
<div class="col-sm-2"><button class="btn btn-sm btn-primary w-100">Search</button></div>
</form>
<div id="logResults" class="mt-3"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
var log = document.getElementById('logContent');
log.scrollTop = log.scrollHeight;
function fileSize(bytes) {
bytes = Number(bytes);
return bytes >= 1048576 ? (bytes / 1048576).toFixed(1) + ' MB' : Math.max(1, Math.round(bytes / 1024)) + ' kB';
}
// What's shown: when the file was last written, how big it is, and that this is its end
var meta = document.getElementById('logMeta');
if (meta) meta.textContent = 'Last written ' + fmt.unix(Number(meta.dataset.time)) + ' \u00b7 ' + fileSize(meta.dataset.size) + ' \u00b7 showing the last 500 lines';
// File names end in the time the server started; show when each was written too
document.querySelectorAll('#logFilePick option').forEach(function (o) {
var started = Number((o.value.match(/_(\d{9,})\.log$/) || [])[1]);
o.textContent = (started ? 'Started ' + fmt.unix(started) + ', ' : '') + 'last written ' + fmt.unix(Number(o.dataset.time)) + ' (' + fileSize(o.dataset.size) + ')';
});
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 = '<p class="text-body-secondary mb-0">No log files match.</p>'; return; }
out.innerHTML = '<div class="table-responsive" style="max-height:24rem"><table class="table table-sm small mb-0"><thead><tr><th>File</th><th>Server</th><th>World</th><th class="text-end">Size</th><th>From</th><th>To</th></tr></thead><tbody>' +
d.files.map(function (f) {
return '<tr><td class="text-break"><code>' + esc(f.name) + '</code>' + (f.crash_dump ? ' <span class="badge text-bg-danger">crash</span>' : '') + '</td><td>' + esc(f.server || '?') + '</td><td>' +
(f.zone === null ? '' : esc(f.zone + (f.zone_name ? ' ' + f.zone_name : '') + ' (clone ' + f.clone + ', instance ' + f.instance + ')')) + '</td><td class="text-end text-nowrap">' + fileSize(f.size) +
'</td><td class="text-nowrap">' + fmt.unix(f.started) + '</td><td class="text-nowrap">' + fmt.unix(f.written) + '</td></tr>';
}).join('') + '</tbody></table></div>' + (d.count > d.files.length ? '<p class="small text-body-secondary mt-1 mb-0">Showing the first ' + d.files.length + '.</p>' : '');
}).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');
var q = document.getElementById('logQuery').value.trim();
out.innerHTML = '<p class="text-body-secondary mb-0">Searching&hellip;</p>';
api.job('/api/logs/search?q=' + encodeURIComponent(q) + '&server=' + encodeURIComponent(document.getElementById('logServer').value) +
'&files=' + document.getElementById('logFiles').value, null, 'GET').then(function (d) {
if (!d.matches.length) { out.innerHTML = '<p class="text-body-secondary mb-0">Nothing found.</p>'; return; }
var lower = q.toLowerCase();
out.innerHTML = (d.truncated ? '<p class="small text-warning">Showing the first 1000 lines.</p>' : '<p class="small text-body-secondary">' + d.matches.length + ' line(s)</p>') +
'<pre class="log-output mb-0">' + d.matches.map(function (m) {
var text = esc(m.text), at = m.text.toLowerCase().indexOf(lower);
if (at >= 0) text = esc(m.text.slice(0, at)) + '<mark>' + esc(m.text.slice(at, at + q.length)) + '</mark>' + esc(m.text.slice(at + q.length));
return '<span class="text-body-secondary">' + esc(m.file) + ':' + m.line + '</span> ' + text;
}).join('\n') + '</pre>';
}).catch(function () { out.innerHTML = ''; });
});
</script>
{% endblock %}