feat(dashboard): UGC gallery and viewer, reached through the dashboard

The /ugc page no longer needs the browser to reach the UGC server: the
dashboard fetches its status (/api/ugc/server/status), the files it made
(/api/ugc/files/<kind>/<id>/<file>) and its NIFs converted for the 3D view
(/api/ugc/mesh/<id>, NifFile like the scenery) from ugc_internal_url
(default http://127.0.0.1:2008) with libcurl on the worker threads, keeping
small answers briefly. ugc_public_url is only an "open on the UGC server"
link now.

The page gets an icon gallery beside the list, filtered by kind, state and a
search by id or owner (GetUgcProcessList/GetModularBuildProcessList take a
search), and a viewer: the generated NIF in 3D at any LOD, now or before it
was made again, with wireframe, vertex color and baked lighting switches, the
LXFML beside it, the icon now and before, and the stats with triangles before
and after hidden faces were removed. The status box shows CPU, memory, the
jobs' memory estimate with their limits, and throttling. The settings page
lists the UGC server's new settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-27 09:38:51 -05:00
parent 175db95c98
commit 95158f69b6
16 changed files with 671 additions and 141 deletions

View File

@@ -296,7 +296,8 @@ 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, "ugc_internal_url", "UGC server address (internal)", "Where the dashboard itself reaches the UGC server for its status and files. Empty: http://127.0.0.1:2008.", ""), eFormat::URL));
c.Add(Format(Text(DASHBOARD, "ugc_public_url", "UGC server address (public)", "Only for the \"open on the UGC server\" links, e.g. https://ugc.example.com. The dashboard's own pages don't need the browser to reach it.", ""), 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));
@@ -415,20 +416,48 @@ namespace {
c.Add(Format(Text(UGC, "ugc_output_dir", "Files folder", "Relative to the server binaries.", "ugc", true), eFormat::PATH));
c.Add(Unit(Int(UGC, "ugc_max_storage_mb", "Most space for files", "The files used longest ago are deleted past this and made again when asked for. 0: no limit.", "2048", 0, std::nullopt, true), "MB"));
c.Add(Int(UGC, "worker_threads", "Worker threads", "0: half the CPU cores.", "0", 0, 64, true));
c.Add(Unit(Float(UGC, "max_cpu_percent", "Most CPU", "The workers together average at most this share of all CPU cores; they pause between bits of work to stay under it. 0: no limit.", "0", 0, 100), "%"));
c.Add(Int(UGC, "worker_nice", "Worker priority", "Linux nice value of the workers: 0 normal, 19 only when nothing else wants the CPU.", "0", 0, 19));
c.Add(Unit(Int(UGC, "max_memory_mb", "Most memory for jobs", "Jobs whose estimated memory would go past this together wait for running ones; a bigger one runs alone. 0: no limit.", "0", 0, 1000000), "MB"));
c.Add(Unit(Int(UGC, "max_model_bricks", "Biggest model", "Models with more bricks fail with that reason. 0: no limit.", "0", 0, 1000000), "bricks"));
c.Add(Text(UGC, "pause_hours", "Pause hours", "Local hours in which no new jobs start, e.g. 18-23 or 22-6. Empty: never.", ""));
c.Add(Unit(Int(UGC, "poll_interval_ms", "Look for new models every", "", "2000", 100, 600000, true), "ms"));
c.Add(Int(UGC, "poll_batch", "Models taken at once", "", "32", 1, 1000, true));
c.Add(Int(UGC, "max_attempts", "Attempts before giving up", "", "3", 1, 100, true));
c.Add(Int(UGC, "brick_lod", "Brick detail", "0 is the most detailed, 2 the least.", "0", 0, 2, true));
c.Add(Bool(UGC, "remove_hidden_faces", "Remove faces nobody can see", "", true, true));
c.Add(Bool(UGC, "bake_ao", "Darken hidden corners", "Ambient occlusion baked into the vertex colors.", true, true));
c.Add(Float(UGC, "ao_strength", "Darkening strength", "0 to 1.", "0.6", 0, 1));
c.Add(Unit(Int(UGC, "optimize_resolution", "Detail of the visibility renders", "", "1024", 64, 4096, true), "pixels"));
c.Add(Unit(Int(UGC, "icon_size", "Icon size", "", "128", 16, 1024, true), "pixels"));
c.Add(Unit(Float(UGC, "icon_yaw", "Icon angle around", "", "35", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "icon_pitch", "Icon angle above", "", "25", -90, 90), "degrees"));
c.Add(Labels(Choice(UGC, "color_palette", "Colors", "LU Toolbox's LU palette (unknown colors black), or the client's Materials.xml.", "lu_toolbox", { "lu_toolbox", "brickdb" }), { "LU Toolbox", "Brick database" }));
c.Add(Unit(Float(UGC, "color_variation", "Color variation", "Each brick's brightness is shifted by up to this much (the same every time the model is made). 0: none.", "5", 0, 100), "%"));
c.Add(Unit(Float(UGC, "transparent_opacity", "Transparent opacity", "", "58.82", 0, 100), "%"));
c.Add(Text(UGC, "lods", "Levels of detail", "brickprimitives levels made, 0 (most detailed) to 2, e.g. 0,2.", "0,2"));
c.Add(Float(UGC, "lod_distance_0", "LOD 0 distance", "", "0", 0, 100000));
c.Add(Float(UGC, "lod_distance_1", "LOD 1 distance", "", "50", 0, 100000));
c.Add(Float(UGC, "lod_distance_2", "LOD 2 distance", "", "100", 0, 100000));
c.Add(Float(UGC, "lod_distance_3", "LOD 3 distance", "", "280", 0, 100000));
c.Add(Float(UGC, "lod_cull", "Drawn up to", "", "10000", 0, 1000000));
c.Add(Text(UGC, "shader_opaque", "Opaque shader", "S<shader>_Opaque_Model.", "01"));
c.Add(Bool(UGC, "combine_transparent", "One shape for all transparent bricks", "Off: each transparent brick is its own shape, so the client can sort them.", false));
c.Add(Bool(UGC, "remove_hidden_faces", "Remove faces nobody can see", "", true));
c.Add(Bool(UGC, "hsr_ground_plane", "Nothing seen from below", "Also removes what can only be seen from under the model.", false));
c.Add(Unit(Int(UGC, "optimize_resolution", "Detail of the visibility renders", "", "1024", 64, 4096), "pixels"));
c.Add(Bool(UGC, "bake_ao", "Darken hidden corners", "Ambient occlusion baked into the vertex colors.", true));
c.Add(Int(UGC, "ao_samples", "Occlusion rays per vertex", "", "64", 1, 1024));
c.Add(Float(UGC, "ao_distance", "Occlusion distance", "", "5", 0, 1000));
c.Add(Float(UGC, "ao_strength", "Darkening strength", "0 to 1.", "1", 0, 1));
c.Add(Float(UGC, "glow_strength", "Glow strength", "What glowing colors add to the baked light.", "6", 0, 100));
c.Add(Unit(Int(UGC, "icon_size", "Icon size", "", "128", 16, 1024), "pixels"));
c.Add(Unit(Float(UGC, "icon_yaw", "Icon angle around", "", "53.36", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "icon_pitch", "Icon angle above", "", "19.54", -90, 90), "degrees"));
c.Add(Unit(Float(UGC, "icon_fov", "Icon field of view", "", "39.6", 1, 120), "degrees"));
c.Add(Float(UGC, "icon_margin", "Icon border", "1 fills the icon.", "1.03", 0.1, 10));
c.Add(Unit(Float(UGC, "modular_icon_yaw", "Car and rocket icon angle around", "", "35", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "modular_icon_pitch", "Car and rocket icon angle above", "", "20", -90, 90), "degrees"));
c.Add(Unit(Float(UGC, "icon_sun_yaw", "Icon sun angle around", "", "21", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "icon_sun_pitch", "Icon sun angle above", "", "50.3", -90, 90), "degrees"));
c.Add(Float(UGC, "icon_sun_strength", "Icon sun strength", "", "2.5", 0, 100));
c.Add(Float(UGC, "icon_ambient", "Icon world light", "", "0.192", 0, 10));
c.Add(Bool(UGC, "icon_shadows", "Icon shadows", "", true));
c.Add(Bool(UGC, "icon_ao", "Icon ambient occlusion", "", true));
c.Add(Bool(UGC, "icon_correct_colors", "Icon color corrections", "White and black toned down, as LU Toolbox's icon renderer does.", true));
c.Add(Unit(Float(UGC, "icon_color_variation", "Icon color variation", "", "0", 0, 100), "%"));
c.Add(Unit(Float(UGC, "modular_icon_yaw", "Car and rocket icon angle around", "", "53.36", -360, 360), "degrees"));
c.Add(Unit(Float(UGC, "modular_icon_pitch", "Car and rocket icon angle above", "", "19.54", -90, 90), "degrees"));
return c;
}

