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>