feat(dashboard): UGC server page

Counts, a paged list and failures of what the UGC server made of players'
models and modular builds, making one, the failed ones or everything again
(new ugc_manage permission), the UGC server's live status and icons from
ugc_public_url, and a 3D view of a model's LXFML. The UGC settings are in the
settings catalog.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 03:55:30 -05:00
parent abf1cc1d80
commit 347a0a7286
11 changed files with 317 additions and 7 deletions

View File

@@ -73,6 +73,7 @@
#include "PublicRoutes.h"
#include "Showcase.h"
#include "ContrabandRoutes.h"
#include "UgcRoutes.h"
#include "PropertyRentRoutes.h"
#include "FeaturedProperties.h"
#include "PasswordRecovery.h"
@@ -527,6 +528,7 @@ int main(int argc, char** argv) {
RegisterShowcaseRoutes();
FeaturedProperties::RegisterRoutes();
ContrabandRoutes::RegisterRoutes();
UgcRoutes::RegisterRoutes();
PropertyRentRoutes::RegisterRoutes();
RegisterPasswordRecoveryRoutes();
RegisterWSRoutes();

View File

@@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES
"Showcase.cpp"
"FeaturedProperties.cpp"
"ContrabandRoutes.cpp"
"UgcRoutes.cpp"
"PropertyRentRoutes.cpp"
"PasswordRecovery.cpp"
"PrometheusMetrics.cpp"

View File

@@ -295,6 +295,7 @@ namespace {
c.Add(Port(DASHBOARD, "port", "Web port", "Where the dashboard listens for browsers.", "2006"));
c.Add(Format(Text(DASHBOARD, "listen_ip", "Listen address", "127.0.0.1 for this machine only (put a reverse proxy in front); 0.0.0.0 for everyone.", "127.0.0.1", true), eFormat::HOST));
c.Add(Port(DASHBOARD, "net_port", "Server link port", "UDP port for the connection to master (the next one is used too). Keep clear of other servers' ports.", "2010"));
c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address", "Where the browser loads previews from the UGC server, e.g. https://ugc.example.com. Empty: this host, port 2008.", ""), eFormat::URL));
c.Add(Format(Text(DASHBOARD, "dashboard_url", "Public address", "For links in emails and alerts, e.g. https://dashboard.example.com.", ""), eFormat::URL));
c.Add(Bool(DASHBOARD, "secure_cookies", "HTTPS only cookies", "Turn on when the dashboard is served over HTTPS.", false));
c.Add(Bool(DASHBOARD, "behind_proxy", "Behind a reverse proxy", "Use the proxy's X-Forwarded-For for rate limits. Only when the dashboard can't be reached directly.", false));

View File

@@ -0,0 +1,97 @@
#include "UgcRoutes.h"
#include "Database.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "RouteUtils.h"
#include "Sd0.h"
#include "WSRoutes.h"
#include "dConfig.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
namespace {
constexpr uint32_t PAGE_SIZE = 50;
const char* StateName(IUgc::eProcessState state) {
switch (state) {
case IUgc::eProcessState::PENDING: return "pending";
case IUgc::eProcessState::DONE: return "done";
case IUgc::eProcessState::FAILED: return "failed";
}
return "unknown";
}
std::optional<IUgc::eProcessState> ParseState(const std::string& text) {
if (text == "pending") return IUgc::eProcessState::PENDING;
if (text == "done") return IUgc::eProcessState::DONE;
if (text == "failed") return IUgc::eProcessState::FAILED;
return std::nullopt;
}
nlohmann::json Counts(const std::vector<std::pair<IUgc::eProcessState, uint64_t>>& counts) {
nlohmann::json out = { { "pending", 0 }, { "done", 0 }, { "failed", 0 } };
for (const auto& [state, count] : counts) out[StateName(state)] = count;
return out;
}
}
namespace UgcRoutes {
void RegisterRoutes() {
Route(eHTTPMethod::GET, "/ugc", Perm("properties_view"), "What the UGC server made of players' models",
[](HTTPReply& reply, const HTTPContext& context) { RenderPage(reply, context, "ugc.jinja2", "ugc"); });
Route(eHTTPMethod::GET, "/api/ugc", Perm("properties_view"),
"Processing state: {counts: {model, modular: {pending, done, failed}}, items: [{id, characterId, characterName, state, attempts, processedAt, "
"error, bakeAo, modules}], ugcUrl, canManage}. Query: ?kind=model|modular&state=pending|done|failed&page=",
[](HTTPReply& reply, const HTTPContext& context) {
const bool modular = QueryValue(context.queryString, "kind") == "modular";
const auto state = ParseState(QueryValue(context.queryString, "state"));
const auto page = GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "page")).value_or(0);
const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, page * PAGE_SIZE, PAGE_SIZE + 1)
: Database::Get()->GetUgcProcessList(state, page * PAGE_SIZE, PAGE_SIZE + 1);
nlohmann::json items = nlohmann::json::array();
for (size_t i = 0; i < list.size() && i < PAGE_SIZE; i++) {
const auto& info = list[i];
items.push_back({ { "id", std::to_string(info.id) }, { "characterId", std::to_string(info.characterId) }, { "characterName", info.characterName },
{ "state", StateName(info.state) }, { "attempts", info.attempts }, { "processedAt", info.processedAt }, { "error", info.error },
{ "bakeAo", info.bakeAo }, { "modules", info.details } });
}
JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } },
{ "items", items }, { "more", list.size() > PAGE_SIZE }, { "ugcUrl", Game::config->GetValue("ugc_public_url") }, { "canManage", Can(context, "ugc_manage") } });
});
Route(eHTTPMethod::GET, "/api/ugc/:id/lxfml", Perm("properties_view"), "A player model's LXFML",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 2);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
auto model = Database::Get()->GetUgcModel(*id);
if (!model) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "No such model");
Sd0 sd0(model->lxfmlData);
reply.status = eHTTPStatusCode::OK;
reply.contentType = eContentType::TEXT_PLAIN;
reply.message = sd0.GetAsStringUncompressed();
});
Route(eHTTPMethod::POST, "/api/ugc/reprocess", Perm("ugc_manage"),
"Have the UGC server make items again. Body: {kind: model|modular, id} for one, {kind, failedOnly: true} for the failed ones, {kind} for all",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
const bool modular = body->value("kind", "model") == "modular";
std::optional<LWOOBJID> id;
if (body->contains("id")) {
id = GeneralUtils::TryParse<LWOOBJID>((*body)["id"].is_string() ? (*body)["id"].get<std::string>() : (*body)["id"].dump());
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
}
const bool failedOnly = body->value("failedOnly", false);
const auto changed = modular ? Database::Get()->ResetModularBuildProcessing(id, failedOnly) : Database::Get()->ResetUgcModelProcessing(id, failedOnly);
const std::string what = modular ? "modular build" : "model";
Audit(context, "ugc_reprocess", "Queued " + std::to_string(changed) + " " + what + "(s) to be made again" +
(id ? " (" + std::to_string(*id) + ")" : failedOnly ? " (the failed ones)" : " (all)"));
BroadcastTableChanged("ugc");
JsonSuccess(reply, { { "message", std::to_string(changed) + " " + what + (changed == 1 ? "" : "s") + " will be made again" } });
});
}
}