View File

@@ -1,6 +1,15 @@
#include "UgcRoutes.h"
#include <memory>
#include <mutex>
#include <set>
#include <curl/curl.h>
#include "Database.h"
#include "NifFile.h"
#include "TtlCache.h"
#include "Workers.h"
#include "Game.h"
#include "GeneralUtils.h"
#include "RouteUtils.h"
@@ -30,6 +39,91 @@ namespace {
return std::nullopt;
}
/**
* The dashboard's own way to the UGC server (ugc_internal_url, normally the same machine): its status and the files
* it made are fetched here and handed to the browser, which may not be able to reach the UGC server at all. Fetches
* run on worker threads (Workers::Reply) with the URL worked out on the web thread; small answers are kept briefly.
*/
struct Fetched {
long status{}; // HTTP status, 0 when the UGC server didn't answer
std::string body;
std::string error;
};
std::mutex g_CacheMutex;
TtlCache<std::string, std::shared_ptr<const Fetched>> g_Cache(std::chrono::seconds(15), 32 * 1024 * 1024);
TtlCache<std::string, std::shared_ptr<const Fetched>> g_StatusCache(std::chrono::seconds(2), 1024 * 1024);
constexpr size_t MAX_FETCH_BYTES = 128 * 1024 * 1024;
size_t Collect(char* data, size_t size, size_t count, void* userData) {
auto* out = static_cast<std::string*>(userData);
if (out->size() + size * count > MAX_FETCH_BYTES) return 0;
out->append(data, size * count);
return size * count;
}
// Web thread: where the dashboard reaches the UGC server
std::string InternalUrl() {
auto url = Game::config->GetValue("ugc_internal_url");
if (url.empty()) url = "http://127.0.0.1:2008";
while (url.ends_with('/')) url.pop_back();
return url;
}
// Any thread: a GET to the UGC server
std::shared_ptr<const Fetched> Get(const std::string& url) {
auto out = std::make_shared<Fetched>();
CURL* curl = curl_easy_init();
if (!curl) {
out->error = "could not start a request";
return out;
}
curl_easy_setopt(curl, CURLOPT_URL, url.c_str());
curl_easy_setopt(curl, CURLOPT_PROTOCOLS_STR, "http,https");
curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 0L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L);
curl_easy_setopt(curl, CURLOPT_CONNECTTIMEOUT, 3L);
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 20L);
curl_easy_setopt(curl, CURLOPT_USERAGENT, "DarkflameServer-Dashboard");
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, Collect);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &out->body);
const auto code = curl_easy_perform(curl);
if (code == CURLE_OK) curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &out->status);
else out->error = curl_easy_strerror(code);
curl_easy_cleanup(curl);
return out;
}
// Any thread: Get, through the cache (answers that aren't a file being made are kept)
std::shared_ptr<const Fetched> CachedGet(const std::string& url, bool status) {
{
std::lock_guard lock(g_CacheMutex);
if (auto hit = (status ? g_StatusCache : g_Cache).Get(url)) return *hit;
}
auto fetched = Get(url);
if (fetched->status == 200 || fetched->status == 404) {
std::lock_guard lock(g_CacheMutex);
(status ? g_StatusCache : g_Cache).Put(url, fetched, fetched->body.size() + 64);
}
return fetched;
}
void FetchError(HTTPReply& reply, const Fetched& fetched, const std::string& url) {
if (fetched.status == 408) return JsonError(reply, eHTTPStatusCode::REQUEST_TIMEOUT, "The UGC server is making it; try again in a moment");
if (fetched.status == 404) return JsonError(reply, eHTTPStatusCode::NOT_FOUND, "The UGC server has no such file");
if (fetched.status == 0) return JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server doesn't answer at " + url + " (" + fetched.error + ")");
JsonError(reply, eHTTPStatusCode::BAD_GATEWAY, "The UGC server answered " + std::to_string(fetched.status));
}
const std::set<std::string> FILES = { "icon.png", "model.nif", "model.noao.nif", "stats.json",
"previous.icon.png", "previous.model.nif", "previous.model.noao.nif", "previous.stats.json" };
std::optional<std::string> KindOf(const std::string& text) {
if (text == "model" || text == "modular") return text;
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;
@@ -44,22 +138,24 @@ namespace UgcRoutes {
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=",
"error, bakeAo, modules}], ugcPublicUrl, canManage}. Query: ?kind=model|modular&state=pending|done|failed&search=(id or owner name)&page=&size=",
[](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);
const auto search = QueryValue(context.queryString, "search").substr(0, 64);
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(PAGE_SIZE), 1u, 200u);
const auto list = modular ? Database::Get()->GetModularBuildProcessList(state, search, page * size, size + 1)
: Database::Get()->GetUgcProcessList(state, search, page * size, size + 1);
nlohmann::json items = nlohmann::json::array();
for (size_t i = 0; i < list.size() && i < PAGE_SIZE; i++) {
for (size_t i = 0; i < list.size() && i < 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") } });
{ "items", items }, { "more", list.size() > size }, { "ugcPublicUrl", 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",
@@ -74,6 +170,67 @@ namespace UgcRoutes {
reply.message = sd0.GetAsStringUncompressed();
});
Route(eHTTPMethod::GET, "/api/ugc/server/status", Perm("properties_view"),
"The UGC server's live status (its /status, fetched by the dashboard from ugc_internal_url): queue, workers, CPU and memory use, "
"limits and throttling. {success: false, error, url} when it doesn't answer",
[](HTTPReply& reply, const HTTPContext& context) {
const auto base = InternalUrl();
Workers::Reply(reply, context, false, [base](HTTPReply& out) {
const auto fetched = CachedGet(base + "/status", true);
if (fetched->status != 200) {
JsonReply(out, eHTTPStatusCode::OK, { { "success", false }, { "url", base }, { "error", fetched->status == 0 ? fetched->error : "answered " + std::to_string(fetched->status) } });
return;
}
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_JSON;
out.message = R"({"success":true,"status":)" + fetched->body + "}";
out.headers.push_back("Cache-Control: no-store");
}, WorkerPool::ePriority::URGENT);
});
Route(eHTTPMethod::GET, "/api/ugc/files/:kind/:id/:file", Perm("properties_view"),
"A file the UGC server made (fetched from ugc_internal_url): icon.png, model.nif, model.noao.nif (before the lighting bake), "
"stats.json, or previous.<one of those> (the version before it was made again). 408 while it's being made",
[](HTTPReply& reply, const HTTPContext& context) {
const auto kind = KindOf(std::string(PathSegment(context.path, 3)));
const auto id = PathId<LWOOBJID>(context.path, 4);
const auto file = std::string(PathSegment(context.path, 5));
if (!kind || !id || !FILES.contains(file)) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid kind, id or file");
const auto url = InternalUrl() + "/files/" + *kind + "/" + std::to_string(*id) + "/" + file;
Workers::Reply(reply, context, false, [url, file](HTTPReply& out) {
const auto fetched = CachedGet(url, false);
if (fetched->status != 200) return FetchError(out, *fetched, url);
out.status = eHTTPStatusCode::OK;
out.contentType = file.ends_with(".png") ? eContentType::IMAGE_PNG : file.ends_with(".json") ? eContentType::APPLICATION_JSON : eContentType::APPLICATION_OCTET_STREAM;
out.message = fetched->body;
out.headers.push_back("Cache-Control: private, no-cache");
}, file.ends_with(".png") ? WorkerPool::ePriority::URGENT : WorkerPool::ePriority::NORMAL);
});
Route(eHTTPMethod::GET, "/api/ugc/mesh/:id", Perm("properties_view"),
"A player model's generated .nif converted for the 3D view (NifFile::Encode, as the scenery meshes). Query: ?lod=0 (most detailed) "
"to 3, &version=current|previous, &ao=0 for the mesh before the lighting bake. The header adds triangles and vertices",
[](HTTPReply& reply, const HTTPContext& context) {
const auto id = PathId<LWOOBJID>(context.path, 3);
if (!id) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid id");
const auto lod = std::min(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "lod")).value_or(0), 3u);
const bool previous = QueryValue(context.queryString, "version") == "previous";
const bool baked = QueryValue(context.queryString, "ao") != "0";
const std::string file = std::string(previous ? "previous." : "") + (baked ? "model.nif" : "model.noao.nif");
const auto url = InternalUrl() + "/files/model/" + std::to_string(*id) + "/" + file;
Workers::Reply(reply, context, false, [url, lod](HTTPReply& out) {
const auto fetched = CachedGet(url, false);
if (fetched->status != 200) return FetchError(out, *fetched, url);
std::string error;
const auto model = NifFile::Parse(fetched->body, lod, error);
if (!model) return JsonError(out, eHTTPStatusCode::UNPROCESSABLE_ENTITY, "The .nif can't be read: " + error);
out.status = eHTTPStatusCode::OK;
out.contentType = eContentType::APPLICATION_OCTET_STREAM;
out.message = NifFile::Encode(*model, std::vector<std::string>(model->meshes.size()));
out.headers.push_back("Cache-Control: private, no-cache");
});
});
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) {

View File

@@ -0,0 +1,141 @@
/**
* A 3D view of a .nif the UGC server made, from /api/ugc/mesh/:id (the dashboard converts it with NifFile::Encode,
* as it does the scenery's models), with wireframe and vertex color switches and triangle counts.
*/
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { RoomEnvironment } from 'three/addons/environments/RoomEnvironment.js';
import { parseModel, mergeMeshes, linearColors } from '/js/scenery-core.js';
export function createNifViewer(container) {
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
container.textContent = '';
container.style.position = 'relative';
container.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x1e2126);
const pmrem = new THREE.PMREMGenerator(renderer);
scene.environment = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environmentIntensity = 0.5;
const sun = new THREE.DirectionalLight(0xffffff, 1.4);
scene.add(sun, sun.target);
const camera = new THREE.PerspectiveCamera(40, 1, 0.05, 20000);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
const root = new THREE.Group();
scene.add(root);
let parts = [];
let wireframe = false, vertexColors = true, disposed = false, framed = false;
const grey = new THREE.Color(0xbbbbbb);
function resize() {
const width = container.clientWidth || 300, height = container.clientHeight || 300;
renderer.setSize(width, height, false);
renderer.domElement.style.width = '100%';
renderer.domElement.style.height = '100%';
camera.aspect = width / height;
camera.updateProjectionMatrix();
}
const observer = new ResizeObserver(resize);
observer.observe(container);
resize();
function loop() {
if (disposed) return;
controls.update();
renderer.render(scene, camera);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
function clear() {
for (const part of parts) {
root.remove(part.mesh);
part.mesh.geometry.dispose();
part.mesh.material.dispose();
}
parts = [];
}
function applyLook() {
for (const part of parts) {
const material = part.mesh.material;
material.wireframe = wireframe;
material.vertexColors = vertexColors && part.hasColors;
material.color.copy(vertexColors && part.hasColors ? part.baseColor : grey);
material.needsUpdate = true;
}
}
function frame() {
const box = new THREE.Box3().setFromObject(root);
if (box.isEmpty()) return;
const center = box.getCenter(new THREE.Vector3());
const radius = Math.max(box.getSize(new THREE.Vector3()).length() / 2, 0.1);
// From the icon's side: in front and to the side, a little above
const direction = new THREE.Vector3(Math.sin(0.93) * Math.cos(0.34), Math.sin(0.34), Math.cos(0.93) * Math.cos(0.34));
camera.position.copy(center).addScaledVector(direction, radius / Math.sin(THREE.MathUtils.degToRad(camera.fov / 2)) * 1.05);
camera.near = radius / 100;
camera.far = radius * 100;
camera.updateProjectionMatrix();
controls.target.copy(center);
sun.position.copy(center).add(new THREE.Vector3(0.23, 0.77, 0.6).multiplyScalar(radius * 4));
sun.target.position.copy(center);
framed = true;
}
return {
/**
* Loads a mesh; resolves {triangles, vertices, shapes}. The view keeps its angle between loads of the same model
* (LOD, version) so they can be compared; `reframe` points it at the model again.
*/
async load(url, reframe) {
const response = await fetch(url, { credentials: 'same-origin' });
if (!response.ok) {
let message = response.status === 408 ? 'Being made, try again in a moment' : 'HTTP ' + response.status;
try { message = (await response.json()).error || message; } catch (e) { /* not JSON */ }
throw new Error(message);
}
const model = parseModel(await response.arrayBuffer());
clear();
let triangles = 0, vertices = 0;
for (const mesh of mergeMeshes(model.meshes)) {
if (!mesh.vertices || !mesh.indices.length) continue;
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.BufferAttribute(mesh.positions, 3));
if (mesh.normals) geometry.setAttribute('normal', new THREE.BufferAttribute(mesh.normals, 3, true));
const hasColors = !!(mesh.colors && mesh.vertexColors !== 0);
if (hasColors) geometry.setAttribute('color', new THREE.BufferAttribute(linearColors(mesh.colors), 4, true));
geometry.setIndex(new THREE.BufferAttribute(mesh.indices, 1));
if (!mesh.normals) geometry.computeVertexNormals();
const baseColor = new THREE.Color().setRGB(mesh.diffuse[0], mesh.diffuse[1], mesh.diffuse[2], THREE.SRGBColorSpace);
const material = new THREE.MeshStandardMaterial({ color: baseColor.clone(), vertexColors: hasColors, transparent: !!mesh.blend, roughness: 0.6, metalness: 0 });
const object = new THREE.Mesh(geometry, material);
if (mesh.blend) object.renderOrder = 1;
root.add(object);
parts.push({ mesh: object, hasColors, baseColor });
triangles += mesh.indices.length / 3;
vertices += mesh.vertices;
}
applyLook();
if (reframe || !framed) frame();
return { triangles, vertices, shapes: model.meshes.length };
},
setWireframe(on) { wireframe = on; applyLook(); },
setVertexColors(on) { vertexColors = on; applyLook(); },
frame,
dispose() {
disposed = true;
observer.disconnect();
clear();
controls.dispose();
pmrem.dispose();
renderer.dispose();
renderer.domElement.remove();
}
};
}

