mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
293 lines
18 KiB
Django/Jinja
293 lines
18 KiB
Django/Jinja
{% extends "base.jinja2" %}
|
|
|
|
{% block title %}Instance Load - DarkflameServer{% endblock %}
|
|
|
|
{% block css %}
|
|
<style>
|
|
.load-chart { width: 100%; height: 200px; display: block; }
|
|
.load-chart .grid { stroke: var(--bs-border-color); stroke-width: 1; }
|
|
.load-chart .cap { stroke-width: 1; stroke-dasharray: 4 3; }
|
|
.load-chart .axis { fill: var(--bs-secondary-color); font-size: 11px; }
|
|
.load-chart .line { fill: none; stroke-width: 2; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
<div class="d-flex flex-wrap justify-content-between align-items-end gap-3 mb-3">
|
|
<div>
|
|
<h2 class="mb-1">Instance Load</h2>
|
|
<p class="text-body-secondary mb-0">Players in each world instance, recorded once a minute with the server health (kept for <code>health_days</code>),
|
|
and how many players an instance of each zone takes.</p>
|
|
</div>
|
|
<div class="btn-group btn-group-sm" role="group" aria-label="Range">
|
|
<input type="radio" class="btn-check" name="loadRange" id="range24h" value="24h" checked><label class="btn btn-outline-secondary" for="range24h">24 hours</label>
|
|
<input type="radio" class="btn-check" name="loadRange" id="range7d" value="7d"><label class="btn btn-outline-secondary" for="range7d">7 days</label>
|
|
<input type="radio" class="btn-check" name="loadRange" id="range30d" value="30d"><label class="btn btn-outline-secondary" for="range30d">30 days</label>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="row g-3">
|
|
<div class="col-xl-5">
|
|
<div class="card">
|
|
<div class="card-header"><h5 class="mb-0">Busiest zones</h5></div>
|
|
<div class="card-body table-responsive">
|
|
<table class="table table-sm table-hover align-middle mb-0">
|
|
<thead><tr><th>Zone</th><th class="text-end" title="Most players in one instance">Busiest instance</th><th class="text-end" title="Most instances running at once">Instances</th><th class="text-end" title="Most players in the zone at once">Players</th></tr></thead>
|
|
<tbody id="zoneRows"><tr><td colspan="4" class="text-body-secondary">Loading…</td></tr></tbody>
|
|
</table>
|
|
<p class="small text-body-secondary mt-2 mb-0">Click a zone to see its instances.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-xl-7">
|
|
<div class="card mb-3">
|
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<h5 class="mb-0 fs-6" id="zoneTitle">Pick a zone</h5>
|
|
<div><label class="visually-hidden" for="zonePick">Zone</label><input class="form-control form-control-sm" id="zonePick" list="zoneList" placeholder="Zone name or ID" autocomplete="off"><datalist id="zoneList"></datalist></div>
|
|
</div>
|
|
<div class="card-body">
|
|
<svg class="load-chart" id="zoneChart" role="img" aria-label="Players per instance over time"></svg>
|
|
<div class="small mt-2" id="zoneLegend"></div>
|
|
</div>
|
|
</div>
|
|
<div class="card">
|
|
<div class="card-header"><h5 class="mb-0 fs-6">Players per instance</h5></div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary">The master server sends a player to a running instance of the zone while it has fewer players than the
|
|
<strong>soft cap</strong> (joining a friend: the <strong>hard cap</strong>), and otherwise starts a new one. The hard cap is also the most
|
|
connections that instance's world server accepts. Without a value here, the client's <code>ZoneTable</code> caps are used.
|
|
<strong>Spare instances</strong>: the master keeps this many instances with room running, so the next players don't wait for a world to
|
|
start. A spare that stays empty shuts itself down after 30 minutes, like any empty world, and the master starts another.</p>
|
|
<div id="limitEditor" class="d-none">
|
|
<div class="row g-2 align-items-end">
|
|
<div class="col-4 col-md-3"><label class="form-label small" for="softCap">Soft cap</label><input type="number" class="form-control form-control-sm" id="softCap" min="1"></div>
|
|
<div class="col-4 col-md-3"><label class="form-label small" for="hardCap">Hard cap</label><input type="number" class="form-control form-control-sm" id="hardCap" min="1"></div>
|
|
<div class="col-4 col-md-3"><label class="form-label small" for="spare">Spare instances</label><input type="number" class="form-control form-control-sm" id="spare" min="0"></div>
|
|
<div class="col-12 col-md-3 text-md-end">
|
|
{% if can.instances_manage %}<button type="button" class="btn btn-primary btn-sm" id="saveLimits">Save</button>{% endif %}
|
|
</div>
|
|
</div>
|
|
<div class="form-text" id="limitHelp"></div>
|
|
</div>
|
|
<h6 class="mt-3">Zones with their own limits</h6>
|
|
<table class="table table-sm align-middle mb-0">
|
|
<thead><tr><th>Zone</th><th class="text-end">Soft</th><th class="text-end">Hard</th><th class="text-end">Spare</th><th>Changed</th></tr></thead>
|
|
<tbody id="limitRows"></tbody>
|
|
</table>
|
|
<p class="small text-body-secondary mt-2 mb-0">Changes apply to instances started from now on; running ones take the new caps too, but a hard cap only as
|
|
high as they were started with.</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{% if can.world_reload %}
|
|
<div class="card mt-3" id="worldFilesCard">
|
|
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
|
<h5 class="mb-0">World files</h5>
|
|
<span class="small text-body-secondary" id="worldFilesWatch"></span>
|
|
</div>
|
|
<div class="card-body">
|
|
<p class="small text-body-secondary">The zone files each running world loaded (<code>.luz</code>, <code>.lvl</code>, <code>.lutriggers</code>, terrain,
|
|
navmesh). When one changes on disk, the master server replaces the instances that loaded the old version with new ones and moves their
|
|
players over. <strong>Reload</strong> does the same for every instance of a zone now. <strong>Properties are not moved</strong> (building in
|
|
progress isn't saved): an instance of a property on old files takes nobody new, its players are told an update is waiting, and it updates
|
|
once everyone has left. Files read from the client's packs are not watched.</p>
|
|
<div id="worldFilesZones"><p class="text-body-secondary mb-0">Waiting for the master server…</p></div>
|
|
</div>
|
|
</div>
|
|
{% endif %}
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(function () {
|
|
var nf = new Intl.NumberFormat();
|
|
var range = '24h', zone = 0, limits = { zones: [] }, byId = {};
|
|
var canEdit = DASH.can('instances_manage');
|
|
var COLOURS = ['var(--bs-primary)', 'var(--bs-success)', 'var(--bs-warning)', 'var(--bs-info)', 'var(--bs-danger)', 'var(--bs-purple, #6f42c1)', 'var(--bs-teal, #20c997)'];
|
|
|
|
function chart(svg, series, from, to, caps) {
|
|
var width = svg.clientWidth || 600, height = 200, left = 36, bottom = 20, top = 8;
|
|
var max = Math.max(2, caps.hard, Math.max.apply(null, [0].concat(series.map(function (s) { return Math.max.apply(null, [0].concat(s.points.map(function (p) { return p[1]; }))); }))));
|
|
var x = function (t) { return left + (t - from) / (to - from) * (width - left - 6); };
|
|
var y = function (v) { return top + (1 - v / max) * (height - top - bottom); };
|
|
var html = '';
|
|
[0, 0.5, 1].forEach(function (f) {
|
|
var v = Math.round(max * f);
|
|
html += '<line class="grid" x1="' + left + '" x2="' + width + '" y1="' + y(v) + '" y2="' + y(v) + '"/><text class="axis" x="' + (left - 4) + '" y="' + (y(v) + 4) + '" text-anchor="end">' + v + '</text>';
|
|
});
|
|
[['soft', 'var(--bs-warning)'], ['hard', 'var(--bs-danger)']].forEach(function (c) {
|
|
html += '<line class="cap" stroke="' + c[1] + '" x1="' + left + '" x2="' + width + '" y1="' + y(caps[c[0]]) + '" y2="' + y(caps[c[0]]) + '"><title>' + c[0] + ' cap ' + caps[c[0]] + '</title></line>';
|
|
});
|
|
[from, (from + to) / 2, to].forEach(function (t, i) {
|
|
var d = new Date(t * 1000);
|
|
var label = range === '24h' ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString([], { month: 'short', day: 'numeric' });
|
|
html += '<text class="axis" x="' + x(t) + '" y="' + (height - 4) + '" text-anchor="' + ['start', 'middle', 'end'][i] + '">' + esc(label) + '</text>';
|
|
});
|
|
series.forEach(function (s, i) {
|
|
var colour = COLOURS[i % COLOURS.length];
|
|
html += '<polyline class="line" stroke="' + colour + '" points="' + s.points.map(function (p) { return x(p[0]).toFixed(1) + ',' + y(p[1]).toFixed(1); }).join(' ') + '"/>';
|
|
if (s.points.length < 60) html += s.points.map(function (p) { return '<circle cx="' + x(p[0]).toFixed(1) + '" cy="' + y(p[1]).toFixed(1) + '" r="3" fill="' + colour + '"><title>' + esc(s.label + ', ' + new Date(p[0] * 1000).toLocaleString() + ': ' + p[1]) + '</title></circle>'; }).join('');
|
|
});
|
|
if (!series.length) html += '<text class="axis" x="' + (width / 2) + '" y="' + (height / 2) + '" text-anchor="middle">No samples for this zone</text>';
|
|
svg.setAttribute('viewBox', '0 0 ' + width + ' ' + height);
|
|
svg.innerHTML = html;
|
|
}
|
|
|
|
function loadZones() {
|
|
api.get('/api/instances/load?range=' + range).then(function (d) {
|
|
if (!d.success) return;
|
|
var list = d.zones.slice().sort(function (a, b) { return b.players - a.players; });
|
|
document.getElementById('zoneRows').innerHTML = list.map(function (z) {
|
|
var caps = byId[z.id];
|
|
var full = caps && z.busiest >= caps.soft;
|
|
return '<tr role="button" data-zone="' + z.id + '"><td>' + fmt.zone(z.id, z.name) + '</td><td class="text-end">' + nf.format(z.busiest) +
|
|
(full ? ' ' + fmt.badge('reached the soft cap', 'warning') : '') + '</td><td class="text-end">' + nf.format(z.instances) + '</td><td class="text-end">' + nf.format(z.players) + '</td></tr>';
|
|
}).join('') || '<tr><td colspan="4" class="text-body-secondary">No samples yet: they are taken once a minute while worlds run.</td></tr>';
|
|
// Open on the busiest zone rather than an empty chart
|
|
var busiest = list.filter(function (z) { return z.id !== 0; })[0]; // character select has no instances worth charting
|
|
if (!zone && busiest) pick(busiest.id);
|
|
});
|
|
}
|
|
|
|
function loadZone() {
|
|
if (!zone) return;
|
|
api.get('/api/instances/load?range=' + range + '&zone=' + zone).then(function (d) {
|
|
if (!d.success) return;
|
|
var series = {};
|
|
d.samples.forEach(function (s) {
|
|
var key = s.instance + '/' + s.clone;
|
|
(series[key] = series[key] || { label: 'Instance ' + s.instance + (s.clone ? ' (clone ' + s.clone + ')' : ''), points: [] }).points.push([s.time, s.players]);
|
|
});
|
|
var list = Object.keys(series).map(function (k) { return series[k]; });
|
|
var caps = byId[zone] || { soft: d.clientCaps.soft, hard: d.clientCaps.hard };
|
|
document.getElementById('zoneTitle').innerHTML = fmt.zone(d.zone.id, d.zone.name);
|
|
chart(document.getElementById('zoneChart'), list, d.from, d.to, caps);
|
|
document.getElementById('zoneLegend').innerHTML = list.map(function (s, i) {
|
|
return '<span class="me-3 text-nowrap"><span style="display:inline-block;width:.8rem;height:.8rem;background:' + COLOURS[i % COLOURS.length] + '"></span> ' + esc(s.label) + '</span>';
|
|
}).join('') + '<span class="text-body-secondary text-nowrap">Dashed: soft cap ' + esc(caps.soft) + ', hard cap ' + esc(caps.hard) + '</span>';
|
|
showEditor();
|
|
});
|
|
}
|
|
|
|
function showEditor() {
|
|
var z = byId[zone];
|
|
var editor = document.getElementById('limitEditor');
|
|
editor.classList.toggle('d-none', !z);
|
|
if (!z) return;
|
|
['softCap', 'hardCap', 'spare'].forEach(function (id) {
|
|
var input = document.getElementById(id);
|
|
input.max = id === 'spare' ? limits.maxSpare : limits.maxCap;
|
|
input.disabled = !canEdit;
|
|
});
|
|
document.getElementById('softCap').value = z.softCap === null ? '' : z.softCap;
|
|
document.getElementById('hardCap').value = z.hardCap === null ? '' : z.hardCap;
|
|
document.getElementById('softCap').placeholder = z.clientSoft;
|
|
document.getElementById('hardCap').placeholder = z.clientHard;
|
|
document.getElementById('spare').value = z.spare;
|
|
document.getElementById('limitHelp').textContent = 'The client\'s caps for ' + z.name + ': ' + z.clientSoft + ' / ' + z.clientHard + '. New instances take ' + z.soft + ' / ' + z.hard + '. Leave a cap empty for the client\'s.';
|
|
}
|
|
|
|
function loadLimits() {
|
|
return api.get('/api/instances/limits').then(function (d) {
|
|
if (!d.success) return;
|
|
limits = d;
|
|
byId = {};
|
|
d.zones.forEach(function (z) { byId[z.id] = z; });
|
|
if (!document.getElementById('zoneList').options.length) {
|
|
document.getElementById('zoneList').innerHTML = d.zones.map(function (z) { return '<option value="' + esc(z.name + ' (' + z.id + ')') + '">'; }).join('');
|
|
}
|
|
document.getElementById('limitRows').innerHTML = d.zones.filter(function (z) { return z.softCap !== null || z.hardCap !== null || z.spare; }).map(function (z) {
|
|
return '<tr role="button" data-zone="' + z.id + '"><td>' + fmt.zone(z.id, z.name) + '</td><td class="text-end">' + esc(z.soft) + '</td><td class="text-end">' + esc(z.hard) + '</td>' +
|
|
'<td class="text-end">' + esc(z.spare) + '</td><td class="small text-body-secondary">' + esc(z.updatedBy) + ' ' + esc(fmt.unix(z.updatedAt)) + '</td></tr>';
|
|
}).join('') || '<tr><td colspan="5" class="text-body-secondary">None: every zone uses the client\'s caps.</td></tr>';
|
|
showEditor();
|
|
});
|
|
}
|
|
|
|
function pick(id) {
|
|
zone = id;
|
|
loadZone();
|
|
}
|
|
|
|
document.addEventListener('click', function (e) {
|
|
var row = e.target.closest('[data-zone]');
|
|
if (row) pick(+row.dataset.zone);
|
|
});
|
|
document.getElementById('zonePick').addEventListener('change', function () {
|
|
var text = this.value.trim(), match = /\((\d+)\)$/.exec(text) || /^(\d+)$/.exec(text);
|
|
if (match && byId[match[1]]) pick(+match[1]);
|
|
});
|
|
var save = document.getElementById('saveLimits');
|
|
if (save) save.addEventListener('click', function () {
|
|
var num = function (id) { var v = document.getElementById(id).value.trim(); return v === '' ? null : +v; };
|
|
api.action('/api/instances/limits', { zone: zone, softCap: num('softCap'), hardCap: num('hardCap'), spare: num('spare') || 0 }).then(function (d) {
|
|
toast(d.message, 'success');
|
|
loadLimits().then(loadZone);
|
|
});
|
|
});
|
|
document.querySelectorAll('input[name="loadRange"]').forEach(function (r) {
|
|
r.addEventListener('change', function () { range = r.value; loadZones(); loadZone(); });
|
|
});
|
|
window.addEventListener('resize', Live.throttle(loadZone, 500));
|
|
if (window.Live) Live.on('zone_limits', Live.throttle(function () { loadLimits().then(loadZone); }, 500));
|
|
setInterval(function () { loadZones(); loadZone(); }, 60000);
|
|
loadLimits().then(loadZones);
|
|
})();
|
|
</script>
|
|
{% if can.world_reload %}
|
|
<script>
|
|
(function () {
|
|
var nf = new Intl.NumberFormat();
|
|
function size(bytes) {
|
|
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
|
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return nf.format(bytes) + ' B';
|
|
}
|
|
function name(path) { var i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); return i < 0 ? path : path.slice(i + 1); }
|
|
|
|
function show(status) {
|
|
var watch = document.getElementById('worldFilesWatch');
|
|
watch.textContent = !status.received ? '' : status.watching ? 'Checked every ' + status.watchSeconds + ' s' + (status.seamless ? ', seamless moves' : '') : 'Not watched (world_watch_seconds = 0)';
|
|
var box = document.getElementById('worldFilesZones');
|
|
if (!status.received) return;
|
|
if (!status.zones.length) { box.innerHTML = '<p class="text-body-secondary mb-0">No world has reported its files yet.</p>'; return; }
|
|
box.innerHTML = status.zones.map(function (z) {
|
|
var stale = z.instances.filter(function (i) { return i.stale; }).length;
|
|
var head = '<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-1"><h6 class="mb-0">' + fmt.zone(z.zone, z.zoneName) +
|
|
(z.changed ? ' ' + fmt.badge('changed on disk', 'warning') : '') + '</h6>' +
|
|
'<button type="button" class="btn btn-outline-primary btn-sm" data-reload-zone="' + esc(z.zone) + '">Reload</button></div>';
|
|
var instances = '<div class="small mb-1">' + z.instances.map(function (i) {
|
|
var badges = (i.stale ? ' ' + fmt.badge('old files', 'warning') : '') + (i.reloading ? ' ' + fmt.badge('being replaced', 'info') :
|
|
i.outdated ? ' ' + fmt.badge(i.clone ? 'updates when empty' : 'draining (old version)', 'secondary') : '');
|
|
return '<span class="me-3 text-nowrap">Instance ' + esc(i.instance) + (i.clone ? ' (clone ' + esc(i.clone) + ')' : '') + ', ' +
|
|
nf.format(i.players) + ' player(s)' + badges + '</span>';
|
|
}).join('') + '</div>';
|
|
var message = z.message ? '<div class="small text-body-secondary mb-1">' + esc(z.message) + '</div>' : '';
|
|
var rows = z.files.map(function (f) {
|
|
var badges = (f.changed ? fmt.badge('changed on disk', 'warning') + ' ' : '') + (f.missing ? fmt.badge('missing', 'danger') + ' ' : '') +
|
|
(f.packed ? fmt.badge('in a pack', 'secondary') + ' ' : '');
|
|
return '<tr><td class="text-nowrap">' + esc(f.kind) + '</td><td><span title="' + esc(f.path) + '">' + esc(name(f.path)) + '</span></td>' +
|
|
'<td class="text-end text-nowrap">' + esc(size(f.size)) + '</td><td><code class="small">' + esc(f.hash) + '</code></td><td>' + badges + '</td></tr>';
|
|
}).join('');
|
|
return '<div class="mb-3">' + head + instances + message + '<div class="table-responsive"><table class="table table-sm align-middle mb-0">' +
|
|
'<thead><tr><th>Kind</th><th>File</th><th class="text-end">Size</th><th>Hash</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div></div>';
|
|
}).join('');
|
|
}
|
|
|
|
function load() { api.get('/api/worlds/files').then(function (d) { if (d.success) show(d.worldFiles); }).catch(function () {}); }
|
|
|
|
document.getElementById('worldFilesZones').addEventListener('click', function (e) {
|
|
var button = e.target.closest('[data-reload-zone]');
|
|
if (!button) return;
|
|
if (!confirm('Reload this zone? Every instance of it is replaced with a new one on the files on disk now; players there are warned and moved over (a short loading screen).')) return;
|
|
api.action('/api/worlds/reload', { zone: +button.dataset.reloadZone }, 'Reload requested').then(load).catch(function () {});
|
|
});
|
|
if (window.Live) Live.onTopic('world_files', show);
|
|
load();
|
|
})();
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|