feat(dashboard): the cars and rockets table has the models table's columns

The UGC page's assemblies (cars and rockets) list had its own columns.
It now has the models' columns and sorts, minus Saved: ID and Owner (the
newest build and its creator, and how many other owners), State, Made,
Took, CPU and RAM (the latest make of any build, and the cost of the make
the UGC server did for the combination; builds that shared the made icon
cost nothing), Size (the module count), File (the build type and its
modules) and Where (how many builds and owners use it, opening
References). The gallery sorts the same way.

Check: UGC page, Cars and rockets, List: every column sorts both ways on
the server, and the gallery's sort list has the same sorts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 06:08:16 -05:00
parent e4991f0118
commit e159d7b768
6 changed files with 168 additions and 39 deletions

View File

@@ -33,15 +33,33 @@ namespace UgcAssemblies {
IUgc::eProcessState state{ IUgc::eProcessState::PENDING };
LWOOBJID iconBuild{}; // a build whose icon is made (the newest made one, else the newest)
std::string error; // a failed build's error, when none is made
// The newest build's creator
LWOOBJID characterId{};
std::string characterName;
uint32_t accountId{};
std::string accountName;
// The combination's last make: when (the latest any build of it was made or attempted) and what it cost (the
// build the UGC server made it for; the builds that shared it after it was made cost nothing)
int64_t processedAt{};
uint32_t processMs{};
uint32_t processCpuMs{};
uint32_t processMemoryKb{};
};
enum class eSort : uint8_t { NEWEST, OLDEST, REFERENCES, NAME };
// The models list's sorts (IUgcLookup::eSort) that assemblies have: MODULES is the Size column (the most modules first)
enum class eSort : uint8_t { NEWEST, OLDEST, REFERENCES, NAME, OWNER, MADE, SLOWEST, CPU, MEMORY, MODULES };
inline std::optional<eSort> ParseSort(std::string_view text) {
if (text.empty() || text == "newest") return eSort::NEWEST;
if (text == "oldest") return eSort::OLDEST;
if (text == "references" || text == "uses") return eSort::REFERENCES;
if (text == "name") return eSort::NAME;
if (text == "owner") return eSort::OWNER;
if (text == "made") return eSort::MADE;
if (text == "slowest") return eSort::SLOWEST;
if (text == "cpu") return eSort::CPU;
if (text == "memory") return eSort::MEMORY;
if (text == "modules" || text == "bricks") return eSort::MODULES;
return std::nullopt;
}
@@ -94,6 +112,22 @@ namespace UgcAssemblies {
}
}
if (!made) assembly.iconBuild = assembly.builds.front();
const auto& newest = *byId[assembly.builds.front()];
assembly.characterId = newest.characterId;
assembly.characterName = newest.characterName;
assembly.accountId = newest.accountId;
assembly.accountName = newest.accountName;
int64_t costAt = -1;
for (const auto id : assembly.builds) {
const auto& build = *byId[id];
assembly.processedAt = std::max(assembly.processedAt, build.processedAt);
if (build.processMs > 0 && build.processedAt > costAt) {
costAt = build.processedAt;
assembly.processMs = build.processMs;
assembly.processCpuMs = build.processCpuMs;
assembly.processMemoryKb = build.processMemoryKb;
}
}
assembly.state = made ? IUgc::eProcessState::DONE : waiting ? IUgc::eProcessState::PENDING : failed ? IUgc::eProcessState::FAILED : IUgc::eProcessState::EMPTY;
if (made) assembly.error.clear();
out.push_back(std::move(assembly));
@@ -141,14 +175,25 @@ namespace UgcAssemblies {
// reverse: the sort's other direction (the order is flipped as a whole)
inline void Sort(std::vector<Assembly>& list, eSort sort, const std::map<uint32_t, ModuleInfo>& modules, bool reverse = false) {
// The most first, ties the newest first
const auto most = [](auto x, auto y, const Assembly& a, const Assembly& b) { return x != y ? x > y : a.builds.front() > b.builds.front(); };
std::stable_sort(list.begin(), list.end(), [&](const Assembly& a, const Assembly& b) {
switch (sort) {
case eSort::OLDEST: return a.builds.back() < b.builds.back();
case eSort::REFERENCES: return a.builds.size() != b.builds.size() ? a.builds.size() > b.builds.size() : a.builds.front() > b.builds.front();
case eSort::REFERENCES: return most(a.builds.size(), b.builds.size(), a, b);
case eSort::NAME: {
const auto an = Lower(Name(a, modules)), bn = Lower(Name(b, modules));
return an != bn ? an < bn : a.key < b.key;
}
case eSort::OWNER: {
const auto an = Lower(a.characterName), bn = Lower(b.characterName);
return an != bn ? an < bn : a.builds.front() > b.builds.front();
}
case eSort::MADE: return most(a.processedAt, b.processedAt, a, b);
case eSort::SLOWEST: return most(a.processMs, b.processMs, a, b);
case eSort::CPU: return most(a.processCpuMs, b.processCpuMs, a, b);
case eSort::MEMORY: return most(a.processMemoryKb, b.processMemoryKb, a, b);
case eSort::MODULES: return most(a.lots.size(), b.lots.size(), a, b);
default: return a.builds.front() > b.builds.front();
}
});

View File

@@ -283,8 +283,9 @@ namespace UgcRoutes {
"A page of player models (kind=model) or of car and rocket assemblies (kind=modular: one per combination of modules, however many builds use "
"it). Query: q= (\"state:\", \"kind:\"/\"type:\" (a build type, e.g. build6), \"owner:\", \"account:\", \"property:\", \"name:\", "
"\"lot:\"/\"module:\" (a LOT or a module's name), \"id:\", or plain text across names, owners and ids), state=, type=, sort=newest|oldest|owner|name|"
"bricks|triangles|slowest|made|cpu|memory|savings (models) or newest|oldest|references|name (assemblies), reverse=1 (the sort's other direction), page= (from 0), size= (1-200). {items, total, page, size, counts, "
"kinds, ugcPublicUrl, canManage}",
"bricks|triangles|slowest|made|cpu|memory|savings (models) or newest|oldest|owner|name|modules|made|slowest|cpu|memory|references (assemblies), "
"reverse=1 (the sort's other direction), page= (from 0), size= (1-200). {items, total, page, size, counts, totals, kinds, ugcPublicUrl, "
"canManage}",
[](HTTPReply& reply, const HTTPContext& context) {
const bool modular = QueryValue(context.queryString, "kind") == "modular";
auto parsed = ParseListSearch(QueryValue(context.queryString, "q").substr(0, 100));
@@ -294,10 +295,10 @@ namespace UgcRoutes {
const auto size = std::clamp(GeneralUtils::TryParse<uint32_t>(QueryValue(context.queryString, "size")).value_or(PAGE_SIZE), 1u, 200u);
const auto sortText = QueryValue(context.queryString, "sort");
const bool reverse = QueryValue(context.queryString, "reverse") == "1";
nlohmann::json items = nlohmann::json::array();
uint64_t total = 0;
const auto kinds = IconKinds();
if (!modular) {
// A page of models, and how many match
const auto models = [&](uint32_t offset, uint32_t limit) {
IUgcLookup::UgcListQuery query;
query.search = parsed.search;
query.state = parsed.state;
@@ -305,12 +306,12 @@ namespace UgcRoutes {
{ "owner", IUgcLookup::eSort::OWNER }, { "name", IUgcLookup::eSort::NAME }, { "bricks", IUgcLookup::eSort::BRICKS }, { "triangles", IUgcLookup::eSort::TRIANGLES }, { "slowest", IUgcLookup::eSort::SLOWEST }, { "made", IUgcLookup::eSort::MADE }, { "cpu", IUgcLookup::eSort::CPU }, { "memory", IUgcLookup::eSort::MEMORY }, { "savings", IUgcLookup::eSort::SAVINGS } };
if (const auto it = SORTS.find(sortText); it != SORTS.end()) query.sort = it->second;
query.reverse = reverse;
query.offset = page * size;
query.limit = size;
const auto [entries, count] = Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODEL, query);
total = count;
for (const auto& entry : entries) items.push_back(EntryJson(entry));
} else {
query.offset = offset;
query.limit = limit;
return Database::Get()->ListUgc(IUgcLookup::eUgcKind::MODEL, query);
};
// The assemblies matching the search and filters, sorted
const auto assemblies = [&]() {
UgcAssemblies::Filter filter;
filter.state = parsed.state;
if (!parsed.kind.empty()) {
@@ -336,12 +337,23 @@ namespace UgcRoutes {
if (parsed.search.number) filter.moduleLot = static_cast<uint32_t>(*parsed.search.number);
}
}
auto assemblies = *CachedAssemblies();
std::erase_if(assemblies, [&](const auto& a) { return !UgcAssemblies::Matches(a, filter, modules); });
UgcAssemblies::Sort(assemblies, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules, reverse);
total = assemblies.size();
for (size_t i = static_cast<size_t>(page) * size; i < assemblies.size() && i < static_cast<size_t>(page + 1) * size; i++) {
const auto& a = assemblies[i];
auto list = *CachedAssemblies();
std::erase_if(list, [&](const auto& a) { return !UgcAssemblies::Matches(a, filter, modules); });
UgcAssemblies::Sort(list, UgcAssemblies::ParseSort(sortText).value_or(UgcAssemblies::eSort::NEWEST), modules, reverse);
return list;
};
nlohmann::json items = nlohmann::json::array();
uint64_t total = 0;
if (!modular) {
const auto [entries, count] = models(page * size, size);
total = count;
for (const auto& entry : entries) items.push_back(EntryJson(entry));
} else {
const auto list = assemblies();
total = list.size();
for (size_t i = static_cast<size_t>(page) * size; i < list.size() && i < static_cast<size_t>(page + 1) * size; i++) {
const auto& a = list[i];
std::string label, kind;
for (const auto& k : kinds) {
if (k.contains("buildType") && k["buildType"] == a.buildType) {
@@ -354,7 +366,9 @@ namespace UgcRoutes {
items.push_back({ { "id", a.key }, { "key", a.key }, { "modules", ldf }, { "moduleList", ModulesJson(a.lots) }, { "buildType", a.buildType },
{ "kind", kind }, { "kindLabel", label }, { "state", IUgc::ProcessStateName(a.state) }, { "error", a.error }, { "uses", a.builds.size() },
{ "owners", a.owners.size() }, { "iconBuild", std::to_string(a.iconBuild) }, { "newestBuild", std::to_string(a.builds.front()) },
{ "storageId", std::to_string(UgcModularKey::StorageId(a.key)) } });
{ "storageId", std::to_string(UgcModularKey::StorageId(a.key)) }, { "characterId", std::to_string(a.characterId) },
{ "characterName", a.characterName }, { "accountId", a.accountId }, { "accountName", a.accountName }, { "processedAt", a.processedAt },
{ "processMs", a.processMs }, { "processCpuMs", a.processCpuMs }, { "processMemoryKb", a.processMemoryKb } });
}
}
JsonSuccess(reply, { { "counts", { { "model", Counts(Database::Get()->GetUgcProcessCounts()) }, { "modular", Counts(Database::Get()->GetModularBuildProcessCounts()) } } },

View File

@@ -12,7 +12,7 @@
var STATES = { pending: ['Waiting', 'secondary'], done: ['Made', 'success'], failed: ['Failed', 'danger'], empty: ['Empty', 'light'] };
var SORTS = {
model: [['newest', 'Newest'], ['oldest', 'Oldest'], ['bricks', 'Most bricks'], ['triangles', 'Most triangles'], ['made', 'Recently made'], ['slowest', 'Slowest to make'], ['cpu', 'Most CPU'], ['memory', 'Most RAM'], ['savings', 'Most triangles saved'], ['owner', 'Owner'], ['name', 'File name']],
modular: [['newest', 'Newest'], ['oldest', 'Oldest'], ['references', 'Most builds'], ['name', 'Name']]
modular: [['newest', 'Newest'], ['oldest', 'Oldest'], ['references', 'Most builds'], ['modules', 'Most modules'], ['made', 'Recently made'], ['slowest', 'Slowest to make'], ['cpu', 'Most CPU'], ['memory', 'Most RAM'], ['owner', 'Owner'], ['name', 'Name']]
};
// The list's state, as in the address: ?kind=&q=&state=&type=&sort=&page= (from 1)&view=, and the open item (&item=, &build=)
var list = { kind: 'model', q: '', state: '', type: '', sort: 'newest', page: 0, view: 'gallery' };
@@ -157,6 +157,15 @@
return { data: null, title: title, orderable: !!sort, sortKey: sort, desc: !!desc, orderSequence: desc ? ['desc', 'asc'] : ['asc', 'desc'],
render: function (x, type, i) { return render(i); } };
}
// When it was made and what that cost: the same columns for models and assemblies
function madeColumns() {
return [
column('Made', function (i) { return '<span class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</span>'; }, 'made', true),
column('Took', function (i) { return '<span class="small">' + (i.processMs ? esc(duration(i.processMs)) : '') + '</span>'; }, 'slowest', true),
column('CPU', function (i) { return '<span class="small">' + (i.processCpuMs ? esc(duration(i.processCpuMs)) : '') + '</span>'; }, 'cpu', true),
column('RAM (est.)', function (i) { return '<span class="small">' + (i.processMemoryKb ? '~' + esc(megabytes(i.processMemoryKb)) : '') + '</span>'; }, 'memory', true)
];
}
var COLUMNS = {
model: [
column('Icon', function (i) { return iconImg(i, 48); }),
@@ -164,11 +173,8 @@
column('Owner', owner, 'owner'),
column('State', function (i) {
return badge(i.state) + (i.attempts ? ' <span class="small text-body-secondary">' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + '</span>' : '') + waitBadge(i);
}),
column('Made', function (i) { return '<span class="small">' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + '</span>'; }, 'made', true),
column('Took', function (i) { return '<span class="small">' + (i.processMs ? esc(duration(i.processMs)) : '') + '</span>'; }, 'slowest', true),
column('CPU', function (i) { return '<span class="small">' + (i.processCpuMs ? esc(duration(i.processCpuMs)) : '') + '</span>'; }, 'cpu', true),
column('RAM (est.)', function (i) { return '<span class="small">' + (i.processMemoryKb ? '~' + esc(megabytes(i.processMemoryKb)) : '') + '</span>'; }, 'memory', true),
})
].concat(madeColumns(), [
column('Saved', function (i) {
if (!i.trianglesBefore) return '';
var removed = i.trianglesBefore - i.triangles, share = removed / i.trianglesBefore * 100;
@@ -178,17 +184,27 @@
column('Size', function (i) { return '<span class="small">' + (i.bricks ? esc(i.bricks) + ' bricks<br>' + esc(i.triangles.toLocaleString()) + ' triangles' : '') + '</span>'; }, 'bricks', true),
column('File', function (i) { return '<span class="small" title="' + esc(i.detail || '') + '">' + esc(fileName(i)) + '</span>' + errorText(i); }, 'name'),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.id) + '</div>'; })
],
]),
// The models' columns but Saved: an assembly's ID and owner are its newest build's, its size its modules, its
// file its type and modules, and it is where the builds that use it are (References)
modular: [
column('Icon', function (i) { return iconImg(i, 48); }),
column('Newest build', function (i) { return '<span class="small">' + esc(i.newestBuild) + '</span>'; }, 'newest', true),
column('Type', function (i) { return esc(i.kindLabel || i.buildType); }),
column('Modules', function (i) { return '<span class="small">' + esc(moduleNames(i)) + '</span><div><code>' + esc(i.key) + '</code></div>' + errorText(i); }, 'name'),
column('State', function (i) { return badge(i.state); }),
column('Builds', function (i) { return esc(i.uses); }, 'references', true),
column('Owners', function (i) { return esc(i.owners); }),
column('ID', function (i) { return '<span class="small" title="The newest build">' + esc(i.newestBuild) + '</span>'; }, 'newest', true),
column('Owner', function (i) {
return owner(i) + (i.owners > 1 ? '<div class="small text-body-secondary">and ' + esc(i.owners - 1) + ' other' + (i.owners === 2 ? '' : 's') + '</div>' : '');
}, 'owner'),
column('State', function (i) { return badge(i.state); })
].concat(madeColumns(), [
column('Size', function (i) { return '<span class="small">' + esc((i.moduleList || []).length) + ' modules</span>'; }, 'modules', true),
column('File', function (i) {
return '<span class="small">' + esc(i.kindLabel || i.buildType) + ': ' + esc(moduleNames(i)) + '</span><div><code class="small">' + esc(i.key) + '</code></div>' + errorText(i);
}, 'name'),
column('Where', function (i) {
return '<span class="small">Used by ' + esc(i.uses) + ' build' + (i.uses === 1 ? '' : 's') + ' of ' + esc(i.owners) + ' owner' + (i.owners === 1 ? '' : 's') +
'</span><div><button type="button" class="btn btn-link btn-sm p-0" data-preview="' + esc(i.id) + '">References</button></div>';
}, 'references', true),
column('', function (i) { return '<div class="text-end text-nowrap">' + actions(i, i.iconBuild) + '</div>'; })
]
])
};
// The sort a DataTables order asks for, as /api/ugc's sort= and reverse=
function orderQuery(columns, order) {

View File

@@ -77,7 +77,7 @@
<div id="modelTableBox"><table class="table table-sm align-middle mb-0 w-100 table-stack" id="ugcModelTable">
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Took</th><th>CPU</th><th>RAM (est.)</th><th>Saved</th><th>Size</th><th>File</th><th></th></tr></thead></table></div>
<div id="assemblyTableBox" class="d-none"><table class="table table-sm align-middle mb-0 w-100 table-stack" id="ugcAssemblyTable">
<thead><tr><th>Icon</th><th>Newest build</th><th>Type</th><th>Modules</th><th>State</th><th>Builds</th><th>Owners</th><th></th></tr></thead></table></div>
<thead><tr><th>Icon</th><th>ID</th><th>Owner</th><th>State</th><th>Made</th><th>Took</th><th>CPU</th><th>RAM (est.)</th><th>Size</th><th>File</th><th>Where</th><th></th></tr></thead></table></div>
</div></div>
<div class="d-flex flex-wrap gap-2 align-items-center mt-2" id="pagerBar">
<nav aria-label="Pages"><ul class="pagination pagination-sm mb-0" id="pager"></ul></nav>

View File

@@ -781,15 +781,20 @@ The UGC Server page (`/ugc`, Server Admin menu; `properties_view` to look, the n
things again and to save icon values) reads the database: counts per state for models and for cars and rockets, and the
items as a gallery of their icons or a list. Player models are listed one by one (owner, state, attempts, last attempt,
bricks and triangles, file name, failure reason). Cars and rockets are listed as **assemblies**, one per combination of
modules however many builds use it (the UGC server makes one icon per combination): its icon, build type (named after
the type's assembly object in `ModularBuildComponent`), its modules with their names and icons from the CDClient, its
state (made when any build of it is) and how many builds and owners use it.
modules however many builds use it (the UGC server makes one icon per combination). The List view gives both the same
columns and sorts (assemblies have no Saved): Icon, ID and Owner (an assembly's newest build and its creator, and how
many other owners), State (an assembly is made when any build of it is), Made, Took, CPU and RAM (an assembly's: the
latest make of any of its builds, and the cost of the make the UGC server did for it; builds that shared the made icon
cost nothing), Size (bricks and triangles; an assembly's module count), File (the file name; an assembly's build type,
named after the type's assembly object in `ModularBuildComponent`, and its modules) and, for assemblies, how many builds
and owners use it.
Both lists are paged on the server (`GET /api/ugc?kind=model|modular&q=&state=&type=&sort=&page=&size=`, with the
total), with numbered pages, first and last, a page to jump to and a page size kept per user. The search box takes
plain text (names, owners, ids) or field prefixes: `owner:`, `account:`, `property:`, `name:`, `lot:` or `module:` (a
LOT, or for assemblies a module's name), `id:`, `state:` and `kind:` (a car or rocket type). Models sort by newest,
oldest, most bricks, most triangles, owner or file name; assemblies by newest, oldest, most builds or name. The kind,
oldest, most bricks, most triangles, recently made, slowest, most CPU, most RAM, most triangles saved, owner or file
name; assemblies the same (most modules for most bricks, no triangles or saved) and by most builds. The kind,
search, filters, sort, page and view are kept in the address, so Back and Forward and shared links work. The search
is the UGC search's SQL (`UgcLookupSql`, the same on MySQL and SQLite, `IUgcLookup::ListUgc`); assemblies are grouped
from the builds (`UgcAssemblies`). Buttons make one item, the failed ones or everything again (these only reset the

View File

@@ -283,6 +283,55 @@ TEST(UgcAssemblies, GroupsBuildsByTheirModules) {
EXPECT_FALSE(UgcAssemblies::ParseSort("nonsense"));
}
// An assembly has the models' columns: its newest build's owner, and its combination's make (when, and what the make
// cost: the build it was made for, not the ones that shared it after)
TEST(UgcAssemblies, HasTheModelsColumns) {
using State = IUgc::eProcessState;
const auto build = [](LWOOBJID id, const std::string& owner, int64_t at, uint32_t ms) {
IUgcLookup::UgcEntry e;
e.kind = IUgcLookup::eUgcKind::MODULAR;
e.id = id;
e.characterId = id * 10;
e.characterName = owner;
e.accountId = static_cast<uint32_t>(id);
e.accountName = owner + "Account";
e.detail = id < 20 ? "1:4713+1:4714+1:4715" : "1:8129+1:8130";
e.state = State::DONE;
e.processedAt = at;
e.processMs = ms;
e.processCpuMs = ms / 2;
e.processMemoryKb = ms * 10;
return e;
};
const std::map<uint32_t, UgcAssemblies::ModuleInfo> modules = {
{ 4713, { 3, "Nose" } }, { 4714, { 3, "Cockpit" } }, { 4715, { 3, "Engine" } }, { 8129, { 6, "Chassis" } }, { 8130, { 6, "Wheels" } },
};
auto list = UgcAssemblies::Group({ build(10, "Zed", 100, 900), build(11, "Amy", 300, 0), build(20, "Bob", 200, 50) }, modules);
const auto rocket = std::find_if(list.begin(), list.end(), [](const auto& a) { return a.key == "4713-4714-4715"; });
ASSERT_NE(rocket, list.end());
EXPECT_EQ(rocket->characterName, "Amy"); // the newest build's
EXPECT_EQ(rocket->characterId, 110);
EXPECT_EQ(rocket->accountName, "AmyAccount");
EXPECT_EQ(rocket->processedAt, 300); // the latest
EXPECT_EQ(rocket->processMs, 900u); // build 11 shared the made icon and cost nothing
EXPECT_EQ(rocket->processCpuMs, 450u);
EXPECT_EQ(rocket->processMemoryKb, 9000u);
UgcAssemblies::Sort(list, UgcAssemblies::eSort::OWNER, modules);
EXPECT_EQ(list.front().characterName, "Amy");
UgcAssemblies::Sort(list, UgcAssemblies::eSort::MADE, modules);
EXPECT_EQ(list.front().processedAt, 300);
UgcAssemblies::Sort(list, UgcAssemblies::eSort::SLOWEST, modules);
EXPECT_EQ(list.front().processMs, 900u);
UgcAssemblies::Sort(list, UgcAssemblies::eSort::SLOWEST, modules, true);
EXPECT_EQ(list.front().processMs, 50u);
UgcAssemblies::Sort(list, UgcAssemblies::eSort::MODULES, modules);
EXPECT_EQ(list.front().lots.size(), 3u);
EXPECT_EQ(UgcAssemblies::ParseSort("cpu"), UgcAssemblies::eSort::CPU);
EXPECT_EQ(UgcAssemblies::ParseSort("memory"), UgcAssemblies::eSort::MEMORY);
EXPECT_EQ(UgcAssemblies::ParseSort("bricks"), UgcAssemblies::eSort::MODULES);
}
TEST_F(UgcLookupSqlTests, FindsByIds) {
EXPECT_EQ(Find("1000", false), (std::set<int64_t>{ 1000 })); // the blueprint id
EXPECT_EQ(Find("7000", false), (std::set<int64_t>{ 1000 })); // the placed model's object id