View File

@@ -1,20 +1,26 @@
/**
* 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.
* The UGC Server page: counts, a gallery and a list of what the UGC server made of players' models and modular builds
* (from the database), its live status, and a viewer comparing a model's generated mesh, its LXFML and its icon, now
* and before it was made again. Everything from the UGC server comes through the dashboard (/api/ugc/server/status,
* /api/ugc/files/..., /api/ugc/mesh/...), so the browser never has to reach the UGC server itself.
*/
(function () {
'use strict';
var kind = 'model', state = '', page = 0, ugcUrl = '', canManage = false, viewer = null;
var kind = 'model', state = '', search = '', page = 0, view = 'gallery', canManage = false, publicUrl = '';
var nifViewer = null, lxfmlViewer = null, current = null;
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'] };
var PAGE_SIZE = { gallery: 48, list: 50 };
function serverUrl() {
return (ugcUrl || (location.protocol + '//' + location.hostname + ':2008')).replace(/\/+$/, '');
try { view = localStorage.getItem('ugcView') === 'list' ? 'list' : 'gallery'; } catch (e) { /* storage blocked */ }
function $(id) { return document.getElementById(id); }
function fileUrl(itemKind, id, name) {
return '/api/ugc/files/' + itemKind + '/' + encodeURIComponent(id) + '/' + name;
}
function fileUrl(id, name) {
return serverUrl() + '/files/' + kind + '/' + encodeURIComponent(id) + '/' + name;
}
function mb(bytes) { return (bytes / 1048576).toFixed(0) + ' MB'; }
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>' +
@@ -22,47 +28,84 @@
'</div></div></div>';
}
function owner(i) {
return i.characterName ? '<a href="/characters/' + esc(i.characterId) + '">' + esc(i.characterName) + '</a>' : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>';
}
function iconImg(i, size) {
if (i.state !== 'done') return '<span class="ugc-noicon text-body-secondary small border rounded" style="width:' + size + 'px;height:' + size + 'px">' + esc((STATES[i.state] || [i.state])[0]) + '</span>';
return '<img src="' + esc(fileUrl(kind, i.id, 'icon.png')) + '" width="' + size + '" height="' + size + '" loading="lazy" alt="" class="ugc-checker rounded" onerror="this.style.visibility=\'hidden\'">';
}
function tile(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
return '<div class="card ugc-tile p-2" tabindex="0" role="button" data-preview="' + esc(i.id) + '" title="' + esc(i.error || '') + '">' + iconImg(i, 128) +
'<div class="small text-truncate mt-1">' + (i.characterName ? esc(i.characterName) : '<span class="text-body-secondary">' + esc(i.characterId) + '</span>') + '</div>' +
'<div class="small text-body-secondary text-truncate">' + esc(i.id) + '</div><div>' + fmt.badge(s[0], s[1]) + '</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>' : '') +
var buttons = '<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]) +
return '<tr><td>' + iconImg(i, 48) + '</td><td class="small">' + esc(i.id) + '</td><td>' + owner(i) + '</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>';
}
var items = [];
function load() {
api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page).then(function (d) {
var size = PAGE_SIZE[view];
api.get('/api/ugc?kind=' + kind + '&state=' + state + '&page=' + page + '&size=' + size + '&search=' + encodeURIComponent(search)).then(function (d) {
if (!d.success) return;
ugcUrl = d.ugcUrl;
items = d.items;
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();
publicUrl = (d.ugcPublicUrl || '').replace(/\/+$/, '');
$('manageButtons').classList.toggle('d-none', !canManage);
$('counts').innerHTML = countCard('Models', d.counts.model) + countCard('Cars and rockets', d.counts.modular);
$('gallery').classList.toggle('d-none', view !== 'gallery');
$('listCard').classList.toggle('d-none', view !== 'list');
if (view === 'gallery') $('gallery').innerHTML = d.items.map(tile).join('') || '<div class="text-body-secondary">Nothing here.</div>';
else $('rows').innerHTML = d.items.map(row).join('') || '<tr><td colspan="7" class="text-body-secondary">Nothing here.</td></tr>';
$('prevPage').disabled = page === 0;
$('nextPage').disabled = !d.more;
}).catch(function () {});
}
function meter(label, value, limit, text) {
var share = limit > 0 ? Math.min(100, value / limit * 100) : 0;
var colour = share > 90 ? 'danger' : share > 70 ? 'warning' : 'success';
return '<div class="col-md-4"><div class="d-flex justify-content-between"><span>' + esc(label) + '</span><span class="text-body-secondary">' + text + '</span></div>' +
(limit > 0 ? '<div class="progress ugc-meter" role="progressbar" aria-label="' + esc(label) + '" aria-valuenow="' + Math.round(share) + '" aria-valuemin="0" aria-valuemax="100">' +
'<div class="progress-bar bg-' + colour + '" style="width:' + share.toFixed(0) + '%"></div></div>' : '') + '</div>';
}
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. ' +
var box = $('serverStatus');
api.get('/api/ugc/server/status').then(function (d) {
if (!d.success) {
box.innerHTML = fmt.badge('Unreachable', 'warning') + ' The dashboard can\'t reach the UGC server at ' + esc(d.url || '') + (d.error ? ' (' + esc(d.error) + ')' : '') +
'. Is <code>enable_ugc_server</code> on, and <code>ugc_internal_url</code> (dashboard settings) right?';
return;
}
var s = d.status, u = s.usage || {}, l = s.limits || {}, last = s.recent && s.recent[0];
var cores = u.cores || 1, machinePercent = (u.cpuPercent || 0) / cores;
var badges = fmt.badge('Online', 'success') + (u.throttled ? ' ' + fmt.badge('Throttled', 'warning') : '') + (u.paused ? ' ' + fmt.badge('Paused (' + esc(l.pauseHours) + ')', 'info') : '');
box.innerHTML = '<div class="mb-2">' + badges + ' ' + 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' : '') + '.' +
(s.maxStorageBytes ? ' of ' + esc(mb(s.maxStorageBytes)) : '') + (s.evicted ? ', ' + esc(s.evicted) + ' deleted to save space' : '') + '.</div>' +
'<div class="row g-3 mb-1">' +
meter('CPU', machinePercent, l.maxCpus ? l.maxCpus / cores * 100 : 0, machinePercent.toFixed(0) + '% of ' + cores + ' cores' +
(l.maxCpus ? ' (limit ' + (l.maxCpus / cores * 100).toFixed(0) + '%)' : ', no limit') + (u.throttledMs ? ', paused ' + (u.throttledMs / 1000).toFixed(0) + ' s to stay under it' : '')) +
meter('Memory', u.residentBytes || 0, 0, esc(mb(u.residentBytes || 0)) + ' in use') +
meter('Jobs\' memory', s.jobMemoryBytes || 0, l.maxMemoryBytes || 0, esc(mb(s.jobMemoryBytes || 0)) + ' estimated' +
(l.maxMemoryBytes ? ' of ' + esc(mb(l.maxMemoryBytes)) : ', no limit') + (s.memoryWaits ? ', ' + esc(s.memoryWaits) + ' waits' : '')) +
'</div>' + (l.nice ? '<div class="text-body-secondary">Workers run at priority ' + esc(l.nice) + '.</div>' : '') +
(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?';
});
}).catch(function () { box.innerHTML = fmt.badge('Unknown', 'secondary') + ' Couldn\'t ask the dashboard for the UGC server\'s status.'; });
}
function remake(body, question) {
@@ -75,23 +118,102 @@
});
}
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; });
// ---- the viewer ----
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
document.getElementById('kindButtons').addEventListener('click', function (e) {
function statsTable(now, before) {
if (!now) return '<div class="small text-body-secondary">No stats for this model (made before the UGC server wrote them).</div>';
var lods = now.lods || [], old = before && before.lods || [];
var diff = function (a, b) {
if (b === undefined || b === null || a === b) return '';
var d = a - b;
return ' <span class="' + (d < 0 ? 'text-success' : 'text-warning') + '">(' + (d > 0 ? '+' : '') + d + ')</span>';
};
var rows = lods.map(function (l, i) {
var o = old[i] || {};
var removed = l.opaqueBefore ? (100 - l.opaqueAfter / l.opaqueBefore * 100).toFixed(0) + '%' : '';
return '<tr><td>LOD ' + esc(l.lod) + '</td><td>' + esc(l.near) + ' - ' + esc(l.far) + '</td><td>' + esc(l.opaqueBefore) + '</td><td>' + esc(l.opaqueAfter) + diff(l.opaqueAfter, o.opaqueAfter) +
'</td><td>' + esc(removed) + '</td><td>' + esc(l.transparent) + '</td><td>' + esc(l.vertices) + diff(l.vertices, o.vertices) + '</td><td>' + esc(l.shapes) + '</td></tr>';
}).join('');
var ms = now.ms || {}, set = now.settings || {};
return '<div class="table-responsive"><table class="table table-sm small mb-1"><thead><tr><th>Detail</th><th>Drawn from - to</th><th>Opaque triangles</th>' +
'<th>After removing hidden faces</th><th>Removed</th><th>Transparent triangles</th><th>Vertices</th><th>Shapes</th></tr></thead><tbody>' + rows + '</tbody></table></div>' +
'<div class="small text-body-secondary">' + esc(now.bricks) + ' bricks' + (now.missingDesigns ? ', no geometry for ' + esc(now.missingDesigns.join(', ')) : '') +
'. Took ' + esc(ms.total) + ' ms (build ' + esc(ms.build) + ', hidden faces ' + esc(ms.hiddenSurfaces) + ', occlusion ' + esc(ms.ambientOcclusion) + ', icon ' + esc(ms.icon) + ')' +
(before && before.ms ? ', before ' + esc(before.ms.total) + ' ms' : '') + '. Colors: ' + esc(set.palette) + ', variation ' + esc(set.colorVariation) + '%' +
(set.ao ? ', occlusion ' + esc(set.aoSamples) + ' rays to ' + esc(set.aoDistance) : ', no occlusion') + '.</div>';
}
function loadMesh(reframe) {
if (!current || !nifViewer) return;
var lod = $('lodSelect').value || '0', version = $('versionSelect').value, ao = $('aoSwitch').checked ? '1' : '0';
var stats = $('nifStats');
stats.textContent = 'Loading…';
nifViewer.load('/api/ugc/mesh/' + encodeURIComponent(current.id) + '?lod=' + lod + '&version=' + version + '&ao=' + ao, reframe).then(function (r) {
var lodStats = current.stats && current.stats.lods && current.stats.lods[+lod];
stats.textContent = r.triangles.toLocaleString() + ' triangles, ' + r.vertices.toLocaleString() + ' vertices in ' + r.shapes + ' shape' + (r.shapes === 1 ? '' : 's') +
(lodStats && version === 'current' ? ' (' + lodStats.opaqueBefore.toLocaleString() + ' opaque triangles before hidden faces were removed)' : '');
}).catch(function (e) { stats.textContent = 'Could not load the mesh: ' + e.message; });
}
function preview(id) {
var item = items.find(function (i) { return i.id === id; }) || { id: id, state: 'done' };
current = { id: id, kind: kind, stats: null };
$('previewTitle').textContent = (kind === 'model' ? 'Model ' : 'Car or rocket ') + id + (item.characterName ? ' by ' + item.characterName : '');
$('previewIcon').src = fileUrl(kind, id, 'icon.png');
$('previousIconBox').classList.add('d-none');
var previous = $('previousIcon');
previous.onload = function () { $('previousIconBox').classList.remove('d-none'); };
previous.src = fileUrl(kind, id, 'previous.icon.png');
var links = '';
if (kind === 'model') {
links += '<a href="' + esc(fileUrl('model', id, 'model.nif')) + '" download="ugc_' + esc(id) + '.nif">Download the mesh (.nif)</a><br>' +
'<a href="/api/ugc/' + esc(id) + '/lxfml" download="ugc_' + esc(id) + '.lxfml">Download the LXFML</a><br>';
}
if (publicUrl) links += '<a href="' + esc(publicUrl + '/files/' + kind + '/' + id + '/icon.png') + '" target="_blank" rel="noopener">Open on the UGC server</a>';
if (canManage) links += '<div class="mt-2"><button type="button" class="btn btn-sm btn-outline-warning" data-remake-open="' + esc(id) + '">Make again</button></div>';
if (item.error) links += '<div class="text-danger mt-2">' + esc(item.error) + '</div>';
$('previewLinks').innerHTML = links;
var model = kind === 'model';
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); });
$('statsBox').innerHTML = model ? '' : '<div class="small">Modules: <code>' + esc(item.modules || '') + '</code>. The game client puts cars and rockets together itself; only the icon is made.</div>';
bootstrap.Modal.getOrCreateInstance($('previewModal')).show();
if (!model) return;
Promise.all([fetchJson(fileUrl('model', id, 'stats.json')), fetchJson(fileUrl('model', id, 'previous.stats.json'))]).then(function (s) {
if (!current || current.id !== id) return;
current.stats = s[0];
current.statsLoaded = true;
$('statsBox').innerHTML = statsTable(s[0], s[1]);
var lods = (s[0] && s[0].lods) || [{ lod: 0 }];
$('lodSelect').innerHTML = lods.map(function (l, i) { return '<option value="' + i + '">LOD ' + esc(l.lod) + (l.far ? ' (' + esc(l.near) + '-' + esc(l.far) + ')' : '') + '</option>'; }).join('');
$('versionSelect').querySelector('[value=previous]').disabled = !s[1];
$('versionSelect').value = 'current';
loadMesh(true);
});
var nifBox = $('nifViewer'), lxfmlBox = $('lxfmlViewer');
import('/js/ugc-viewer.js').then(function (module) {
if (!nifViewer) {
nifViewer = module.createNifViewer(nifBox);
nifViewer.setWireframe($('wireframeSwitch').checked);
nifViewer.setVertexColors($('colorsSwitch').checked);
}
if (current && current.id === id && current.statsLoaded) loadMesh(true);
}).catch(function (e) { nifBox.textContent = 'The 3D view could not load: ' + e.message; });
lxfmlBox.textContent = 'Loading…';
import('/js/lddviewer.js').then(function (module) {
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlBox.textContent = '';
lxfmlViewer = module.createViewer(lxfmlBox, {});
return lxfmlViewer.load([{ id: id, name: 'Model ' + id, position: [0, 0, 0], rotation: [0, 0, 0, 1], url: '/api/ugc/' + id + '/lxfml' }], 1);
}).catch(function (e) { lxfmlBox.textContent = 'The 3D view could not load: ' + e.message; });
}
$('kindButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-kind]');
if (!button) return;
kind = button.dataset.kind;
@@ -99,20 +221,55 @@
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 () {
$('viewButtons').addEventListener('click', function (e) {
var button = e.target.closest('[data-view]');
if (!button) return;
view = button.dataset.view;
page = 0;
try { localStorage.setItem('ugcView', view); } catch (err) { /* storage blocked */ }
this.querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b === button); });
load();
});
$('viewButtons').querySelectorAll('[data-view]').forEach(function (b) { b.classList.toggle('active', b.dataset.view === view); });
$('stateFilter').addEventListener('change', function () { state = this.value; page = 0; load(); });
var searchTimer = null;
$('search').addEventListener('input', function () {
var value = this.value.trim();
clearTimeout(searchTimer);
searchTimer = setTimeout(function () { search = value; page = 0; load(); }, 300);
});
$('prevPage').addEventListener('click', function () { if (page > 0) { page--; load(); } });
$('nextPage').addEventListener('click', function () { page++; load(); });
$('retryFailed').addEventListener('click', function () { remake({ failedOnly: true }); });
$('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) {
function onItemClick(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);
}
$('rows').addEventListener('click', onItemClick);
$('gallery').addEventListener('click', onItemClick);
$('gallery').addEventListener('keydown', function (e) { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(e); } });
$('previewLinks').addEventListener('click', function (e) {
var button = e.target.closest('[data-remake-open]');
if (button) remake({ id: button.dataset.remakeOpen });
});
$('lodSelect').addEventListener('change', function () { loadMesh(false); });
$('versionSelect').addEventListener('change', function () { loadMesh(false); });
$('aoSwitch').addEventListener('change', function () { loadMesh(false); });
$('wireframeSwitch').addEventListener('change', function () { if (nifViewer) nifViewer.setWireframe(this.checked); });
$('colorsSwitch').addEventListener('change', function () { if (nifViewer) nifViewer.setVertexColors(this.checked); });
$('reframeButton').addEventListener('click', function () { if (nifViewer) nifViewer.frame(); });
$('previewModal').addEventListener('hidden.bs.modal', function () {
current = null;
if (lxfmlViewer && lxfmlViewer.dispose) lxfmlViewer.dispose();
lxfmlViewer = null;
});
if (window.Live) Live.on('ugc', load);
load();
loadStatus();
setInterval(loadStatus, 10000);
})();

