Move startPolling() before nv.addGraph calls so it fires immediately after DOM setup. This eliminates the race where nv.addGraph's async render loop could delay or silently swallow the polling start. Charts still initialize via nv.addGraph and have null guards in updateCharts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
200 lines
7.2 KiB
JavaScript
200 lines
7.2 KiB
JavaScript
/**
|
|
* Server info page — live dashboard with D3/NVD3 charts.
|
|
*/
|
|
function ServerInfoPage(params, container) {
|
|
var DATA_LIMIT = 100;
|
|
var recordedData = [];
|
|
|
|
var html =
|
|
'<div id="ServerInfoController">' +
|
|
'<div class="row">' +
|
|
'<div class="col-sm-12"><h2 id="si-hostname">Server Info</h2></div>' +
|
|
'<div id="si-fields" class="col-sm-12"><span class="text-white-50">Loading…</span></div>' +
|
|
'</div>' +
|
|
'<div class="row">' +
|
|
'<div class="col-sm-12">' +
|
|
'<div class="card mb-3">' +
|
|
'<div class="card-header">' +
|
|
'<button class="btn btn-sm btn-outline-primary float-end" role="button" data-bs-toggle="collapse" data-bs-target="#performanceCollapse">toggle</button>' +
|
|
'<h4 class="card-title mb-0">Server Performance</h4>' +
|
|
'</div>' +
|
|
'<div id="performanceCollapse" class="collapse show">' +
|
|
'<div class="card-body"><svg id="chart-performance" style="height:200px;width:100%"></svg></div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="card mb-3">' +
|
|
'<div class="card-header">' +
|
|
'<button class="btn btn-sm btn-outline-primary float-end" role="button" data-bs-toggle="collapse" data-bs-target="#networkCollapse">toggle</button>' +
|
|
'<h4 class="card-title mb-0">Server Networking</h4>' +
|
|
'</div>' +
|
|
'<div id="networkCollapse" class="collapse show">' +
|
|
'<div class="card-body"><svg id="chart-network" style="height:250px;width:100%"></svg></div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div class="card mb-3">' +
|
|
'<div class="card-header">' +
|
|
'<button class="btn btn-sm btn-outline-primary float-end" role="button" data-bs-toggle="collapse" data-bs-target="#playersCollapse">toggle</button>' +
|
|
'<h4 class="card-title mb-0">Players</h4>' +
|
|
'</div>' +
|
|
'<div id="playersCollapse" class="collapse show">' +
|
|
'<div class="card-body"><svg id="chart-players" style="height:250px;width:100%"></svg></div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
container.innerHTML = html;
|
|
|
|
// Start data polling immediately — charts will render when ready
|
|
if (Rcon.isConnected()) startPolling();
|
|
else {
|
|
var _c = function () { startPolling(); Rcon.off('connected', _c); };
|
|
Rcon.on('connected', _c);
|
|
}
|
|
|
|
// ---- NVD3 Charts ----
|
|
nv.addGraph(function () {
|
|
var perfChart = nv.models.multiChart()
|
|
.margin({ top: 30, right: 75, bottom: 40, left: 75 })
|
|
.color(['darkred', 'yellowgreen']);
|
|
perfChart.yAxis1.axisLabel('Framerate').tickFormat(d3.format(',d'));
|
|
perfChart.yAxis2.axisLabel('Entities').tickFormat(d3.format(',d'));
|
|
perfChart.xAxis.axisLabel('Time').tickFormat(function (d) { return d3.time.format('%H:%M:%S')(new Date(d)); });
|
|
perfChart.lines1.duration(0);
|
|
perfChart.lines2.duration(0);
|
|
|
|
d3.select('#chart-performance').datum([
|
|
{ key: 'Framerate', type: 'line', yAxis: 1, values: [] },
|
|
{ key: 'Entities', type: 'line', yAxis: 2, values: [] }
|
|
]).call(perfChart);
|
|
|
|
window._perfChart = perfChart;
|
|
});
|
|
|
|
nv.addGraph(function () {
|
|
var netChart = nv.models.stackedAreaChart()
|
|
.margin({ top: 0, right: 75, bottom: 40, left: 75 });
|
|
netChart.yAxis.axisLabel('Network').tickFormat(function (bytes) {
|
|
var fmt = d3.format('.0f');
|
|
if (bytes < 1024) return fmt(bytes) + 'B';
|
|
if (bytes < 1048576) return fmt(bytes / 1024) + 'kB';
|
|
if (bytes < 1073741824) return fmt(bytes / 1048576) + 'MB';
|
|
return fmt(bytes / 1073741824) + 'GB';
|
|
});
|
|
netChart.xAxis.axisLabel('Time').tickFormat(function (d) { return d3.time.format('%H:%M:%S')(new Date(d)); });
|
|
|
|
d3.select('#chart-network').datum([
|
|
{ key: 'IN', values: [] },
|
|
{ key: 'OUT', values: [] }
|
|
]).call(netChart);
|
|
|
|
window._netChart = netChart;
|
|
});
|
|
|
|
nv.addGraph(function () {
|
|
var playersChart = nv.models.pieChart()
|
|
.x(function (d) { return d.key; })
|
|
.y(function (d) { return d.y; })
|
|
.showLabels(true)
|
|
.donut(true)
|
|
.duration(500)
|
|
.height(250)
|
|
.margin({ top: 0, right: 75, bottom: 0, left: 75 });
|
|
playersChart.pie.startAngle(function (d) { return d.startAngle / 2 - Math.PI / 2; });
|
|
playersChart.pie.endAngle(function (d) { return d.endAngle / 2 - Math.PI / 2; });
|
|
|
|
d3.select('#chart-players').datum([]).call(playersChart);
|
|
|
|
window._playersChart = playersChart;
|
|
});
|
|
|
|
// ---- Data polling ----
|
|
var pollTimer = null;
|
|
|
|
function refresh() {
|
|
Rcon.request('serverinfo', function (msg) {
|
|
updateData(JSON.parse(msg.Message));
|
|
});
|
|
}
|
|
|
|
function updateData(data) {
|
|
// Guard: element may be gone if navigated away
|
|
var fieldsEl = document.getElementById('si-fields');
|
|
if (!fieldsEl) return;
|
|
|
|
// Update text fields
|
|
var fieldsHtml = '';
|
|
for (var key in data) {
|
|
if (data.hasOwnProperty(key)) {
|
|
var val = data[key];
|
|
if (typeof val === 'number') val = formatNumber(val);
|
|
fieldsHtml += '<div class="col-sm-4"><p><span class="text-white-50">' + key + ':</span> <span>' + val + '</span></p></div>';
|
|
}
|
|
}
|
|
fieldsEl.innerHTML = fieldsHtml;
|
|
if (data.Hostname) {
|
|
var hn = document.getElementById('si-hostname');
|
|
if (hn) hn.textContent = data.Hostname;
|
|
}
|
|
|
|
// Collect chart data
|
|
recordedData.push({ ts: Date.now(), data: data });
|
|
if (recordedData.length > DATA_LIMIT) {
|
|
recordedData = recordedData.slice(recordedData.length - DATA_LIMIT);
|
|
}
|
|
updateCharts(data);
|
|
}
|
|
|
|
function updateCharts(data) {
|
|
// Guard: SVG elements may be gone if navigated away
|
|
// Players pie chart
|
|
if (window._playersChart) {
|
|
var pc = d3.select('#chart-players');
|
|
if (!pc.empty()) {
|
|
pc.datum([
|
|
{ key: 'Queued', y: data.Queued || 0 },
|
|
{ key: 'Joining', y: data.Joining || 0 },
|
|
{ key: 'Players', y: data.Players || 0 },
|
|
{ key: 'Free', y: (data.MaxPlayers || 0) - ((data.Joining || 0) + (data.Players || 0)) }
|
|
]).call(window._playersChart);
|
|
}
|
|
}
|
|
|
|
// Performance chart
|
|
var fpsVals = [], entVals = [], netInVals = [], netOutVals = [];
|
|
for (var i = 0; i < recordedData.length; i++) {
|
|
var r = recordedData[i];
|
|
fpsVals.push({ x: r.ts, y: r.data.Framerate || 0 });
|
|
entVals.push({ x: r.ts, y: r.data.EntityCount || 0 });
|
|
netInVals.push({ x: r.ts, y: r.data.NetworkIn || 0 });
|
|
netOutVals.push({ x: r.ts, y: r.data.NetworkOut || 0 });
|
|
}
|
|
|
|
if (window._perfChart) {
|
|
d3.select('#chart-performance').datum([
|
|
{ key: 'Framerate', type: 'line', yAxis: 1, values: fpsVals },
|
|
{ key: 'Entities', type: 'line', yAxis: 2, values: entVals }
|
|
]).call(window._perfChart);
|
|
}
|
|
|
|
if (window._netChart) {
|
|
d3.select('#chart-network').datum([
|
|
{ key: 'IN', values: netInVals },
|
|
{ key: 'OUT', values: netOutVals }
|
|
]).call(window._netChart);
|
|
}
|
|
}
|
|
|
|
function startPolling() {
|
|
refresh();
|
|
pollTimer = setInterval(refresh, 1000);
|
|
}
|
|
|
|
return function cleanup() {
|
|
if (pollTimer) clearInterval(pollTimer);
|
|
window._perfChart = null;
|
|
window._netChart = null;
|
|
window._playersChart = null;
|
|
};
|
|
}
|