From 347a0a72867587eaddd86d7564397bf8e977caa6 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Sun, 27 Sep 2026 03:55:30 -0500 Subject: [PATCH] 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 --- dCommon/Permissions.cpp | 1 + dDashboardServer/DashboardServer.cpp | 2 + dDashboardServer/routes/CMakeLists.txt | 1 + dDashboardServer/routes/SettingsCatalog.cpp | 1 + dDashboardServer/routes/UgcRoutes.cpp | 97 ++++++++++++++++ dDashboardServer/routes/UgcRoutes.h | 6 + dDashboardServer/static/js/ugc.js | 118 ++++++++++++++++++++ dDashboardServer/templates/header.jinja2 | 5 +- dDashboardServer/templates/ugc.jinja2 | 77 +++++++++++++ docs/UgcServer.md | 12 +- resources/dashboardconfig.ini | 4 + 11 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 dDashboardServer/routes/UgcRoutes.cpp create mode 100644 dDashboardServer/routes/UgcRoutes.h create mode 100644 dDashboardServer/static/js/ugc.js create mode 100644 dDashboardServer/templates/ugc.jinja2 diff --git a/dCommon/Permissions.cpp b/dCommon/Permissions.cpp index 6930215b9..218760c45 100644 --- a/dCommon/Permissions.cpp +++ b/dCommon/Permissions.cpp @@ -90,6 +90,7 @@ namespace { { "contraband_manage", "Economy and map", "Contraband list", "Add, change and remove contraband items (flagged, or removed from players, when a character has one)", 8 }, { "play_keys_manage", "Server", "Play keys", "Create, edit and delete play keys, and see the key an account used", 8 }, + { "ugc_manage", "Server", "UGC processing", "Have the UGC server make player models' meshes and icons again", 8 }, { "client_files", "Server", "Client files", "Browse and download the game client's files", 8 }, { "vanity_manage", "Server", "Vanity NPCs", "Edit the vanity files, NPCs and plaque texts, add vanity changes to scheduled events, and respawn them in game", 8 }, { "tasks_view", "Server", "View scheduled tasks", "See scheduled tasks, their runs and logs", 8 }, diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 2a7027134..ca9f9a15c 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -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(); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index a99d16a6e..8dd991d2f 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -52,6 +52,7 @@ set(DASHBOARDROUTES_SOURCES "Showcase.cpp" "FeaturedProperties.cpp" "ContrabandRoutes.cpp" + "UgcRoutes.cpp" "PropertyRentRoutes.cpp" "PasswordRecovery.cpp" "PrometheusMetrics.cpp" diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 42107dcc8..8c69031c7 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -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)); diff --git a/dDashboardServer/routes/UgcRoutes.cpp b/dDashboardServer/routes/UgcRoutes.cpp new file mode 100644 index 000000000..c94998950 --- /dev/null +++ b/dDashboardServer/routes/UgcRoutes.cpp @@ -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 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>& 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(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(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 id; + if (body->contains("id")) { + id = GeneralUtils::TryParse((*body)["id"].is_string() ? (*body)["id"].get() : (*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" } }); + }); + } +} diff --git a/dDashboardServer/routes/UgcRoutes.h b/dDashboardServer/routes/UgcRoutes.h new file mode 100644 index 000000000..f3c805605 --- /dev/null +++ b/dDashboardServer/routes/UgcRoutes.h @@ -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(); +} diff --git a/dDashboardServer/static/js/ugc.js b/dDashboardServer/static/js/ugc.js new file mode 100644 index 000000000..60b918042 --- /dev/null +++ b/dDashboardServer/static/js/ugc.js @@ -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 '
' + esc(title) + '
' + + fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') + + '
'; + } + + function row(i) { + var s = STATES[i.state] || [i.state, 'secondary']; + var icon = i.state === 'done' ? '' : ''; + var owner = i.characterName ? '' + esc(i.characterName) + '' : '' + esc(i.characterId) + ''; + var details = kind === 'modular' ? '' + esc(i.modules) + '' : ''; + if (i.error) details += '
' + esc(i.error) + '
'; + var buttons = (kind === 'model' ? '' : '') + + (canManage ? '' : ''); + return '' + icon + '' + esc(i.id) + '' + owner + '' + fmt.badge(s[0], s[1]) + + (i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '' : '') + '' + + '' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '' + details + '' + buttons + ''; + } + + 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('') || 'Nothing here.'; + 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 ? '
Last: ' + esc(last.kind) + ' ' + esc(last.id) + (last.ok ? ' made in ' + esc(last.ms) + ' ms' : ' failed') + + (last.message ? ' (' + esc(last.message) + ')' : '') + '
' : ''); + }).catch(function () { + box.innerHTML = fmt.badge('Unreachable', 'warning') + ' The UGC server doesn\'t answer at ' + esc(serverUrl()) + + '. Is enable_ugc_server on, and ugc_public_url 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 = 'Download the mesh (.nif)
' + + 'Download the LXFML'; + 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); +})(); diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index 74eaab62c..38ae08438 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -117,8 +117,8 @@ {% 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 %} diff --git a/dDashboardServer/templates/ugc.jinja2 b/dDashboardServer/templates/ugc.jinja2 new file mode 100644 index 000000000..8a06d72ff --- /dev/null +++ b/dDashboardServer/templates/ugc.jinja2 @@ -0,0 +1,77 @@ +{% extends "base.jinja2" %} + +{% block title %}UGC Server - DarkflameServer{% endblock %} + +{% block content %} +
+
+

UGC Server

+

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 docs/UgcServer.md.

+
+ +
+ +
+
Asking the UGC server…
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + + +
IconIDOwnerStateLast attemptDetails
+
+
+ + +
+ + +
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} diff --git a/docs/UgcServer.md b/docs/UgcServer.md index 3a48a9f53..2f4dc080b 100644 --- a/docs/UgcServer.md +++ b/docs/UgcServer.md @@ -90,7 +90,7 @@ wanted. ## Database state -Migrations `dlu/mysql/80_ugc_processing.sql` and `dlu/sqlite/63_ugc_processing.sql` (the MySQL one only adds columns +Migrations `dlu/mysql/81_ugc_processing.sql` and `dlu/sqlite/64_ugc_processing.sql` (the MySQL one only adds columns that aren't there yet). `ugc`: `is_optimized` (existing) is 0 until the model has been processed, 1 once its files are made, 2 when processing @@ -142,10 +142,12 @@ make one item's files into a folder without a database, for trying settings. ## Dashboard -The UGC page (`/ugc`) reads everything from the database: counts per state, a paged list of models and modular builds -with their state, attempts, last error and owner, and buttons to reprocess one model, all failed ones, or everything -(these only change the database columns). Previews (icon, mesh, LXFML) load from the UGC server's `/files/` URLs; -`ugc_public_url` in `dashboardconfig.ini` says where it is (default `http://:2008`). +The UGC Server page (`/ugc`, Server Admin menu; `properties_view` to look, the new `ugc_manage` permission to make +things again) reads the database: counts per state for models and for cars and rockets, a paged list with owner, +state, attempts, last attempt and failure reason, and buttons to make one item, the failed ones or everything again +(these only reset the columns; the UGC server picks the rows up). Icons, the live status (`/status`) and the mesh +download come from the UGC server at `ugc_public_url` (`dashboardconfig.ini`; empty: the dashboard's host name on +port 2008). "View" shows the model's LXFML in the dashboard's 3D viewer (`/api/ugc//lxfml`). ## Not done yet diff --git a/resources/dashboardconfig.ini b/resources/dashboardconfig.ini index 03e2ad94c..99ed70c7c 100644 --- a/resources/dashboardconfig.ini +++ b/resources/dashboardconfig.ini @@ -50,6 +50,10 @@ enable_char_xml_upload=0 # Links are never built from the request's Host header, so this must be set explicitly. dashboard_url= +# Where the browser loads previews (icons, meshes) from the UGC server, e.g. https://ugc.example.com +# Empty: the dashboard's host name on port 2008 +ugc_public_url= + # SMTP server and port. Typical: 587 with starttls, or 465 with tls. smtp_host= smtp_port=587