Files
DarkflameServer/dDashboardServer/templates/index.jinja2
Aaron Kimbrell 3c004fd36d feat(dashboard): a 3D button beside every property link
fmt.property(id, name) links a property's page and adds a small 3D button
straight to its 3D view. Used everywhere a property is linked: the
properties list and top slots, moderation, character pages' related
properties, UGC search and the UGC page's placements, the home page, the
chat log, player reports and report notes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 22:31:25 -05:00

332 lines
19 KiB
Django/Jinja

{% extends "base.jinja2" %}
{% block title %}Dashboard - DarkflameServer{% endblock %}
{% block content %}
<h2 class="mb-1">Welcome back, {{ username }}</h2>
<p class="text-body-secondary mb-4">Server status updates live. <a href="/account">Manage your account</a></p>
{% if can.own_characters %}
<div class="card mb-4">
<div class="card-header"><h5 class="mb-0">Your characters</h5></div>
<div class="card-body">
{% if length(my_characters) == 0 %}
<p class="text-body-secondary mb-0">You don't have any characters yet. Create one in the game!</p>
{% else %}
<div class="row g-3">
{% for char in my_characters %}
<div class="col-sm-6 col-lg-4">
<a href="/characters/{{ char.id }}" class="card h-100 text-decoration-none">
<div class="card-body">
<h6 class="card-title mb-1">{{ char.name }}</h6>
<div class="small text-body-secondary char-login" data-ts="{{ char.last_login }}"></div>
</div>
</a>
</div>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% endif %}
<div class="row g-3 mb-4">
{% include "server_status.jinja2" %}
{% include "statistics.jinja2" %}
{% if can.health_view %}
<div class="col-md-6 col-lg-4{% if not ugc.enabled %} d-none{% endif %}" id="ugcCard">
<div class="card h-100">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">UGC Server</h5>
{% if can.properties_view %}<a class="btn btn-sm btn-outline-secondary" href="/ugc">Models</a>{% endif %}
</div>
<div class="card-body" id="ugcCardBody"><p class="text-body-secondary mb-0">Loading&hellip;</p></div>
</div>
</div>
{% endif %}
</div>
{% include "world_instances.jinja2" %}
{% if can.server_live_update %}
<div class="row g-3 mt-1">
<div class="col-12" id="liveUpdate">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">Live update</h5>
<span id="liveUpdatePhase"></span>
</div>
<div class="card-body">
<p class="small text-body-secondary">Moves everything onto the server binaries on disk now, without a restart: the UGC server finishes its jobs
and restarts, auth restarts (logins pause for a few seconds), chat hands its teams over and restarts, every world instance is
replaced by a new one and its players are moved there (the game's Mythran maintenance warning, then a short loading screen), and
the dashboard restarts last. Properties are saved before their new instance loads them; races and minigames finish first.
Master keeps running: updating master itself takes a normal restart.</p>
<div class="d-flex flex-wrap gap-2 align-items-center mb-2" id="liveUpdateForm">
<div class="input-group input-group-sm" style="max-width: 20rem">
<span class="input-group-text">Warn players</span>
<select class="form-select" id="liveUpdateWarn">
<option value="" selected>as set (live_update_warn_seconds)</option><option value="0">not at all</option><option value="10">10 seconds before</option>
<option value="30">30 seconds before</option><option value="60">1 minute before</option><option value="120">2 minutes before</option>
</select>
</div>
<button class="btn btn-sm btn-warning" id="liveUpdateStart">Start live update</button>
<button class="btn btn-sm btn-outline-danger d-none" id="liveUpdateCancel">Cancel</button>
</div>
<p class="mb-2" id="liveUpdateSummary"></p>
<div class="table-responsive d-none" id="liveUpdateTableWrap">
<table class="table table-sm align-middle mb-0">
<thead><tr><th>What</th><th>Instance</th><th>Players</th><th>State</th><th></th></tr></thead>
<tbody id="liveUpdateRows"></tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{% if can.server_announce or can.server_restart %}
<div class="row g-3 mt-1">
{% if can.server_announce %}
<div class="col-lg-6">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Announcement</h5></div>
<div class="card-body">
<p class="small text-body-secondary">Pops up for everyone online and is posted in their chat.</p>
<div class="mb-2"><input class="form-control form-control-sm" id="announceTitle" maxlength="100" placeholder="Title (optional)"></div>
<div class="mb-2"><textarea class="form-control form-control-sm" id="announceMessage" rows="2" maxlength="1000" placeholder="Message"></textarea></div>
<button class="btn btn-sm btn-primary" id="announceSend">Send to everyone online</button>
</div>
</div>
</div>
{% endif %}
{% if can.server_restart %}
<div class="col-lg-6" id="restart">
<div class="card h-100">
<div class="card-header"><h5 class="mb-0">Scheduled restart</h5></div>
<div class="card-body">
<div id="restartScheduled" class="d-none">
<p class="mb-2" id="restartInfo"></p>
<button class="btn btn-sm btn-outline-danger" id="restartCancel">Cancel restart</button>
</div>
<div id="restartForm">
<p class="small text-body-secondary">Players are warned in game at 60, 30, 15, 10, 5, 2 and 1 minutes and 30 and 10 seconds before.
The server shuts down cleanly; your process supervisor (systemd, Docker restart policy...) starts it again.</p>
<div class="input-group input-group-sm mb-2">
<span class="input-group-text">In</span>
<select class="form-select" id="restartSeconds">
<option value="30">30 seconds</option><option value="60">1 minute</option><option value="300">5 minutes</option><option value="600">10 minutes</option><option value="900" selected>15 minutes</option>
<option value="1800">30 minutes</option><option value="3600">1 hour</option><option value="7200">2 hours</option>
</select>
</div>
<div class="mb-2"><input class="form-control form-control-sm" id="restartReason" maxlength="300" placeholder="Reason shown to players (optional)"></div>
<button class="btn btn-sm btn-warning" id="restartSchedule">Schedule restart</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{% endif %}
{% endblock %}
{% block scripts %}
<script>
$(function () {
var announce = document.getElementById('announceSend');
if (announce) announce.addEventListener('click', function () {
api.action('/api/server/announce', { title: document.getElementById('announceTitle').value.trim(), message: document.getElementById('announceMessage').value.trim() }, 'Announcement sent')
.then(function () { document.getElementById('announceMessage').value = ''; }).catch(function () {});
});
var schedule = document.getElementById('restartSchedule');
if (schedule) {
var showRestart = function (restart) {
var scheduled = restart && restart.at;
document.getElementById('restartScheduled').classList.toggle('d-none', !scheduled);
document.getElementById('restartForm').classList.toggle('d-none', !!scheduled);
if (scheduled) document.getElementById('restartInfo').textContent = 'Restarting at ' + new Date(restart.at * 1000).toLocaleTimeString() +
(restart.by ? ' (set by ' + restart.by + ')' : '') + (restart.reason ? ': ' + restart.reason : '');
};
schedule.addEventListener('click', function () {
if (!confirm('Schedule a restart? Everyone online will be warned and then disconnected.')) return;
api.action('/api/server/restart', { seconds: parseInt(document.getElementById('restartSeconds').value, 10), reason: document.getElementById('restartReason').value.trim() }, 'Restart scheduled')
.then(function (r) { showRestart(r.restart); }).catch(function () {});
});
document.getElementById('restartCancel').addEventListener('click', function () {
api.action('/api/server/restart/cancel', {}, 'Restart cancelled').then(function () { showRestart(null); }).catch(function () {});
});
api.get('/api/server/restart').then(showRestart);
if (window.Live) Live.onStatus(function (s) { showRestart(s.restart); });
}
// Live update: start or cancel it, and show every server and world as it goes (pushed on the live_update topic)
if (document.getElementById('liveUpdate')) {
var stateColors = { pending: 'secondary', preparing: 'info', starting: 'info', ready: 'primary', waiting: 'warning', draining: 'warning',
stopping: 'info', stopped: 'success', failed: 'danger', skipped: 'secondary' };
var stateNames = { pending: 'Waiting its turn', preparing: 'Saving property', starting: 'Starting new one', ready: 'New one ready', waiting: 'Waiting for players',
draining: 'Moving players', stopping: 'Stopping old one', stopped: 'Done', failed: 'Failed', skipped: 'Skipped' };
var phaseColors = { idle: 'secondary', running: 'warning', cancelling: 'warning', done: 'success', failed: 'danger', cancelled: 'secondary' };
var kindNames = { database: 'Database migrations', ugc: 'UGC server', auth: 'Auth server', chat: 'Chat server', dashboard: 'Dashboard' };
var showLiveUpdate = function (u) {
if (!u) return;
var running = !!u.running;
document.getElementById('liveUpdateStart').classList.toggle('d-none', running);
document.getElementById('liveUpdateWarn').closest('.input-group').classList.toggle('d-none', running);
document.getElementById('liveUpdateCancel').classList.toggle('d-none', !running || u.phase === 'cancelling');
document.getElementById('liveUpdatePhase').innerHTML = u.updateId ? fmt.badge(u.phase, phaseColors[u.phase] || 'secondary') : '';
var summary = '';
if (u.updateId) {
summary = 'Started ' + esc(fmt.unix(u.startedAt)) + (u.by ? ' by ' + esc(u.by) : '') + (u.finishedAt ? ', finished ' + esc(fmt.unix(u.finishedAt)) : '') +
'. ' + esc(u.done) + ' of ' + esc(u.total) + ' done' + (u.failed ? ', ' + esc(u.failed) + ' failed' : '') + '.';
}
if (u.message) summary += (summary ? ' ' : '') + '<strong>' + esc(u.message) + '</strong>';
document.getElementById('liveUpdateSummary').innerHTML = summary;
var rows = (u.units || []).map(function (unit) {
var what = unit.kind === 'world' ? fmt.zone(unit.zone, unit.zoneName) + (unit.clone ? ' <span class="small text-body-secondary">property clone ' + esc(unit.clone) + '</span>' : '') +
(unit.isPrivate ? ' ' + fmt.badge('Private', 'warning') : '') : esc(kindNames[unit.kind] || unit.kind);
var instance = unit.kind === 'world' ? esc(unit.instance) + (unit.replacement ? ' &rarr; ' + esc(unit.replacement) : '') : '';
var players = unit.kind === 'world' ? esc(unit.players) + (unit.moved ? ' <span class="small text-body-secondary">(' + esc(unit.moved) + ' moved)</span>' : '') : '';
return '<tr><td>' + what + '</td><td>' + instance + '</td><td>' + players + '</td><td>' + fmt.badge(stateNames[unit.state] || unit.state, stateColors[unit.state] || 'secondary') +
'</td><td class="small">' + esc(unit.message || '') + '</td></tr>';
});
document.getElementById('liveUpdateRows').innerHTML = rows.join('');
document.getElementById('liveUpdateTableWrap').classList.toggle('d-none', rows.length === 0);
};
document.getElementById('liveUpdateStart').addEventListener('click', function () {
if (!confirm('Start a live update onto the binaries on disk now? Everyone online is moved to new instances (a short loading screen), and the dashboard restarts at the end.')) return;
var warn = document.getElementById('liveUpdateWarn').value;
api.action('/api/server/live_update', warn === '' ? {} : { warnSeconds: parseInt(warn, 10) }, 'Live update requested').catch(function () {});
});
document.getElementById('liveUpdateCancel').addEventListener('click', function () {
if (!confirm('Cancel the live update? Nothing new is started; what is under way finishes.')) return;
api.action('/api/server/live_update/cancel', {}, 'Cancelling').catch(function () {});
});
var loadLiveUpdate = function () { api.get('/api/server/live_update').then(function (d) { if (d.success) showLiveUpdate(d.liveUpdate); }).catch(function () {}); };
loadLiveUpdate();
if (window.Live) Live.onTopic('live_update', showLiveUpdate);
}
// Staff get the detailed list (address, which property a clone is); everyone else the basic one from the status
var detailedWorlds = DASH.can('players_view');
// Worlds master launched that haven't connected yet, and those shutting down
function stateBadge(row) {
if (row.state === 'starting') return ' ' + fmt.badge('Starting', 'info');
if (row.state === 'stopping') return ' ' + fmt.badge('Shutting down', 'secondary');
if (row.state === 'draining') return ' ' + fmt.badge('Moving players', 'warning');
return '';
}
function withPending(data) {
return (data.worlds || []).concat((data.startingWorlds || []).map(function (w) {
return { zoneName: w.zoneName, mapID: w.mapID, instanceID: w.property ? '' : w.instanceID, cloneID: w.cloneID || 0, players: 0, state: w.state };
}));
}
var worlds = $('#worldInstancesTable').DataTable({
paging: false,
searching: false,
info: false,
order: [[0, 'asc']],
columns: [
{ data: 'zoneName', render: function (d, t, row) {
// Staff who read chat can open this world on the Chat Log: its chat, players and stats
var name = DASH.can('chat_view') && row.mapID ? fmt.link('/chat_log#world=' + row.mapID + ':' + row.instanceID, d) : esc(d);
return name + ' <span class="text-body-secondary">(' + esc(row.mapID) + ')</span>' + stateBadge(row); } },
// Without players_view, property instances come merged per zone ({properties: n}) and without clone IDs
{ data: 'instanceID', render: function (d, t, row) { return row.properties ? esc(row.properties) + ' propert' + (row.properties === 1 ? 'y' : 'ies') : esc(d); } }
].concat(detailedWorlds ? [{ data: 'cloneID' }, { data: 'propertyName', defaultContent: '', render: function (d, t, row) {
if (!row.cloneID) return '<span class="text-body-secondary">-</span>';
return (row.propertyId ? fmt.property(row.propertyId, d) : esc(d || '')) + (row.ownerId ? '<div class="small">by ' + fmt.character(row.ownerId, row.ownerName) + '</div>' : '');
} }] : []).concat([
{ data: 'players', render: function (d) { return fmt.badge(d, 'primary'); } }
]).concat(detailedWorlds ? [{ data: 'isPrivate', render: function (d) { return d ? fmt.badge('Private', 'warning') : fmt.badge('Public', 'success'); } }, { data: 'port', defaultContent: '', render: function (d, t, row) { return '<code class="small">' + esc((row.ip || '') + ':' + d) + '</code>'; } }] : []).concat(DASH.can('worlds_manage') ? [{ data: null, orderable: false, className: 'text-end', render: function (d, t, row) {
if (row.state) return '';
return '<button class="btn btn-sm btn-outline-danger text-nowrap" data-shutdown-zone="' + esc(row.mapID) + '" data-shutdown-instance="' + esc(row.instanceID) + '">Shut down</button>';
} }] : []),
language: { emptyTable: 'No active world instances' }
});
var loadDetailedWorlds = Live.throttle(function () {
api.get('/api/worlds').then(function (d) { if (d.success) worlds.clear().rows.add(d.worlds).draw(false); });
}, 2000);
$('#worldInstancesTable').on('click', '[data-shutdown-zone]', function () {
var zone = parseInt(this.dataset.shutdownZone, 10), instance = parseInt(this.dataset.shutdownInstance, 10);
var row = worlds.rows().data().toArray().filter(function (w) { return w.mapID === zone && w.instanceID === instance; })[0];
var players = row ? row.players : 0;
if (!confirm('Shut down ' + (row ? row.zoneName : 'zone ' + zone) + ' instance ' + instance + '?' + (players ? ' Its ' + players + ' player(s) are saved and disconnected.' : ''))) return;
api.action('/api/worlds/shutdown', { zone: zone, instance: instance }, 'Shutting down').catch(function () {});
});
function setStatus(id, online) {
var el = document.getElementById(id);
if (!el) return;
el.textContent = online ? 'Online' : 'Offline';
el.className = 'badge text-bg-' + (online ? 'success' : 'danger');
}
function setText(id, value) {
var el = document.getElementById(id);
if (el && value !== undefined) el.textContent = value;
}
// The UGC server's card (staff with health_view): its state, the database queue and what its last report said
var ugcLoadedAt = 0;
function loadUgc(force) {
var card = document.getElementById('ugcCard');
if (!card || (!force && Date.now() - ugcLoadedAt < 15000)) return;
ugcLoadedAt = Date.now();
api.get('/api/servers/ugc').then(function (d) {
if (!d.success) return;
card.classList.toggle('d-none', !d.enabled && !d.online);
var g = d.gauges || {}, c = d.counts || { model: {}, modular: {} };
var sum = function (state) { return (c.model[state] || 0) + (c.modular[state] || 0); };
var line = function (label, value, id) {
return '<div class="d-flex justify-content-between align-items-center mb-2"><span>' + label + '</span><span' + (id ? ' id="' + id + '"' : '') + '>' + value + '</span></div>';
};
var status = fmt.badge(d.online ? 'Online' : 'Offline', d.online ? 'success' : 'danger') +
(d.online && d.since ? ' <span class="small text-body-secondary">up ' + esc(fmt.duration(Date.now() / 1000 - d.since)) + '</span>' : '');
var storage = g.ugc_stored_bytes !== undefined ? esc(fmt.bytes(g.ugc_stored_bytes)) + (g.ugc_max_storage_bytes ? ' <span class="text-body-secondary">of ' + esc(fmt.bytes(g.ugc_max_storage_bytes)) + '</span>' : '') : '<span class="text-body-secondary">-</span>';
var workers = g.workers_threads !== undefined ? esc(g.workers_busy + ' / ' + g.workers_threads) + (g.workers_queued ? ' <span class="text-body-secondary">(' + esc(g.workers_queued) + ' queued)</span>' : '') : '<span class="text-body-secondary">-</span>';
document.getElementById('ugcCardBody').innerHTML =
line('Status', status) +
line('Waiting', fmt.badge(sum('pending'), sum('pending') ? 'warning' : 'secondary')) +
line('Made', fmt.badge(sum('done'), 'primary')) +
line('Failed', fmt.badge(sum('failed'), sum('failed') ? 'danger' : 'secondary')) +
line('Workers busy', workers) +
line('Storage used', storage).replace('mb-2"', 'mb-0"');
}).catch(function () {});
}
function update(data) {
if (data.auth) setStatus('auth-status', data.auth.online);
if (data.chat) setStatus('chat-status', data.chat.online);
if (data.ugc) {
setStatus('ugc-status', data.ugc.online);
var ugcRow = document.getElementById('ugc-status-row');
if (ugcRow) ugcRow.classList.toggle('d-none', !data.ugc.enabled);
loadUgc();
}
if (data.worlds) {
setText('world-count', data.stats && data.stats.worlds !== undefined ? data.stats.worlds : data.worlds.length);
if (detailedWorlds) loadDetailedWorlds();
else worlds.clear().rows.add(withPending(data)).draw(false);
}
if (data.stats) {
setText('online-players', data.stats.onlinePlayers);
setText('total-accounts', data.stats.totalAccounts);
setText('total-characters', data.stats.totalCharacters);
}
}
document.querySelectorAll('.char-login').forEach(function (el) {
var ts = parseInt(el.getAttribute('data-ts'), 10);
el.textContent = ts > 0 ? 'Last played ' + fmt.unix(ts) : 'Never played';
});
api.get('/api/status').then(update).catch(function () {});
loadUgc(true);
Live.onStatus(update);
});
</script>
{% endblock %}