mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 10:53:44 +00:00
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>
332 lines
19 KiB
Django/Jinja
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…</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 ? ' → ' + 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 %}
|