View File

@@ -22,16 +22,22 @@
<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">
<select class="form-select form-select-sm w-auto" id="stateFilter" aria-label="State">
<option value="">Every state</option><option value="pending">Waiting</option><option value="done">Made</option><option value="failed">Failed</option>
</select>
<input type="search" class="form-control form-control-sm w-auto" id="search" placeholder="ID or owner" aria-label="Search by ID or owner">
<div class="btn-group btn-group-sm" role="group" id="viewButtons">
<button type="button" class="btn btn-outline-secondary active" data-view="gallery">Icons</button>
<button type="button" class="btn btn-outline-secondary" data-view="list">List</button>
</div>
<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">
<div id="gallery" class="ugc-gallery mb-2"></div>
<div class="card d-none" id="listCard">
<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>
@@ -43,19 +49,62 @@
</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-dialog modal-xl modal-fullscreen-lg-down"><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 class="d-flex flex-wrap gap-2 align-items-center mb-2 small" id="meshControls">
<label class="d-flex align-items-center gap-1">Detail
<select class="form-select form-select-sm w-auto" id="lodSelect"></select></label>
<label class="d-flex align-items-center gap-1">Version
<select class="form-select form-select-sm w-auto" id="versionSelect">
<option value="current">Now</option><option value="previous">Before it was made again</option>
</select></label>
<div class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" id="wireframeSwitch"><label class="form-check-label" for="wireframeSwitch">Wireframe</label></div>
<div class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" id="colorsSwitch" checked><label class="form-check-label" for="colorsSwitch">Vertex colors</label></div>
<div class="form-check form-switch mb-0"><input class="form-check-input" type="checkbox" id="aoSwitch" checked><label class="form-check-label" for="aoSwitch">Baked lighting</label></div>
<button type="button" class="btn btn-sm btn-outline-secondary" id="reframeButton">Reset view</button>
</div>
<div class="row g-3">
<div class="col-lg-5" id="nifColumn">
<div class="small fw-semibold mb-1">Generated mesh (.nif)</div>
<div id="nifViewer" class="rounded border" style="height: 420px"></div>
<div class="small text-body-secondary mt-1" id="nifStats"></div>
</div>
<div class="col-lg-4" id="lxfmlColumn">
<div class="small fw-semibold mb-1">As built (LXFML)</div>
<div id="lxfmlViewer" class="rounded border" style="height: 420px"></div>
<div class="small text-body-secondary mt-1">Every brick, before hidden faces are removed.</div>
</div>
<div class="col-lg-3">
<div class="small fw-semibold mb-1">Icon</div>
<div class="d-flex gap-2 flex-wrap">
<figure class="text-center mb-0"><img id="previewIcon" alt="Icon" class="border rounded bg-body-tertiary ugc-checker" width="128" height="128">
<figcaption class="small text-body-secondary">Now</figcaption></figure>
<figure class="text-center mb-0 d-none" id="previousIconBox"><img id="previousIcon" alt="Icon before" class="border rounded bg-body-tertiary ugc-checker" width="128" height="128">
<figcaption class="small text-body-secondary">Before</figcaption></figure>
</div>
<div class="small mt-2" id="previewLinks"></div>
</div>
</div>
<div class="mt-3" id="statsBox"></div>
</div>
</div></div>
</div>
</div>
<style>
.ugc-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: .5rem; }
.ugc-tile { cursor: pointer; text-align: center; }
.ugc-tile:hover, .ugc-tile:focus-visible { border-color: var(--bs-primary); }
.ugc-tile img, .ugc-tile .ugc-noicon { width: 128px; height: 128px; }
.ugc-noicon { display: inline-flex; align-items: center; justify-content: center; }
.ugc-checker { background-image: linear-gradient(45deg, rgba(128,128,128,.15) 25%, transparent 25%, transparent 75%, rgba(128,128,128,.15) 75%),
linear-gradient(45deg, rgba(128,128,128,.15) 25%, transparent 25%, transparent 75%, rgba(128,128,128,.15) 75%);
background-size: 16px 16px; background-position: 0 0, 8px 8px; }
.ugc-meter { height: .4rem; }
</style>
{% endblock %}
{% block scripts %}

