feat(dashboard): show the UGC server on the home page and Server Health

Server Status gets a UGC row while master starts it, and staff with
health_view a UGC Server card: up time, waiting/made/failed, busy workers and
storage, linking to /ugc. Server Health adds UGC to the uptime history and a
Servers table with every server's process, memory, CPU and last report.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 09:39:21 -05:00
parent e669a4fa7e
commit 5692e43381
5 changed files with 128 additions and 7 deletions

View File

@@ -81,6 +81,12 @@ namespace {
pid = ServerState::g_UgcPid;
}
out["pid"] = pid;
// Up since its process started when this machine runs it (the dashboard may have started after it)
if (pid && out.value("online", false)) {
for (const auto& process : ServerRoutes::Processes()) {
if (process.pid == pid && process.startedAt) out["since"] = process.startedAt;
}
}
// Its traffic report (every few seconds, via master) carries its workers, totals and storage
const auto traffic = Traffic::Server("ugc");
out["gauges"] = traffic.value("gauges", nlohmann::json::object());

View File

@@ -123,6 +123,21 @@
// Formatting helpers used by table renderers
window.fmt = {
unix: function (ts) { return ts ? new Date(ts * 1000).toLocaleString() : '-'; },
// 1536 -> "1.5 KB"
bytes: function (n) {
n = Number(n) || 0;
var units = ['B', 'KB', 'MB', 'GB', 'TB'], i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return (i === 0 ? n : n.toFixed(n < 10 ? 1 : 0)) + ' ' + units[i];
},
// Seconds -> "3d 4h", "2h 5m", "7m", "12s"
duration: function (s) {
s = Math.max(0, Math.floor(Number(s) || 0));
var d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
if (d) return d + 'd ' + h + 'h';
if (h) return h + 'h ' + m + 'm';
return m ? m + 'm' : s + 's';
},
date: function (s) {
if (!s) return '-';
var d = new Date(String(s).replace(' ', 'T') + (String(s).indexOf('Z') === -1 && String(s).indexOf('T') === -1 ? 'Z' : ''));

View File

@@ -31,7 +31,12 @@
<div class="col-lg-6"><div class="card"><div class="card-header"><h5 class="mb-0 fs-6">Players online (highest)</h5></div><div class="card-body"><svg class="health-chart" id="chartPlayers" role="img" aria-label="Players online over time"></svg></div></div></div>
<div class="col-lg-6"><div class="card"><div class="card-header"><h5 class="mb-0 fs-6">Worlds running</h5></div><div class="card-body"><svg class="health-chart" id="chartWorlds" role="img" aria-label="Worlds running over time"></svg></div></div></div>
<div class="col-lg-6"><div class="card"><div class="card-header"><h5 class="mb-0 fs-6">Memory (all servers)</h5></div><div class="card-body"><svg class="health-chart" id="chartMemory" role="img" aria-label="Memory over time"></svg></div></div></div>
<div class="col-lg-6"><div class="card"><div class="card-header"><h5 class="mb-0 fs-6">Auth and chat servers</h5></div><div class="card-body" id="uptime"></div></div></div>
<div class="col-lg-6"><div class="card"><div class="card-header"><h5 class="mb-0 fs-6">Auth, chat and UGC servers</h5></div><div class="card-body" id="uptime"></div></div></div>
</div>
<div class="card mt-3">
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0">Servers</h5><span class="small text-body-secondary">Memory and CPU from this machine (Linux); CPU is of one core since the last refresh</span></div>
<div class="card-body table-responsive" id="servers"><p class="text-body-secondary mb-0">Loading&hellip;</p></div>
</div>
{% if can.logs_system %}
@@ -91,13 +96,18 @@
}
function uptime(samples, from, to) {
var rows = [['auth', 'Auth server'], ['chat', 'Chat server']].map(function (s) {
var up = samples.filter(function (p) { return p[s[0]]; }).length;
var percent = samples.length ? (up / samples.length * 100) : 0;
var servers = [['auth', 'Auth server'], ['chat', 'Chat server']];
// The UGC server only counts while master was set to start it
if (samples.some(function (p) { return p.ugc_enabled; })) servers.push(['ugc', 'UGC server', 'ugc_enabled']);
var rows = servers.map(function (s) {
var counted = s[2] ? samples.filter(function (p) { return p[s[2]]; }) : samples;
var up = counted.filter(function (p) { return p[s[0]]; }).length;
var percent = counted.length ? (up / counted.length * 100) : 0;
var bars = samples.map(function (p) {
return '<span title="' + esc(new Date(p.time * 1000).toLocaleString()) + '" style="flex: 1; min-width: 1px; background: var(--bs-' + (p[s[0]] ? 'success' : 'danger') + ')"></span>';
var color = s[2] && !p[s[2]] ? 'secondary-bg' : p[s[0]] ? 'success' : 'danger';
return '<span title="' + esc(new Date(p.time * 1000).toLocaleString()) + '" style="flex: 1; min-width: 1px; background: var(--bs-' + color + ')"></span>';
}).join('');
return '<div class="mb-3"><div class="d-flex justify-content-between small mb-1"><span>' + s[1] + '</span><span>' + (samples.length ? percent.toFixed(1) + '% up' : 'no samples') + '</span></div>' +
return '<div class="mb-3"><div class="d-flex justify-content-between small mb-1"><span>' + s[1] + '</span><span>' + (counted.length ? percent.toFixed(1) + '% up' : 'no samples') + '</span></div>' +
'<div class="d-flex rounded overflow-hidden" style="height: 18px">' + bars + '</div></div>';
});
document.getElementById('uptime').innerHTML = rows.join('') + '<p class="small text-body-secondary mb-0">Red means the server was not connected when the sample was taken.</p>';
@@ -109,7 +119,8 @@
var c = d.current;
document.getElementById('healthNow').innerHTML = [
['Players online', nf.format(c.players)], ['Worlds running', nf.format(c.worlds)],
['Auth / chat', (c.auth ? 'up' : 'DOWN') + ' / ' + (c.chat ? 'up' : 'DOWN')], ['Memory', c.memory_kb ? size(c.memory_kb) : 'n/a']
[c.ugc_enabled ? 'Auth / chat / UGC' : 'Auth / chat', [c.auth, c.chat].concat(c.ugc_enabled ? [c.ugc] : []).map(function (up) { return up ? 'up' : 'DOWN'; }).join(' / ')],
['Memory', c.memory_kb ? size(c.memory_kb) : 'n/a']
].map(function (s) {
return '<div class="col-6 col-md-3"><div class="card text-center"><div class="card-body py-3"><div class="text-body-secondary small text-uppercase mb-1">' + s[0] + '</div><div class="fs-4 fw-bold">' + esc(s[1]) + '</div></div></div></div>';
}).join('');
@@ -120,6 +131,38 @@
});
}
// Every server with its process and what its last traffic report said (UGC: workers, storage and the database queue)
function loadServers() {
api.get('/api/servers').then(function (d) {
var box = document.getElementById('servers');
if (!d.success) { box.innerHTML = '<p class="text-danger mb-0">' + esc(d.error) + '</p>'; return; }
var now = Date.now() / 1000;
var details = function (s) {
var g = (s.traffic && s.traffic.gauges) || {}, parts = [];
if (s.players !== undefined) parts.push(nf.format(s.players) + ' player' + (s.players === 1 ? '' : 's'));
if (s.traffic && s.traffic.connections) parts.push(nf.format(s.traffic.connections) + ' connection' + (s.traffic.connections === 1 ? '' : 's') + (s.traffic.ping_ms ? ', ' + s.traffic.ping_ms + ' ms' : ''));
if (g.workers_threads !== undefined) parts.push('workers ' + g.workers_busy + '/' + g.workers_threads + (g.workers_queued ? ', ' + g.workers_queued + ' queued' : ''));
if (s.key === 'ugc') {
var c = d.ugc.counts || { model: {}, modular: {} };
var sum = function (state) { return (c.model[state] || 0) + (c.modular[state] || 0); };
parts.push(nf.format(sum('pending')) + ' waiting, ' + nf.format(sum('done')) + ' made, ' + nf.format(sum('failed')) + ' failed');
if (g.ugc_stored_bytes !== undefined) parts.push(fmt.bytes(g.ugc_stored_bytes) + (g.ugc_max_storage_bytes ? ' of ' + fmt.bytes(g.ugc_max_storage_bytes) : '') + ' stored');
}
return esc(parts.join(' · '));
};
box.innerHTML = '<table class="table table-sm align-middle mb-0"><thead><tr><th>Server</th><th>Status</th><th>Up for</th><th class="text-end">PID</th><th class="text-end">Memory</th><th class="text-end">CPU</th><th>Details</th></tr></thead><tbody>' +
d.servers.map(function (s) {
var p = s.process;
var started = (p && p.started_at) || s.since;
var label = s.key === 'ugc' && DASH.can('properties_view') ? fmt.link('/ugc', s.label) : esc(s.label);
return '<tr><td>' + label + '</td><td>' + (s.kind === 'OTHER' ? fmt.badge('Not listed', 'secondary') : fmt.badge(s.online ? 'Online' : 'Offline', s.online ? 'success' : 'danger')) + '</td>' +
'<td>' + ((s.online || s.kind === 'OTHER') && started ? esc(fmt.duration(now - started)) : '-') + '</td>' +
'<td class="text-end">' + (p ? esc(p.pid) : '-') + '</td><td class="text-end">' + (p ? esc(size(p.memory_kb)) : '-') + '</td>' +
'<td class="text-end">' + (p ? esc(p.cpu_percent.toFixed(1) + '%') : '-') + '</td><td class="small">' + details(s) + '</td></tr>';
}).join('') + '</tbody></table>';
}).catch(function () {});
}
function loadDumps() {
var box = document.getElementById('dumps');
if (!box) return;
@@ -153,7 +196,9 @@
});
window.addEventListener('resize', Live.throttle(load, 500));
setInterval(load, 60000);
setInterval(loadServers, 10000);
load();
loadServers();
loadDumps();
})();
</script>

