mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-15 09:04:10 +00:00
With new binaries in place, master moves everything onto them while the server keeps running (the dashboard's Live update, /liveupdate or SIGUSR2 to master): - database migrations of the new build first; a failure stops there - UGC finishes the jobs it is running (queued rows stay pending), auth restarts, chat hands its teams to master for the next chat server; master starts the new processes and retries ones that don't come back - once the new chat server is up (CHAT_SERVER_READY) every world connects at once and sends its players again (LoginSessionNotify resync, no login logged) - every world instance is replaced with an instance migration: public worlds and private ones (same password) at once, properties after the old instance saved and froze the property (MIGRATE_PREPARE: no building, claiming or saving there any more), activity zones and character select once their players left or after a wait; empty instances just stop, zones in prestart_worlds get a new one first - the dashboard restarts last and picks the status up again Players land where they stood (position carried in CarriedPlayerState, also on properties and Moon Base). Draining instances get no new players (InstanceMigration::AcceptsNewPlayers) and show as "Moving players" in the world list. The order lives in LiveUpdateMachine.h without master state and is unit tested; master's glue is LiveUpdateCoordinator. Master itself is not replaced. Message IDs are appended only. 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.link('/properties/' + row.propertyId, d || '(unnamed)') : 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 %}
|