View File

@@ -75,8 +75,9 @@ public:
// with `failedOnly`). Returns how many rows changed.
virtual uint64_t ResetUgcModelProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) = 0;
// A page of models (all, or those in `state`), the newest first
virtual std::vector<ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const uint32_t offset, const uint32_t limit) = 0;
// A page of models (all, or those in `state`), the newest first; `search` (when not empty) matches the model's id
// exactly or part of its owner's name
virtual std::vector<ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) = 0;
// How many models are in each state
virtual std::vector<std::pair<eProcessState, uint64_t>> GetUgcProcessCounts() = 0;

View File

@@ -26,7 +26,7 @@ public:
virtual void SetModularBuildProcessed(const LWOOBJID id, const IUgc::eProcessState state, const uint32_t attempts, const std::string_view error) = 0;
virtual std::optional<IUgc::ProcessInfo> GetModularBuildProcessInfo(const LWOOBJID id) = 0;
virtual uint64_t ResetModularBuildProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) = 0;
virtual std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const uint32_t offset, const uint32_t limit) = 0;
virtual std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) = 0;
virtual std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetModularBuildProcessCounts() = 0;
};

View File

@@ -416,13 +416,13 @@ public:
void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) override;
std::optional<IUgc::ProcessInfo> GetUgcProcessInfo(const LWOOBJID id) override;
uint64_t ResetUgcModelProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) override;
std::vector<IUgc::ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const uint32_t offset, const uint32_t limit) override;
std::vector<IUgc::ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) override;
std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetUgcProcessCounts() override;
std::vector<IUgcModularBuild::PendingBuild> GetModularBuildsToProcess(const uint32_t limit) override;
void SetModularBuildProcessed(const LWOOBJID id, const IUgc::eProcessState state, const uint32_t attempts, const std::string_view error) override;
std::optional<IUgc::ProcessInfo> GetModularBuildProcessInfo(const LWOOBJID id) override;
uint64_t ResetModularBuildProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) override;
std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const uint32_t offset, const uint32_t limit) override;
std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) override;
std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetModularBuildProcessCounts() override;
std::optional<IProperty::Info> GetPropertyInfo(const LWOOBJID id) override;
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true, bool pendingOnly = false) override;

