mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-10-02 02:43:44 +00:00
feat(dashboard): world files card with changed-on-disk badges and Reload
On the Instances page, behind world_reload; zone names from GameText. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -66,6 +66,8 @@
|
||||
#include "ModeratorHelper.h"
|
||||
#include "LiveWorld.h"
|
||||
#include "LiveUpdateRoutes.h"
|
||||
#include "WorldReloadRoutes.h"
|
||||
#include "master/WorldFiles.h"
|
||||
#include "Scenery.h"
|
||||
#include "Workers.h"
|
||||
#include "ReportRoutes.h"
|
||||
@@ -361,6 +363,7 @@ namespace {
|
||||
handlers.On<ServerTraffic>(Master::SERVER_TRAFFIC, [](const ServerTraffic& report, const SystemAddress&) { Traffic::Ingest(report); });
|
||||
handlers.On<ProfileResult>(Master::PROFILE_RESULT, [](const ProfileResult& result, const SystemAddress&) { Performance::IngestProfile(result); });
|
||||
handlers.On<LiveUpdateStatus>(Master::LIVE_UPDATE_STATUS, [](const LiveUpdateStatus& status, const SystemAddress&) { LiveUpdateRoutes::HandleStatus(status); });
|
||||
handlers.On<WorldFilesStatus>(Master::WORLD_FILES_STATUS, [](const WorldFilesStatus& status, const SystemAddress&) { WorldReloadRoutes::HandleStatus(status); });
|
||||
return handlers;
|
||||
}();
|
||||
return handlers;
|
||||
@@ -570,6 +573,7 @@ int main(int argc, char** argv) {
|
||||
ModeratorHelper::RegisterRoutes();
|
||||
LiveWorld::RegisterRoutes();
|
||||
LiveUpdateRoutes::RegisterRoutes();
|
||||
WorldReloadRoutes::RegisterRoutes();
|
||||
Inspector::RegisterRoutes();
|
||||
CaptureReplay::RegisterRoutes();
|
||||
RegisterCDClientBrowserRoutes();
|
||||
|
||||
@@ -20,6 +20,7 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"EconomyJobs.cpp"
|
||||
"LiveWorld.cpp"
|
||||
"LiveUpdateRoutes.cpp"
|
||||
"WorldReloadRoutes.cpp"
|
||||
"SettingsRoutes.cpp"
|
||||
"Background.cpp"
|
||||
"Scheduler.cpp"
|
||||
|
||||
92
dDashboardServer/routes/WorldReloadRoutes.cpp
Normal file
92
dDashboardServer/routes/WorldReloadRoutes.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include "WorldReloadRoutes.h"
|
||||
|
||||
#include "FdbSnapshot.h"
|
||||
#include "Game.h"
|
||||
#include "GameText.h"
|
||||
#include "MasterPackets.h"
|
||||
#include "Permissions.h"
|
||||
#include "RouteUtils.h"
|
||||
#include "Web.h"
|
||||
#include "dServer.h"
|
||||
#include "eHTTPMethod.h"
|
||||
#include "master/WorldFiles.h"
|
||||
|
||||
using namespace RouteUtils;
|
||||
|
||||
namespace {
|
||||
constexpr const char* TOPIC = "world_files";
|
||||
constexpr const char* PERMISSION = "world_reload";
|
||||
|
||||
WorldFilesStatus g_Status;
|
||||
bool g_Received = false;
|
||||
|
||||
nlohmann::json ToJson(const WorldFilesStatus& status) {
|
||||
nlohmann::json zones = nlohmann::json::array();
|
||||
for (const auto& zone : status.zones) {
|
||||
nlohmann::json files = nlohmann::json::array();
|
||||
bool changed = false;
|
||||
for (const auto& file : zone.files) {
|
||||
changed = changed || file.changed;
|
||||
files.push_back({ {"kind", ZoneFileLog::KindName(file.disk.kind)}, {"path", file.disk.path}, {"packed", file.disk.packed},
|
||||
{"size", file.disk.size}, {"hash", FdbSnapshot::HashText(file.disk.hash)}, {"hashed", file.hashed},
|
||||
{"missing", file.missing}, {"changed", file.changed} });
|
||||
}
|
||||
nlohmann::json instances = nlohmann::json::array();
|
||||
for (const auto& instance : zone.instances) {
|
||||
instances.push_back({ {"instance", instance.instanceId}, {"clone", instance.cloneId}, {"players", instance.players},
|
||||
{"stale", instance.stale}, {"reloading", instance.reloading} });
|
||||
}
|
||||
zones.push_back({ {"zone", zone.zoneId}, {"zoneName", GameText::ZoneName(zone.zoneId)}, {"changed", changed},
|
||||
{"files", files}, {"instances", instances}, {"message", zone.message} });
|
||||
}
|
||||
return { {"received", g_Received}, {"watching", status.watching}, {"watchSeconds", status.watchSeconds},
|
||||
{"seamless", status.seamless}, {"zones", zones} };
|
||||
}
|
||||
}
|
||||
|
||||
namespace WorldReloadRoutes {
|
||||
void HandleStatus(const WorldFilesStatus& status) {
|
||||
g_Status = status;
|
||||
g_Received = true;
|
||||
auto json = ToJson(status);
|
||||
Game::web.SendWSMessage(TOPIC, json);
|
||||
}
|
||||
|
||||
nlohmann::json StatusJson() {
|
||||
return ToJson(g_Status);
|
||||
}
|
||||
|
||||
void RegisterRoutes() {
|
||||
Game::web.RegisterWSSubscription(TOPIC, std::function<uint8_t()>([] { return Permissions::Level(PERMISSION); }), PERMISSION);
|
||||
|
||||
Route(eHTTPMethod::GET, "/api/worlds/files", Perm(PERMISSION),
|
||||
"Each running zone's data files (.luz, .lvl, .lutriggers, terrain, navmesh) as its worlds loaded them, whether they changed on disk since, "
|
||||
"and which instances loaded an older version (also pushed on the world_files socket topic)",
|
||||
[](HTTPReply& reply, const HTTPContext&) {
|
||||
JsonSuccess(reply, { {"worldFiles", StatusJson()} });
|
||||
});
|
||||
|
||||
Route(eHTTPMethod::POST, "/api/worlds/reload", Perm(PERMISSION),
|
||||
"Replace every instance of a zone with a new one on the zone files on disk now: players are moved over (the Mythran shift, or the "
|
||||
"seamless mode when world_reload_seamless=1), empty instances are stopped. Body: {zone, warnSeconds (0-300, optional)}",
|
||||
[](HTTPReply& reply, const HTTPContext& context) {
|
||||
const auto body = ParseBody(context);
|
||||
if (!body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON");
|
||||
WorldReloadRequest request;
|
||||
request.zoneId = body->value("zone", 0u);
|
||||
if (request.zoneId == 0) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Pick a zone");
|
||||
if (body->contains("warnSeconds") && !(*body)["warnSeconds"].is_null()) {
|
||||
const int32_t warn = body->value("warnSeconds", -1);
|
||||
if (warn < 0 || warn > InstanceMigrationRequest::MAX_WARN_SECONDS) {
|
||||
return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Warn players 0 to 300 seconds before they are moved");
|
||||
}
|
||||
request.warnSeconds = static_cast<uint16_t>(warn);
|
||||
}
|
||||
if (!Game::server || !Game::server->GetIsConnectedToMaster()) return JsonError(reply, eHTTPStatusCode::SERVICE_UNAVAILABLE, "Not connected to the master server");
|
||||
request.requestedBy = context.authenticatedUser.substr(0, WorldFiles::MAX_BY);
|
||||
MasterPackets::SendToMaster(request);
|
||||
Audit(context, "world_reload", "Reloaded zone " + std::to_string(request.zoneId) + " (players warned " + std::to_string(request.warnSeconds) + " s before they are moved)");
|
||||
JsonSuccess(reply, { {"message", "Asked master to reload the zone"} });
|
||||
});
|
||||
}
|
||||
}
|
||||
19
dDashboardServer/routes/WorldReloadRoutes.h
Normal file
19
dDashboardServer/routes/WorldReloadRoutes.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#pragma once
|
||||
|
||||
#include "json.hpp"
|
||||
|
||||
struct WorldFilesStatus;
|
||||
|
||||
/**
|
||||
* World hot reload on the dashboard (docs/WorldHotReload.md): the zone files each running world loaded, whether they
|
||||
* changed on disk, and a Reload action per zone. The status comes from master (WORLD_FILES_STATUS) and is pushed to
|
||||
* browsers on the world_files socket topic.
|
||||
*/
|
||||
namespace WorldReloadRoutes {
|
||||
void RegisterRoutes();
|
||||
|
||||
// Master sent a status
|
||||
void HandleStatus(const WorldFilesStatus& status);
|
||||
|
||||
nlohmann::json StatusJson();
|
||||
}
|
||||
@@ -80,6 +80,21 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if can.world_reload %}
|
||||
<div class="card mt-3" id="worldFilesCard">
|
||||
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
|
||||
<h5 class="mb-0">World files</h5>
|
||||
<span class="small text-body-secondary" id="worldFilesWatch"></span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="small text-body-secondary">The zone files each running world loaded (<code>.luz</code>, <code>.lvl</code>, <code>.lutriggers</code>, terrain,
|
||||
navmesh). When one changes on disk, the master server replaces the instances that loaded the old version with new ones and moves their
|
||||
players over. <strong>Reload</strong> does the same for every instance of a zone now. Files read from the client's packs are not watched.</p>
|
||||
<div id="worldFilesZones"><p class="text-body-secondary mb-0">Waiting for the master server…</p></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
@@ -219,4 +234,56 @@
|
||||
loadLimits().then(loadZones);
|
||||
})();
|
||||
</script>
|
||||
{% if can.world_reload %}
|
||||
<script>
|
||||
(function () {
|
||||
var nf = new Intl.NumberFormat();
|
||||
function size(bytes) {
|
||||
if (bytes >= 1048576) return (bytes / 1048576).toFixed(1) + ' MB';
|
||||
if (bytes >= 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return nf.format(bytes) + ' B';
|
||||
}
|
||||
function name(path) { var i = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')); return i < 0 ? path : path.slice(i + 1); }
|
||||
|
||||
function show(status) {
|
||||
var watch = document.getElementById('worldFilesWatch');
|
||||
watch.textContent = !status.received ? '' : status.watching ? 'Checked every ' + status.watchSeconds + ' s' + (status.seamless ? ', seamless moves' : '') : 'Not watched (world_watch_seconds = 0)';
|
||||
var box = document.getElementById('worldFilesZones');
|
||||
if (!status.received) return;
|
||||
if (!status.zones.length) { box.innerHTML = '<p class="text-body-secondary mb-0">No world has reported its files yet.</p>'; return; }
|
||||
box.innerHTML = status.zones.map(function (z) {
|
||||
var stale = z.instances.filter(function (i) { return i.stale; }).length;
|
||||
var head = '<div class="d-flex flex-wrap justify-content-between align-items-center gap-2 mb-1"><h6 class="mb-0">' + fmt.zone(z.zone, z.zoneName) +
|
||||
(z.changed ? ' ' + fmt.badge('changed on disk', 'warning') : '') + '</h6>' +
|
||||
'<button type="button" class="btn btn-outline-primary btn-sm" data-reload-zone="' + esc(z.zone) + '">Reload</button></div>';
|
||||
var instances = '<div class="small mb-1">' + z.instances.map(function (i) {
|
||||
var badges = (i.stale ? ' ' + fmt.badge('old files', 'warning') : '') + (i.reloading ? ' ' + fmt.badge('being replaced', 'info') : '');
|
||||
return '<span class="me-3 text-nowrap">Instance ' + esc(i.instance) + (i.clone ? ' (clone ' + esc(i.clone) + ')' : '') + ', ' +
|
||||
nf.format(i.players) + ' player(s)' + badges + '</span>';
|
||||
}).join('') + '</div>';
|
||||
var message = z.message ? '<div class="small text-body-secondary mb-1">' + esc(z.message) + '</div>' : '';
|
||||
var rows = z.files.map(function (f) {
|
||||
var badges = (f.changed ? fmt.badge('changed on disk', 'warning') + ' ' : '') + (f.missing ? fmt.badge('missing', 'danger') + ' ' : '') +
|
||||
(f.packed ? fmt.badge('in a pack', 'secondary') + ' ' : '');
|
||||
return '<tr><td class="text-nowrap">' + esc(f.kind) + '</td><td><span title="' + esc(f.path) + '">' + esc(name(f.path)) + '</span></td>' +
|
||||
'<td class="text-end text-nowrap">' + esc(size(f.size)) + '</td><td><code class="small">' + esc(f.hash) + '</code></td><td>' + badges + '</td></tr>';
|
||||
}).join('');
|
||||
return '<div class="mb-3">' + head + instances + message + '<div class="table-responsive"><table class="table table-sm align-middle mb-0">' +
|
||||
'<thead><tr><th>Kind</th><th>File</th><th class="text-end">Size</th><th>Hash</th><th></th></tr></thead><tbody>' + rows + '</tbody></table></div></div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function load() { api.get('/api/worlds/files').then(function (d) { if (d.success) show(d.worldFiles); }).catch(function () {}); }
|
||||
|
||||
document.getElementById('worldFilesZones').addEventListener('click', function (e) {
|
||||
var button = e.target.closest('[data-reload-zone]');
|
||||
if (!button) return;
|
||||
if (!confirm('Reload this zone? Every instance of it is replaced with a new one on the files on disk now; players there are warned and moved over (a short loading screen).')) return;
|
||||
api.action('/api/worlds/reload', { zone: +button.dataset.reloadZone }, 'Reload requested').then(load).catch(function () {});
|
||||
});
|
||||
if (window.Live) Live.onTopic('world_files', show);
|
||||
load();
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user