nv.addGraph uses setTimeout internally, so chart models may not exist yet when the first WebSocket response arrives. Moved startPolling() into the last nv.addGraph callback to guarantee charts are ready before data flows. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
197 lines
7.2 KiB
JavaScript
197 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;
|
|
|
|
// ---- 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;
|
|
|
|
// All charts ready — start polling data
|
|
if (Rcon.isConnected()) startPolling();
|
|
else { var _c = function () { startPolling(); Rcon.off('connected', _c); }; Rcon.on('connected', _c); }
|
|
});
|
|
|
|
// ---- 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;
|
|
};
|
|
}
|