View File

@@ -139,22 +139,19 @@ uint64_t MySQLDatabase::ResetUgcModelProcessing(const std::optional<LWOOBJID> id
return ExecuteUpdate("UPDATE ugc SET is_optimized = 0, process_attempts = 0, process_error = '';");
}
std::vector<IUgc::ProcessInfo> MySQLDatabase::GetUgcProcessList(const std::optional<eProcessState> state, const uint32_t offset, const uint32_t limit) {
std::vector<IUgc::ProcessInfo> MySQLDatabase::GetUgcProcessList(const std::optional<eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) {
const std::string select =
"SELECT u.id, u.character_id, c.name AS character_name, u.is_optimized, u.process_attempts, u.processed_at, u.process_error, u.bake_ao "
"FROM ugc AS u LEFT JOIN charinfo AS c ON c.id = u.character_id ";
std::vector<IUgc::ProcessInfo> list;
auto read = [&list](PreparedStmtResultSet& result) {
while (result->next()) {
list.push_back(ReadUgcProcessInfo(result, false));
}
};
if (state) {
auto result = ExecuteSelect(select + "WHERE u.is_optimized = ? ORDER BY u.id DESC LIMIT ? OFFSET ?;", static_cast<int32_t>(*state), limit, offset);
read(result);
} else {
auto result = ExecuteSelect(select + "ORDER BY u.id DESC LIMIT ? OFFSET ?;", limit, offset);
read(result);
// Every filter always bound, off when its first value says so
const int32_t stateValue = state ? static_cast<int32_t>(*state) : -1;
const std::string text(search);
const std::string pattern = "%" + text + "%";
const std::string where = "WHERE (? < 0 OR u.is_optimized = ?) AND (? = '' OR CAST(u.id AS CHAR) = ? OR c.name LIKE ?) ";
auto result = ExecuteSelect(select + where + "ORDER BY u.id DESC LIMIT ? OFFSET ?;", stateValue, stateValue, text, text, pattern, limit, offset);
while (result->next()) {
list.push_back(ReadUgcProcessInfo(result, false));
}
return list;
}

View File

@@ -62,19 +62,16 @@ uint64_t MySQLDatabase::ResetModularBuildProcessing(const std::optional<LWOOBJID
return ExecuteUpdate("UPDATE ugc_modular_build SET is_optimized = 0, process_attempts = 0, process_error = '';");
}
std::vector<IUgc::ProcessInfo> MySQLDatabase::GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const uint32_t offset, const uint32_t limit) {
std::vector<IUgc::ProcessInfo> MySQLDatabase::GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) {
std::vector<IUgc::ProcessInfo> list;
auto read = [&list](PreparedStmtResultSet& result) {
while (result->next()) {
list.push_back(ReadModularProcessInfo(result, true));
}
};
if (state) {
auto result = ExecuteSelect(MODULAR_SELECT + "WHERE b.is_optimized = ? ORDER BY b.ugc_id DESC LIMIT ? OFFSET ?;", static_cast<int32_t>(*state), limit, offset);
read(result);
} else {
auto result = ExecuteSelect(MODULAR_SELECT + "ORDER BY b.ugc_id DESC LIMIT ? OFFSET ?;", limit, offset);
read(result);
// Every filter always bound, off when its first value says so
const int32_t stateValue = state ? static_cast<int32_t>(*state) : -1;
const std::string text(search);
const std::string pattern = "%" + text + "%";
const std::string where = "WHERE (? < 0 OR b.is_optimized = ?) AND (? = '' OR CAST(b.ugc_id AS CHAR) = ? OR c.name LIKE ?) ";
auto result = ExecuteSelect(MODULAR_SELECT + where + "ORDER BY b.ugc_id DESC LIMIT ? OFFSET ?;", stateValue, stateValue, text, text, pattern, limit, offset);
while (result->next()) {
list.push_back(ReadModularProcessInfo(result, true));
}
return list;
}

View File

@@ -400,13 +400,13 @@ public:
void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) override;
std::optional<IUgc::ProcessInfo> GetUgcProcessInfo(const LWOOBJID id) override;
uint64_t ResetUgcModelProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) override;
std::vector<IUgc::ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const uint32_t offset, const uint32_t limit) override;
std::vector<IUgc::ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) override;
std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetUgcProcessCounts() override;
std::vector<IUgcModularBuild::PendingBuild> GetModularBuildsToProcess(const uint32_t limit) override;
void SetModularBuildProcessed(const LWOOBJID id, const IUgc::eProcessState state, const uint32_t attempts, const std::string_view error) override;
std::optional<IUgc::ProcessInfo> GetModularBuildProcessInfo(const LWOOBJID id) override;
uint64_t ResetModularBuildProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) override;
std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const uint32_t offset, const uint32_t limit) override;
std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) override;
std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetModularBuildProcessCounts() override;
std::optional<IProperty::Info> GetPropertyInfo(const LWOOBJID id) override;
std::string GetPropertiesTable(uint32_t start, uint32_t length, const std::string_view search = "", uint32_t orderColumn = 0, bool orderAsc = true, bool pendingOnly = false) override;