View File

@@ -0,0 +1,6 @@
#pragma once
// The UGC page: what the UGC server has made of players' models and modular builds, and making them again
namespace UgcRoutes {
void RegisterRoutes();
}

View File

@@ -0,0 +1,118 @@
/**
* The UGC Server page: counts and a list of what the UGC server made of players' models and modular builds (from the
* database), its live status and previews (from the UGC server itself, at ugc_public_url), and making things again.
*/
(function () {
'use strict';
var kind = 'model', state = '', page = 0, ugcUrl = '', canManage = false, viewer = null;
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'] };
function serverUrl() {
return (ugcUrl || (location.protocol + '//' + location.hostname + ':2008')).replace(/\/+$/, '');
}
function fileUrl(id, name) {
return serverUrl() + '/files/' + kind + '/' + encodeURIComponent(id) + '/' + name;
}
function countCard(title, c) {
return '<div class="col-md-6"><div class="card"><div class="card-body py-2"><div class="small text-body-secondary">' + esc(title) + '</div>' +
fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') +
'</div></div></div>';
}
function row(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
var icon = i.state === 'done' ? '<img src="' + esc(fileUrl(i.id, 'icon.png')) + '" width="48" height="48" loading="lazy" alt="" onerror="this.style.visibility=\'hidden\'">' : '';
var owner = i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
var details = kind === 'modular' ? '<code class="small">' + esc(i.modules) + '</code>' : '';
if (i.error) details += '<div class="small text-danger">' + esc(i.error) + '</div>';
var buttons = (kind === 'model' ? '<button type="button" class="btn btn-sm btn-outline-secondary me-1" data-preview="' + esc(i.id) + '">View</button>' : '') +
(canManage ? '<button type="button" class="btn btn-sm btn-outline-warning" data-remake="' + esc(i.id) + '">Make again</button>' : '');
return '<tr><td>' + icon + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner + '</td><td>' + fmt.badge(s[0], s[1]) +
(i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + '</td>' +
'<td class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</td><td>' + details + '</td><td class="text-end text-nowrap">' + buttons + '</td></tr>';
}
function load() {
api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page).then(function (d) {
if (!d.success) return;
ugcUrl = d.ugcUrl;
canManage = d.canManage;
document.getElementById('manageButtons').classList.toggle('d-none', !canManage);
document.getElementById('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets', d.counts.modular);
document.getElementById('rows').innerHTML = d.items.map(row).join('') || '<tr><td colspan="7" class="text-body-secondary">Nothing here.</td></tr>';
document.getElementById('prevPage').disabled = page === 0;
document.getElementById('nextPage').disabled = !d.more;
loadStatus();
}).catch(function () {});
}
function loadStatus() {
var box = document.getElementById('serverStatus');
fetch(serverUrl() + '/status').then(function (r) { return r.json(); }).then(function (s) {
var mb = function (b) { return (b / 1048576).toFixed(0) + ' MB'; };
var last = s.recent && s.recent[0];
box.innerHTML = fmt.badge('Online', 'success') + ' ' + esc(s.workers) + ' workers, ' + esc(s.active) + ' working, ' + esc(s.queued) + ' queued. ' +
'Since it started: ' + esc(s.made) + ' made, ' + esc(s.failed) + ' failed attempts. Files: ' + esc(mb(s.storedBytes)) +
(s.maxStorageBytes ? ' of ' + esc(mb(s.maxStorageBytes)) : '') + (s.evicted ? ', ' + esc(s.evicted) + ' deleted to save space' : '') + '.' +
(last ? '<div class="text-body-secondary">Last: ' + esc(last.kind) + ' ' + esc(last.id) + (last.ok ? ' made in ' + esc(last.ms) + ' ms' : ' failed') +
(last.message ? ' (' + esc(last.message) + ')' : '') + '</div>' : '');
}).catch(function () {
box.innerHTML = fmt.badge('Unreachable', 'warning') + ' The UGC server doesn\'t answer at ' + esc(serverUrl()) +
'. Is <code>enable_ugc_server</code> on, and <code>ugc_public_url</code> right?';
});
}
function remake(body, question) {
if (question && !confirm(question)) return;
body.kind = kind;
api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
load();
});
}
function preview(id) {
document.getElementById('previewTitle').textContent = 'Model ' + id;
document.getElementById('previewIcon').src = fileUrl(id, 'icon.png');
document.getElementById('previewLinks').innerHTML = '<a href="' + esc(fileUrl(id, 'model.nif')) + '">Download the mesh (.nif)</a><br>' +
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a>';
bootstrap.Modal.getOrCreateInstance(document.getElementById('previewModal')).show();
var container = document.getElementById('previewViewer');
container.textContent = 'Loading…';
import('/js/lddviewer.js').then(function (module) {
if (viewer && viewer.dispose) viewer.dispose();
container.textContent = '';
viewer = module.createViewer(container, {});
return viewer.load([{ id: id, name: 'Model ' + id, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/ugc/' + id + '/lxfml' }], 1);
}).catch(function (e) { container.textContent = 'The 3D view could not load: ' + e.message; });
}
document.getElementById('kindButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-kind]');
if (!button) return;
kind = button.dataset.kind;
page = 0;
this.querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b === button); });
load();
});
document.getElementById('stateFilter').addEventListener('change', function () { state = this.value; page = 0; load(); });
document.getElementById('prevPage').addEventListener('click', function () { if (page > 0) { page--; load(); } });
document.getElementById('nextPage').addEventListener('click', function () { page++; load(); });
document.getElementById('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
document.getElementById('remakeAll').addEventListener('click', function () {
remake({}, 'Make every ' + (kind === 'model' ? 'model' : 'car and rocket') + ' again? This can take a long time.');
});
document.getElementById('rows').addEventListener('click', function (e) {
var remakeButton = e.target.closest('[data-remake]'), previewButton = e.target.closest('[data-preview]');
if (remakeButton) remake({ id: remakeButton.dataset.remake });
else if (previewButton) preview(previewButton.dataset.preview);
});
if (window.Live) Live.on('ugc', load);
load();
setInterval(loadStatus, 10000);
})();

