/**
* 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 = '', 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 };
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 mb(bytes) { return (bytes / 1048576).toFixed(0) + ' MB'; }
function countCard(title, c) {
return '
' + esc(title) + '
' +
fmt.badge(c.done + ' made', 'success') + ' ' + fmt.badge(c.pending + ' waiting', 'secondary') + ' ' + fmt.badge(c.failed + ' failed', c.failed ? 'danger' : 'secondary') +
'
';
}
function owner(i) {
return i.characterName ? '' + esc(i.characterName) + ' ' : '' + esc(i.characterId) + ' ';
}
function iconImg(i, size) {
if (i.state !== 'done') return '' + esc((STATES[i.state] || [i.state])[0]) + ' ';
return ' ';
}
function tile(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
return '' + iconImg(i, 128) +
'
' + (i.characterName ? esc(i.characterName) : '' + esc(i.characterId) + ' ') + '
' +
'
' + esc(i.id) + '
' + fmt.badge(s[0], s[1]) + '
';
}
function row(i) {
var s = STATES[i.state] || [i.state, 'secondary'];
var details = kind === 'modular' ? '' + esc(i.modules) + '' : '';
if (i.error) details += '' + esc(i.error) + '
';
var buttons = 'View ' +
(canManage ? 'Make again ' : '');
return '' + iconImg(i, 48) + ' ' + esc(i.id) + ' ' + owner(i) + ' ' + fmt.badge(s[0], s[1]) +
(i.attempts ? ' ' + esc(i.attempts) + ' attempt' + (i.attempts === 1 ? '' : 's') + ' ' : '') + ' ' +
'' + (i.processedAt ? esc(fmt.unix(i.processedAt)) : '') + ' ' + details + ' ' + buttons + ' ';
}
var items = [];
function load() {
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;
items = d.items;
canManage = d.canManage;
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('') || 'Nothing here.
';
else $('rows').innerHTML = d.items.map(row).join('') || 'Nothing here. ';
$('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 '' + esc(label) + ' ' + text + '
' +
(limit > 0 ? '
' : '') + '
';
}
function loadStatus() {
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 enable_ugc_server on, and ugc_internal_url (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 = '' + 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' : '') + '.
' +
'' +
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' : '')) +
'
' + (l.nice ? 'Workers run at priority ' + esc(l.nice) + '.
' : '') +
(last ? 'Last: ' + esc(last.kind) + ' ' + esc(last.id) + (last.ok ? ' made in ' + esc(last.ms) + ' ms' : ' failed') +
(last.message ? ' (' + esc(last.message) + ')' : '') + '
' : '');
}).catch(function () { box.innerHTML = fmt.badge('Unknown', 'secondary') + ' Couldn\'t ask the dashboard for the UGC server\'s status.'; });
}
function remake(body, question) {
if (question && !confirm(question)) return;
body.kind = kind;
api.post('/api/ugc/reprocess', body).then(function (d) {
if (!d.success) { toast(d.error || 'Failed', 'danger'); return; }
toast(d.message, 'success');
load();
});
}
// ---- the viewer ----
function fetchJson(url) {
return fetch(url, { credentials: 'same-origin' }).then(function (r) { return r.ok ? r.json() : null; }).catch(function () { return null; });
}
function statsTable(now, before) {
if (!now) return 'No stats for this model (made before the UGC server wrote them).
';
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 ' (' + (d > 0 ? '+' : '') + d + ') ';
};
var rows = lods.map(function (l, i) {
var o = old[i] || {};
var removed = l.opaqueBefore ? (100 - l.opaqueAfter / l.opaqueBefore * 100).toFixed(0) + '%' : '';
return 'LOD ' + esc(l.lod) + ' ' + esc(l.near) + ' - ' + esc(l.far) + ' ' + esc(l.opaqueBefore) + ' ' + esc(l.opaqueAfter) + diff(l.opaqueAfter, o.opaqueAfter) +
' ' + esc(removed) + ' ' + esc(l.transparent) + ' ' + esc(l.vertices) + diff(l.vertices, o.vertices) + ' ' + esc(l.shapes) + ' ';
}).join('');
var ms = now.ms || {}, set = now.settings || {};
return 'Detail Drawn from - to Opaque triangles ' +
'After removing hidden faces Removed Transparent triangles Vertices Shapes ' + rows + '
' +
'' + 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') + '.
';
}
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 += 'Download the mesh (.nif) ' +
'Download the LXFML ';
}
if (publicUrl) links += 'Open on the UGC server ';
if (canManage) links += 'Make again
';
if (item.error) links += '' + esc(item.error) + '
';
$('previewLinks').innerHTML = links;
var model = kind === 'model';
['nifColumn', 'lxfmlColumn', 'meshControls'].forEach(function (x) { $(x).classList.toggle('d-none', !model); });
$('statsBox').innerHTML = model ? '' : 'Modules: ' + esc(item.modules || '') + '. The game client puts cars and rockets together itself; only the icon is made.
';
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 'LOD ' + esc(l.lod) + (l.far ? ' (' + esc(l.near) + '-' + esc(l.far) + ')' : '') + ' '; }).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;
page = 0;
this.querySelectorAll('[data-kind]').forEach(function (b) { b.classList.toggle('active', b === button); });
load();
});
$('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.');
});
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);
})();