View File

@@ -140,23 +140,20 @@ uint64_t SQLiteDatabase::ResetUgcModelProcessing(const std::optional<LWOOBJID> i
return ExecuteUpdate("UPDATE ugc SET is_optimized = 0, process_attempts = 0, process_error = '';");
}
std::vector<IUgc::ProcessInfo> SQLiteDatabase::GetUgcProcessList(const std::optional<eProcessState> state, const uint32_t offset, const uint32_t limit) {
std::vector<IUgc::ProcessInfo> SQLiteDatabase::GetUgcProcessList(const std::optional<eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) {
const std::string select =
"SELECT u.id, u.character_id, c.name AS character_name, u.is_optimized, u.process_attempts, u.processed_at, u.process_error, u.bake_ao "
"FROM ugc AS u LEFT JOIN charinfo AS c ON c.id = u.character_id ";
std::vector<IUgc::ProcessInfo> list;
auto read = [&list](CppSQLite3Query& result) {
while (!result.eof()) {
list.push_back(ReadUgcProcessInfo(result, false));
result.nextRow();
}
};
if (state) {
auto [_, result] = ExecuteSelect(select + "WHERE u.is_optimized = ? ORDER BY u.id DESC LIMIT ? OFFSET ?;", static_cast<int32_t>(*state), limit, offset);
read(result);
} else {
auto [_, result] = ExecuteSelect(select + "ORDER BY u.id DESC LIMIT ? OFFSET ?;", limit, offset);
read(result);
// Every filter always bound, off when its first value says so
const int32_t stateValue = state ? static_cast<int32_t>(*state) : -1;
const std::string text(search);
const std::string pattern = "%" + text + "%";
const std::string where = "WHERE (? < 0 OR u.is_optimized = ?) AND (? = '' OR CAST(u.id AS CHAR) = ? OR c.name LIKE ?) ";
auto [_, result] = ExecuteSelect(select + where + "ORDER BY u.id DESC LIMIT ? OFFSET ?;", stateValue, stateValue, text, text, pattern, limit, offset);
while (!result.eof()) {
list.push_back(ReadUgcProcessInfo(result, false));
result.nextRow();
}
return list;
}

View File

@@ -63,20 +63,17 @@ uint64_t SQLiteDatabase::ResetModularBuildProcessing(const std::optional<LWOOBJI
return ExecuteUpdate("UPDATE ugc_modular_build SET is_optimized = 0, process_attempts = 0, process_error = '';");
}
std::vector<IUgc::ProcessInfo> SQLiteDatabase::GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const uint32_t offset, const uint32_t limit) {
std::vector<IUgc::ProcessInfo> SQLiteDatabase::GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) {
std::vector<IUgc::ProcessInfo> list;
auto read = [&list](CppSQLite3Query& result) {
while (!result.eof()) {
list.push_back(ReadModularProcessInfo(result, true));
result.nextRow();
}
};
if (state) {
auto [_, result] = ExecuteSelect(MODULAR_SELECT + "WHERE b.is_optimized = ? ORDER BY b.ugc_id DESC LIMIT ? OFFSET ?;", static_cast<int32_t>(*state), limit, offset);
read(result);
} else {
auto [_, result] = ExecuteSelect(MODULAR_SELECT + "ORDER BY b.ugc_id DESC LIMIT ? OFFSET ?;", limit, offset);
read(result);
// Every filter always bound, off when its first value says so
const int32_t stateValue = state ? static_cast<int32_t>(*state) : -1;
const std::string text(search);
const std::string pattern = "%" + text + "%";
const std::string where = "WHERE (? < 0 OR b.is_optimized = ?) AND (? = '' OR CAST(b.ugc_id AS CHAR) = ? OR c.name LIKE ?) ";
auto [_, result] = ExecuteSelect(MODULAR_SELECT + where + "ORDER BY b.ugc_id DESC LIMIT ? OFFSET ?;", stateValue, stateValue, text, text, pattern, limit, offset);
while (!result.eof()) {
list.push_back(ReadModularProcessInfo(result, true));
result.nextRow();
}
return list;
}

View File

@@ -398,13 +398,13 @@ class TestSQLDatabase : public GameDatabase {
void SetUgcModelProcessed(const LWOOBJID id, const eProcessState state, const uint32_t attempts, const std::string_view error, const bool bakeAo) override {}
std::optional<IUgc::ProcessInfo> GetUgcProcessInfo(const LWOOBJID id) override { return {}; }
uint64_t ResetUgcModelProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) override { return 0; }
std::vector<IUgc::ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const uint32_t offset, const uint32_t limit) override { return {}; }
std::vector<IUgc::ProcessInfo> GetUgcProcessList(const std::optional<eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) override { return {}; }
std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetUgcProcessCounts() override { return {}; }
std::vector<IUgcModularBuild::PendingBuild> GetModularBuildsToProcess(const uint32_t limit) override { return {}; }
void SetModularBuildProcessed(const LWOOBJID id, const IUgc::eProcessState state, const uint32_t attempts, const std::string_view error) override {}
std::optional<IUgc::ProcessInfo> GetModularBuildProcessInfo(const LWOOBJID id) override { return {}; }
uint64_t ResetModularBuildProcessing(const std::optional<LWOOBJID> id, const bool failedOnly) override { return 0; }
std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const uint32_t offset, const uint32_t limit) override { return {}; }
std::vector<IUgc::ProcessInfo> GetModularBuildProcessList(const std::optional<IUgc::eProcessState> state, const std::string_view search, const uint32_t offset, const uint32_t limit) override { return {}; }
std::vector<std::pair<IUgc::eProcessState, uint64_t>> GetModularBuildProcessCounts() override { return {}; }
void DeleteAccount(const uint32_t accountId) override {};
void InsertAuditLog(uint32_t accountId, const std::string_view accountName, const std::string_view action, const std::string_view description, uint32_t targetAccountId, LWOOBJID targetCharacterId) override {};