View File

@@ -33,6 +33,17 @@
<div class="row g-3 mb-4">
{% include "server_status.jinja2" %}
{% include "statistics.jinja2" %}
{% if can.health_view %}
<div class="col-md-6 col-lg-4{% if not ugc.enabled %} d-none{% endif %}" id="ugcCard">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">UGC Server</h5>
{% if can.properties_view %}<a class="btn btn-sm btn-outline-secondary" href="/ugc">Models</a>{% endif %}
</div>
<div class="card-body" id="ugcCardBody"><p class="text-body-secondary mb-0">Loading&hellip;</p></div>
</div>
</div>
{% endif %}
</div>
{% include "world_instances.jinja2" %}
@@ -160,9 +171,43 @@ $(function () {
if (el && value !== undefined) el.textContent = value;
}
// The UGC server's card (staff with health_view): its state, the database queue and what its last report said
var ugcLoadedAt = 0;
function loadUgc(force) {
var card = document.getElementById('ugcCard');
if (!card || (!force && Date.now() - ugcLoadedAt < 15000)) return;
ugcLoadedAt = Date.now();
api.get('/api/servers/ugc').then(function (d) {
if (!d.success) return;
card.classList.toggle('d-none', !d.enabled && !d.online);
var g = d.gauges || {}, c = d.counts || { model: {}, modular: {} };
var sum = function (state) { return (c.model[state] || 0) + (c.modular[state] || 0); };
var line = function (label, value, id) {
return '<div class="d-flex justify-content-between align-items-center mb-2"><span>' + label + '</span><span' + (id ? ' id="' + id + '"' : '') + '>' + value + '</span></div>';
};
var status = fmt.badge(d.online ? 'Online' : 'Offline', d.online ? 'success' : 'danger') +
(d.online && d.since ? ' <span class="small text-body-secondary">up ' + esc(fmt.duration(Date.now() / 1000 - d.since)) + '</span>' : '');
var storage = g.ugc_stored_bytes !== undefined ? esc(fmt.bytes(g.ugc_stored_bytes)) + (g.ugc_max_storage_bytes ? ' <span class="text-body-secondary">of ' + esc(fmt.bytes(g.ugc_max_storage_bytes)) + '</span>' : '') : '<span class="text-body-secondary">-</span>';
var workers = g.workers_threads !== undefined ? esc(g.workers_busy + ' / ' + g.workers_threads) + (g.workers_queued ? ' <span class="text-body-secondary">(' + esc(g.workers_queued) + ' queued)</span>' : '') : '<span class="text-body-secondary">-</span>';
document.getElementById('ugcCardBody').innerHTML =
line('Status', status) +
line('Waiting', fmt.badge(sum('pending'), sum('pending') ? 'warning' : 'secondary')) +
line('Made', fmt.badge(sum('done'), 'primary')) +
line('Failed', fmt.badge(sum('failed'), sum('failed') ? 'danger' : 'secondary')) +
line('Workers busy', workers) +
line('Storage used', storage).replace('mb-2"', 'mb-0"');
}).catch(function () {});
}
function update(data) {
if (data.auth) setStatus('auth-status', data.auth.online);
if (data.chat) setStatus('chat-status', data.chat.online);
if (data.ugc) {
setStatus('ugc-status', data.ugc.online);
var ugcRow = document.getElementById('ugc-status-row');
if (ugcRow) ugcRow.classList.toggle('d-none', !data.ugc.enabled);
loadUgc();
}
if (data.worlds) {
setText('world-count', data.stats && data.stats.worlds !== undefined ? data.stats.worlds : data.worlds.length);
if (detailedWorlds) loadDetailedWorlds();
@@ -181,6 +226,7 @@ $(function () {
});
api.get('/api/status').then(update).catch(function () {});
loadUgc(true);
Live.onStatus(update);
});
</script>

View File

@@ -18,6 +18,15 @@
<span class="badge text-bg-danger" id="chat-status">Offline</span>
{% endif %}
</div>
{# Only while master starts it (enable_ugc_server); the page shows it when that changes #}
<div class="d-flex justify-content-between align-items-center mb-3{% if not ugc.enabled %} d-none{% endif %}" id="ugc-status-row">
<span>UGC Server</span>
{% if ugc.online %}
<span class="badge text-bg-success" id="ugc-status">Online</span>
{% else %}
<span class="badge text-bg-danger" id="ugc-status">Offline</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Active Worlds</span>
<span class="badge text-bg-primary" id="world-count">{{ stats.worlds }}</span>