View File

@@ -117,8 +117,8 @@
</div>
{% endif %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "maintenance", "webhooks", "client_assets"] %}
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.maintenance or can.webhooks or can.client_files %}
{% set adminPages = ["settings", "settings_history", "permissions", "tasks", "play_keys", "backups", "maintenance", "webhooks", "client_assets", "ugc"] %}
{% if can.settings or can.permissions_manage or can.tasks_view or can.play_keys_manage or can.backups or can.maintenance or can.webhooks or can.client_files or can.ugc_manage %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-admin" aria-expanded="{% if current_page in adminPages %}true{% else %}false{% endif %}" aria-controls="nav-admin">
Server Admin<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -132,6 +132,7 @@
{% if can.backups %}<a href="/backups" class="list-group-item list-group-item-action{% if current_page == "backups" %} active{% endif %}">Backups</a>{% endif %}
{% if can.maintenance %}<a href="/maintenance" class="list-group-item list-group-item-action{% if current_page == "maintenance" %} active{% endif %}">Maintenance</a>{% endif %}
{% if can.webhooks %}<a href="/webhooks" class="list-group-item list-group-item-action{% if current_page == "webhooks" %} active{% endif %}">Webhooks</a>{% endif %}
{% if can.ugc_manage %}<a href="/ugc" class="list-group-item list-group-item-action{% if current_page == "ugc" %} active{% endif %}">UGC Server</a>{% endif %}
{% if can.client_files %}<a href="/client_assets" class="list-group-item list-group-item-action{% if current_page == "client_assets" %} active{% endif %}">Client Files</a>{% endif %}
</div>
</div>

View File

@@ -0,0 +1,77 @@
{% extends "base.jinja2" %}
{% block title %}UGC Server - DarkflameServer{% endblock %}
{% block content %}
<div id="ugcPage">
<div class="mb-3">
<h2 class="mb-1">UGC Server</h2>
<p class="text-body-secondary mb-0">The UGC server makes a mesh and an icon for every model players build, and icons for their cars and rockets,
and serves them to the game client. New and changed models are picked up by themselves; a model that fails is tried a few times, then left
as failed until it's made again from here. See <code>docs/UgcServer.md</code>.</p>
</div>
<div class="row g-2 mb-3" id="counts"></div>
<div class="card mb-3">
<div class="card-body py-2 small" id="serverStatus">Asking the UGC server…</div>
</div>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2">
<div class="btn-group btn-group-sm" role="group" id="kindButtons">
<button type="button" class="btn btn-outline-primary active" data-kind="model">Models</button>
<button type="button" class="btn btn-outline-primary" data-kind="modular">Cars and rockets</button>
</div>
<select class="form-select form-select-sm w-auto" id="stateFilter">
<option value="">Every state</option><option value="pending">Waiting</option><option value="done">Made</option><option value="failed">Failed</option>
</select>
<div class="ms-auto d-none" id="manageButtons">
<button type="button" class="btn btn-sm btn-outline-warning" id="retryFailed">Try the failed ones again</button>
<button type="button" class="btn btn-sm btn-outline-danger" id="remakeAll">Make everything again</button>
</div>
</div>
<div class="card">
<table class="table table-sm align-middle mb-0 table-stack">
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Last attempt</th><th>Details</th><th></th></tr></thead>
<tbody id="rows"></tbody>
</table>
</div>
<div class="d-flex gap-2 mt-2">
<button type="button" class="btn btn-sm btn-outline-secondary" id="prevPage">Newer</button>
<button type="button" class="btn btn-sm btn-outline-secondary" id="nextPage">Older</button>
</div>
<div class="modal fade" id="previewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-xl"><div class="modal-content">
<div class="modal-header"><h5 class="modal-title" id="previewTitle">Model</h5><button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button></div>
<div class="modal-body">
<div class="row g-3">
<div class="col-md-3 text-center"><img id="previewIcon" alt="Icon" class="border rounded bg-body-tertiary" width="128" height="128">
<div class="small mt-2" id="previewLinks"></div></div>
<div class="col-md-9"><div id="previewViewer" style="height: 420px"></div>
<div class="small text-body-secondary">The model as built (its LXFML).</div></div>
</div>
</div>
</div></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script type="importmap">
{
"imports": {
"three": "https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js",
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"
},
"integrity": {
"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.min.js": "sha384-IDC7sAMAIMB/TZ6dgKKPPAKZ2bXXXP8+FBMBC8cU319eBhKITx+PaalhfDkDNH28",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/controls/OrbitControls.js": "sha384-aJoe4qqS/DgF2jh9njAuvA6QIveJYoCuYOfYjdFY8P3eawzmc9bEQ40jq2TvOyuP",
"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/environments/RoomEnvironment.js": "sha384-9ItPPH2vgKbkvgrqTKq7ptGYe+WuAnqlZtlqleJW4/CJjnM5R7C+efD6iS7ADPJ9"
}
}
</script>
<script src="/js/ldd-materials.js"></script>
<script src="/js/ugc.js"></script>
{% endblock %}