View File

@@ -50,8 +50,12 @@ 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
# Where the dashboard reaches the UGC server (normally on the same machine) for its status and the files it made;
# the dashboard's pages load them through the dashboard, so the browser never needs to reach the UGC server.
# Empty: http://127.0.0.1:2008
ugc_internal_url=
# The UGC server's public address, only for "open on the UGC server" links, e.g. https://ugc.example.com. Empty: none.
ugc_public_url=
# SMTP server and port. Typical: 587 with starttls, or 465 with tls.

View File

@@ -727,9 +727,13 @@ TEST_F(ParitySeeded, UgcModel) {
});
Both("GetUgcProcessList", [&](GameDatabase& db) {
json out = json::array();
for (const auto& info : db.GetUgcProcessList(IUgc::eProcessState::FAILED, 0, 10)) out.push_back(infoJson(info));
out.push_back(db.GetUgcProcessList(std::nullopt, 0, 10).size());
out.push_back(db.GetUgcProcessList(IUgc::eProcessState::DONE, 0, 10).size());
for (const auto& info : db.GetUgcProcessList(IUgc::eProcessState::FAILED, "", 0, 10)) out.push_back(infoJson(info));
out.push_back(db.GetUgcProcessList(std::nullopt, "", 0, 10).size());
out.push_back(db.GetUgcProcessList(IUgc::eProcessState::DONE, "", 0, 10).size());
// Search: an id exactly, or part of the owner's name
out.push_back(db.GetUgcProcessList(std::nullopt, "1152921510000500001", 0, 10).size());
out.push_back(db.GetUgcProcessList(std::nullopt, "115292151000050000", 0, 10).size());
out.push_back(db.GetUgcProcessList(std::nullopt, "li", 0, 10).size());
return out;
});
Both("ResetUgcModelProcessing", [&](GameDatabase& db) {
@@ -744,7 +748,7 @@ TEST_F(ParitySeeded, UgcModel) {
db.SetModularBuildProcessed(1152921510000500002LL, IUgc::eProcessState::PENDING, 1, "no mesh");
out.push_back(infoJson(db.GetModularBuildProcessInfo(1152921510000500002LL)));
out.push_back(countsJson(db.GetModularBuildProcessCounts()));
out.push_back(db.GetModularBuildProcessList(std::nullopt, 0, 10).size());
out.push_back(db.GetModularBuildProcessList(std::nullopt, "", 0, 10).size());
out.push_back(db.ResetModularBuildProcessing(1152921510000500002LL, false));
out.push_back(infoJson(db.GetModularBuildProcessInfo(1152921510000500002LL)));
return out;