mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
feat(dashboard): Database Usage page
Totals and disk free, a size chart over a day, week or month, the fastest-growing tables and every table with sorting. The time chart takes a timeLabel for ranges longer than a day. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -450,6 +450,7 @@ void RegisterDashboardRoutes() {
|
||||
SimplePage("/diagnostics", Perm("health_view"), "diagnostics.jinja2", "diagnostics", "Packets, bytes and HTTP requests per second of every server");
|
||||
SimplePage("/network", Perm("health_view"), "network.jinja2", "network", "Live diagram of the traffic between players, the servers and web clients, and the traffic of each connection");
|
||||
SimplePage("/performance", Perm("health_view"), "performance.jinja2", "performance", "Main loop frame times and phases of every server, slow frames with their scopes, and profiling sessions with a flame graph");
|
||||
SimplePage("/database_usage", Perm("health_view"), "database_usage.jinja2", "database_usage", "Database size over time, the fastest-growing tables, every table's size and rows, and free disk space");
|
||||
SimplePage("/players", Perm("players_view"), "players.jinja2", "players", "Who is online, with kick, rescue and teleport");
|
||||
SimplePage("/backups", Perm("backups"), "backups.jinja2", "backups", "Database backups");
|
||||
// The GM level tabs need permissions_manage, the grants tab grants_manage
|
||||
|
||||
@@ -138,7 +138,8 @@
|
||||
|
||||
/**
|
||||
* A time chart: lines ([{label, color, values, dash}]) or a stack of areas (stacked: true), with a crosshair and
|
||||
* every series' value in the tooltip; `threshold` draws a dashed line (the slow frame threshold)
|
||||
* every series' value in the tooltip; `threshold` draws a dashed line (the slow frame threshold); `timeLabel(t, long)`
|
||||
* names a time (default: the time of day)
|
||||
*/
|
||||
function timeChart(svg, times, series, options) {
|
||||
options = options || {};
|
||||
@@ -170,7 +171,7 @@
|
||||
[0, Math.floor((n - 1) / 2), n - 1].forEach(function (i) {
|
||||
if (i < 0 || !times.length) return;
|
||||
el('text', { x: X(i), y: height - 4, 'text-anchor': i === 0 ? 'start' : i === n - 1 ? 'end' : 'middle', class: 'axis' }, svg).textContent =
|
||||
new Date(times[i] * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: n <= 400 ? '2-digit' : undefined });
|
||||
(options.timeLabel ? options.timeLabel(times[i]) : new Date(times[i] * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: n <= 400 ? '2-digit' : undefined }));
|
||||
});
|
||||
if (options.stacked) {
|
||||
var base = new Array(n).fill(0);
|
||||
@@ -213,7 +214,7 @@
|
||||
return '<div><span class="pf-key" style="background:' + s.color + '"></span>' + esc(s.label) + ': ' + (options.format ? options.format(s.values[i]) : esc(s.values[i])) + '</div>';
|
||||
});
|
||||
if (options.extra) rows = rows.concat(options.extra(i));
|
||||
showTip(box, '<div class="fw-semibold">' + esc(new Date(times[i] * 1000).toLocaleTimeString()) + '</div>' + rows.join(''), e.clientX - box.getBoundingClientRect().left, e.clientY - box.getBoundingClientRect().top);
|
||||
showTip(box, '<div class="fw-semibold">' + esc(options.timeLabel ? options.timeLabel(times[i], true) : new Date(times[i] * 1000).toLocaleTimeString()) + '</div>' + rows.join(''), e.clientX - box.getBoundingClientRect().left, e.clientY - box.getBoundingClientRect().top);
|
||||
});
|
||||
hit.addEventListener('mouseleave', function () { cross.setAttribute('visibility', 'hidden'); hideTip(box); });
|
||||
}
|
||||
|
||||
173
dDashboardServer/templates/database_usage.jinja2
Normal file
173
dDashboardServer/templates/database_usage.jinja2
Normal file
@@ -0,0 +1,173 @@
|
||||
{% extends "base.jinja2" %}
|
||||
|
||||
{% block title %}Database Usage - DarkflameServer{% endblock %}
|
||||
|
||||
{% block css %}
|
||||
<style>
|
||||
/* The Diagnostics page's series colours */
|
||||
:root, [data-bs-theme=light] { --tr-1: #0d6efd; --tr-2: #fd7e14; --tr-3: #20c997; }
|
||||
[data-bs-theme=dark] { --tr-1: #3d8bfd; --tr-2: #ca6510; --tr-3: #1aa179; }
|
||||
.pf-chart-box { position: relative; height: 240px; }
|
||||
.pf-chart { width: 100%; height: 100%; display: block; position: absolute; inset: 0; }
|
||||
.pf-chart .grid { stroke: var(--bs-border-color); stroke-width: 1; }
|
||||
.pf-chart .axis { fill: var(--bs-secondary-color); font-size: 11px; }
|
||||
.pf-chart .line { fill: none; stroke-width: 2; stroke-linejoin: round; }
|
||||
.pf-chart .area { stroke: var(--bs-body-bg); stroke-width: 1; }
|
||||
.pf-chart .cross { stroke: var(--bs-secondary-color); stroke-width: 1; stroke-dasharray: 3 3; }
|
||||
.pf-tip { position: absolute; pointer-events: none; z-index: 5; background: var(--bs-body-bg); border: 1px solid var(--bs-border-color); border-radius: .375rem;
|
||||
padding: .35rem .5rem; font-size: .75rem; white-space: nowrap; box-shadow: var(--bs-box-shadow-sm); }
|
||||
.pf-key { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: middle; margin-right: 4px; }
|
||||
.pf-legend { font-size: .75rem; color: var(--bs-secondary-color); }
|
||||
.du-table td, .du-table th { font-variant-numeric: tabular-nums; }
|
||||
.du-table th[data-sort] { cursor: pointer; user-select: none; white-space: nowrap; }
|
||||
.du-stat { font-size: 1.4rem; font-weight: 600; font-variant-numeric: tabular-nums; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex flex-wrap justify-content-between align-items-end gap-2 mb-3">
|
||||
<div>
|
||||
<h2 class="mb-1">Database Usage</h2>
|
||||
<p class="text-body-secondary mb-0 small">How much room the game database takes, measured by the <a href="/tasks">Database usage task</a> (hourly by default) and kept for
|
||||
<code>database_usage_days</code>. Disk warning: <code>database_disk_warn_percent</code> (<a href="/settings">Settings</a>).</p>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-2 align-items-center">
|
||||
{% if can.tasks_manage %}<button class="btn btn-sm btn-outline-primary" id="measureNow">Measure now</button>{% endif %}
|
||||
<div class="btn-group btn-group-sm text-nowrap" role="group" aria-label="Range">
|
||||
<input type="radio" class="btn-check" name="duRange" id="rangeDay" value="day" data-pref="database_usage.range"><label class="btn btn-outline-secondary" for="rangeDay">Day</label>
|
||||
<input type="radio" class="btn-check" name="duRange" id="rangeWeek" value="week" checked data-pref="database_usage.range"><label class="btn btn-outline-secondary" for="rangeWeek">Week</label>
|
||||
<input type="radio" class="btn-check" name="duRange" id="rangeMonth" value="month" data-pref="database_usage.range"><label class="btn btn-outline-secondary" for="rangeMonth">Month</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning d-none" id="diskWarning"></div>
|
||||
<div class="alert alert-info d-none" id="noSamples">No measurements yet. The Database usage task runs every hour{% if can.tasks_manage %}, or use Measure now{% endif %}.</div>
|
||||
|
||||
<div class="row g-3 mb-3" id="stats">
|
||||
<div class="col-6 col-lg"><div class="card h-100"><div class="card-body py-2"><div class="small text-body-secondary">Database</div><div class="du-stat" id="statTotal">-</div><div class="small text-body-secondary" id="statGrowth"></div></div></div></div>
|
||||
<div class="col-6 col-lg"><div class="card h-100"><div class="card-body py-2"><div class="small text-body-secondary">Data / indexes</div><div class="du-stat" id="statData">-</div><div class="small text-body-secondary" id="statFree"></div></div></div></div>
|
||||
<div class="col-6 col-lg"><div class="card h-100"><div class="card-body py-2"><div class="small text-body-secondary">Rows</div><div class="du-stat" id="statRows">-</div><div class="small text-body-secondary" id="statRowsNote"></div></div></div></div>
|
||||
<div class="col-6 col-lg"><div class="card h-100"><div class="card-body py-2"><div class="small text-body-secondary">Disk free</div><div class="du-stat" id="statDisk">-</div><div class="small text-body-secondary text-truncate" id="statDiskNote"></div></div></div></div>
|
||||
</div>
|
||||
|
||||
<div class="row g-3">
|
||||
<div class="col-xl-8 d-flex flex-column gap-3">
|
||||
<div class="card"><div class="card-header py-2 d-flex justify-content-between flex-wrap gap-1"><h5 class="mb-0 fs-6">Size</h5><span class="pf-legend" id="legend"></span></div>
|
||||
<div class="card-body p-2 pf-chart-box"><svg class="pf-chart" id="chartSize" role="img" aria-label="Database size over time: data and indexes"></svg></div></div>
|
||||
<div class="card"><div class="card-header py-2"><h5 class="mb-0 fs-6">Tables <span class="text-body-secondary fw-normal small" id="tablesCaption"></span></h5></div>
|
||||
<div class="card-body p-0"><div class="table-responsive"><table class="table table-sm table-hover mb-0 du-table small">
|
||||
<thead><tr><th data-sort="name">Table</th><th data-sort="total_bytes" class="text-end">Total</th><th data-sort="data_bytes" class="text-end">Data</th><th data-sort="index_bytes" class="text-end">Indexes</th>
|
||||
<th data-sort="rows" class="text-end">Rows</th><th data-sort="growth_bytes" class="text-end">Growth</th><th data-sort="growth_rows" class="text-end">New rows</th></tr></thead>
|
||||
<tbody id="tables"></tbody></table></div></div></div>
|
||||
</div>
|
||||
<div class="col-xl-4">
|
||||
<div class="card"><div class="card-header py-2"><h5 class="mb-0 fs-6">Fastest-growing <span class="text-body-secondary fw-normal small" id="fastestCaption"></span></h5></div><div class="card-body p-0" id="fastest"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/js/perf-charts.js"></script>
|
||||
<script>
|
||||
(function () {
|
||||
var C = window.PerfCharts, nf = new Intl.NumberFormat();
|
||||
var range = (document.querySelector('input[name="duRange"]:checked') || {}).value || 'week';
|
||||
var data = null, sortKey = 'total_bytes', sortDesc = true;
|
||||
var RANGE_NAMES = { day: 'the last day', week: 'the last week', month: 'the last 30 days' };
|
||||
|
||||
function signedBytes(n) { return (n < 0 ? '-' : n > 0 ? '+' : '') + fmt.bytes(Math.abs(n)); }
|
||||
function signedNumber(n) { return (n > 0 ? '+' : '') + nf.format(n); }
|
||||
function timeLabel(t, long) {
|
||||
var d = new Date(t * 1000);
|
||||
return long || range !== 'day' ? d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' }) : d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function load() {
|
||||
api.get('/api/database/usage?range=' + encodeURIComponent(range)).then(function (d) {
|
||||
if (!d.success) return;
|
||||
data = d;
|
||||
draw();
|
||||
}).catch(function () {});
|
||||
}
|
||||
|
||||
function drawTables() {
|
||||
var rows = (data.tables || []).slice().sort(function (a, b) {
|
||||
var x = a[sortKey], y = b[sortKey];
|
||||
var c = typeof x === 'string' ? x.localeCompare(y) : x - y;
|
||||
return sortDesc ? -c : c;
|
||||
});
|
||||
document.getElementById('tables').innerHTML = rows.map(function (t) {
|
||||
return '<tr><td><code>' + esc(t.name) + '</code></td><td class="text-end">' + fmt.bytes(t.total_bytes) + '</td><td class="text-end">' + fmt.bytes(t.data_bytes) +
|
||||
'</td><td class="text-end">' + fmt.bytes(t.index_bytes) + '</td><td class="text-end">' + nf.format(t.rows) + '</td><td class="text-end">' + signedBytes(t.growth_bytes) +
|
||||
'</td><td class="text-end">' + signedNumber(t.growth_rows) + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="7" class="text-body-secondary">Nothing yet</td></tr>';
|
||||
document.querySelectorAll('.du-table th[data-sort]').forEach(function (th) {
|
||||
th.textContent = th.textContent.replace(/ [▲▼]$/, '') + (th.dataset.sort === sortKey ? (sortDesc ? ' ▼' : ' ▲') : '');
|
||||
});
|
||||
}
|
||||
|
||||
function draw() {
|
||||
var latest = data.latest, first = data.first;
|
||||
document.getElementById('noSamples').classList.toggle('d-none', !!latest);
|
||||
var warning = document.getElementById('diskWarning');
|
||||
warning.classList.toggle('d-none', !(latest && latest.disk.low));
|
||||
if (latest && latest.disk.low) {
|
||||
warning.textContent = 'Low disk space: ' + fmt.bytes(latest.disk.free) + ' (' + latest.disk.free_percent.toFixed(1) + '%) free on the database\'s disk, under the ' + data.warn_percent + '% warning (database_disk_warn_percent).';
|
||||
}
|
||||
document.getElementById('statTotal').textContent = latest ? fmt.bytes(latest.total_bytes) : '-';
|
||||
document.getElementById('statGrowth').textContent = latest && first && latest.time !== first.time ? signedBytes(latest.total_bytes - first.total_bytes) + ' over ' + RANGE_NAMES[data.range] : '';
|
||||
document.getElementById('statData').textContent = latest ? fmt.bytes(latest.data_bytes) + ' / ' + fmt.bytes(latest.index_bytes) : '-';
|
||||
document.getElementById('statFree').textContent = latest && latest.free_bytes ? fmt.bytes(latest.free_bytes) + ' allocated but unused' : '';
|
||||
document.getElementById('statRows').textContent = latest ? nf.format(latest.rows) : '-';
|
||||
document.getElementById('statRowsNote').textContent = data.backend === 'mysql' ? 'the server\'s estimate' : '';
|
||||
var disk = latest && latest.disk;
|
||||
document.getElementById('statDisk').textContent = disk && disk.known ? fmt.bytes(disk.free) : '-';
|
||||
document.getElementById('statDisk').classList.toggle('text-danger', !!(disk && disk.low));
|
||||
document.getElementById('statDiskNote').textContent = disk && disk.known ? disk.free_percent.toFixed(1) + '% of ' + fmt.bytes(disk.total) + (data.disk_path ? ' (' + data.disk_path + ')' : '')
|
||||
: 'not known: the database is on another machine (database_disk_path)';
|
||||
document.getElementById('statDiskNote').title = data.disk_path || '';
|
||||
if (!data.sizes_known) document.getElementById('statFree').textContent = 'SQLite has no dbstat table: table sizes are unknown';
|
||||
|
||||
var s = data.series;
|
||||
var series = [
|
||||
{ label: 'Data', color: 'var(--tr-1)', values: s.data },
|
||||
{ label: 'Indexes', color: 'var(--tr-2)', values: s.index },
|
||||
{ label: 'Unused', color: 'var(--tr-3)', values: s.free },
|
||||
];
|
||||
document.getElementById('legend').innerHTML = series.map(function (x) { return '<span class="me-2 text-nowrap"><span class="pf-key" style="background:' + x.color + '"></span>' + x.label + '</span>'; }).join('');
|
||||
C.timeChart(document.getElementById('chartSize'), s.times, series, {
|
||||
stacked: true, format: function (v) { return fmt.bytes(v); }, timeLabel: timeLabel,
|
||||
extra: function (i) { return ['<div>Rows: ' + nf.format(s.rows[i]) + '</div>'].concat(s.disk_free[i] != null ? ['<div>Disk free: ' + fmt.bytes(s.disk_free[i]) + '</div>'] : []); },
|
||||
});
|
||||
|
||||
document.getElementById('tablesCaption').textContent = latest ? '(at ' + timeLabel(latest.time, true) + '; growth over ' + RANGE_NAMES[data.range] + ', click a heading to sort)' : '';
|
||||
drawTables();
|
||||
document.getElementById('fastestCaption').textContent = '(' + RANGE_NAMES[data.range] + ')';
|
||||
document.getElementById('fastest').innerHTML = '<div class="table-responsive"><table class="table table-sm mb-0 du-table small"><thead><tr><th>Table</th><th class="text-end">Growth</th><th class="text-end">Per day</th><th class="text-end">New rows</th></tr></thead><tbody>' +
|
||||
(data.fastest.map(function (t) {
|
||||
return '<tr><td><code>' + esc(t.name) + '</code></td><td class="text-end">' + signedBytes(t.growth_bytes) + '</td><td class="text-end">' + signedBytes(Math.round(t.growth_per_day)) +
|
||||
'</td><td class="text-end">' + signedNumber(t.growth_rows) + '</td></tr>';
|
||||
}).join('') || '<tr><td colspan="4" class="text-body-secondary">No table grew' + (latest && first && latest.time !== first.time ? '' : ' (needs two measurements in the range)') + '</td></tr>') + '</tbody></table></div>';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.du-table th[data-sort]').forEach(function (th) {
|
||||
th.onclick = function () {
|
||||
if (sortKey === th.dataset.sort) sortDesc = !sortDesc; else { sortKey = th.dataset.sort; sortDesc = sortKey !== 'name'; }
|
||||
if (data) drawTables();
|
||||
};
|
||||
});
|
||||
document.querySelectorAll('input[name="duRange"]').forEach(function (r) { r.onchange = function () { range = r.value; load(); }; });
|
||||
var measure = document.getElementById('measureNow');
|
||||
if (measure) measure.onclick = function () {
|
||||
measure.disabled = true;
|
||||
api.action('/api/tasks/database_usage/run', {}, 'Measuring; the page updates when it is done').then(function () {
|
||||
setTimeout(function () { measure.disabled = false; load(); }, 5000);
|
||||
}).catch(function () { measure.disabled = false; });
|
||||
};
|
||||
window.addEventListener('resize', function () { if (data) draw(); });
|
||||
load();
|
||||
setInterval(function () { if (!document.hidden) load(); }, 60000);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -105,7 +105,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% set logsPages = ["health", "instances", "diagnostics", "network", "performance", "activity_log", "command_log", "audit_log", "system_log", "client_sysinfo"] %}
|
||||
{% set logsPages = ["health", "instances", "diagnostics", "network", "performance", "database_usage", "activity_log", "command_log", "audit_log", "system_log", "client_sysinfo"] %}
|
||||
{% if can.health_view or can.logs_activity or can.logs_command or can.logs_audit or can.logs_system or can.client_sysinfo %}
|
||||
<div class="nav-group">
|
||||
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-logs" aria-expanded="{% if current_page in logsPages %}true{% else %}false{% endif %}" aria-controls="nav-logs">
|
||||
@@ -113,7 +113,7 @@
|
||||
</button>
|
||||
<div class="collapse{% if current_page in logsPages %} show{% endif %}" id="nav-logs">
|
||||
<div class="list-group list-group-flush">
|
||||
{% if can.health_view %}<a href="/health" class="list-group-item list-group-item-action{% if current_page == "health" %} active{% endif %}">Server Health</a><a href="/instances" class="list-group-item list-group-item-action{% if current_page == "instances" %} active{% endif %}">Instance Load</a><a href="/diagnostics" class="list-group-item list-group-item-action{% if current_page == "diagnostics" %} active{% endif %}">Diagnostics</a><a href="/network" class="list-group-item list-group-item-action{% if current_page == "network" %} active{% endif %}">Network</a><a href="/performance" class="list-group-item list-group-item-action{% if current_page == "performance" %} active{% endif %}">Performance</a>{% endif %}
|
||||
{% if can.health_view %}<a href="/health" class="list-group-item list-group-item-action{% if current_page == "health" %} active{% endif %}">Server Health</a><a href="/instances" class="list-group-item list-group-item-action{% if current_page == "instances" %} active{% endif %}">Instance Load</a><a href="/diagnostics" class="list-group-item list-group-item-action{% if current_page == "diagnostics" %} active{% endif %}">Diagnostics</a><a href="/network" class="list-group-item list-group-item-action{% if current_page == "network" %} active{% endif %}">Network</a><a href="/performance" class="list-group-item list-group-item-action{% if current_page == "performance" %} active{% endif %}">Performance</a><a href="/database_usage" class="list-group-item list-group-item-action{% if current_page == "database_usage" %} active{% endif %}">Database Usage</a>{% endif %}
|
||||
{% if can.logs_activity %}<a href="/activity_log" class="list-group-item list-group-item-action{% if current_page == "activity_log" %} active{% endif %}">Activity Log</a>{% endif %}
|
||||
{% if can.logs_command %}<a href="/command_log" class="list-group-item list-group-item-action{% if current_page == "command_log" %} active{% endif %}">Command Log</a>{% endif %}
|
||||
{% if can.logs_audit %}<a href="/audit_log" class="list-group-item list-group-item-action{% if current_page == "audit_log" %} active{% endif %}">Audit Log</a>{% endif %}
|
||||
|
||||
@@ -911,6 +911,22 @@ The built-in view shows only instrumented scopes. For a native deep dive:
|
||||
- Or a sampling profiler on the server process: on Linux `perf record -g -p <pid>` then `perf script` into a flame graph
|
||||
tool; any native profiler on Windows and macOS. The dashboard doesn't run native profilers itself.
|
||||
|
||||
### Database usage
|
||||
|
||||
**Database Usage** (`/database_usage`, `health_view`) shows how much room the game database takes: its size, data and
|
||||
indexes, rows and the free space on its disk; a size chart over the last day, week or 30 days; the fastest-growing tables
|
||||
over that range; and every table's size, rows and growth (click a heading to sort).
|
||||
|
||||
- The **Database usage** task (Tasks page, `0 * * * *` by default; **Measure now** on the page with `tasks_manage`) measures
|
||||
on the background connection: MySQL/MariaDB from `information_schema.TABLES` (rows are the server's estimate; on MySQL 8
|
||||
the session asks for fresh statistics), SQLite from its page counts and the `dbstat` table (rows counted).
|
||||
- Samples go to `database_usage` and `database_usage_tables`, kept for `database_usage_days` (365; pruned by Log pruning).
|
||||
- Disk free: of `database_disk_path` when set, else the SQLite file's disk, or the MySQL data directory (`@@datadir`) when
|
||||
`mysql_host` is this machine; otherwise not shown. Under `database_disk_warn_percent` (10; 0 never) the page warns and
|
||||
a "server" alert goes to subscribed webhooks once (again after it recovers and drops again).
|
||||
- Settings: Settings > Dashboard > Database usage, and Data retention.
|
||||
- `GET /api/database/usage?range=day|week|month`.
|
||||
|
||||
### Logs and crash dumps
|
||||
|
||||
**System Log** (GM 8+, `logs_system`) shows the end of any server's log file (the newest by default; pick an older one
|
||||
|
||||
Reference in New Issue
Block a user