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:
Aaron Kimbrell
2026-09-30 02:44:16 -05:00
parent e054d1f4e4
commit 139df9ab9a
5 changed files with 183 additions and 0 deletions

View File

@@ -66,6 +66,8 @@
#include "ModeratorHelper.h" #include "ModeratorHelper.h"
#include "LiveWorld.h" #include "LiveWorld.h"
#include "LiveUpdateRoutes.h" #include "LiveUpdateRoutes.h"
#include "WorldReloadRoutes.h"
#include "master/WorldFiles.h"
#include "Scenery.h" #include "Scenery.h"
#include "Workers.h" #include "Workers.h"
#include "ReportRoutes.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<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<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<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;
}(); }();
return handlers; return handlers;
@@ -570,6 +573,7 @@ int main(int argc, char** argv) {
ModeratorHelper::RegisterRoutes(); ModeratorHelper::RegisterRoutes();
LiveWorld::RegisterRoutes(); LiveWorld::RegisterRoutes();
LiveUpdateRoutes::RegisterRoutes(); LiveUpdateRoutes::RegisterRoutes();
WorldReloadRoutes::RegisterRoutes();
Inspector::RegisterRoutes(); Inspector::RegisterRoutes();
CaptureReplay::RegisterRoutes(); CaptureReplay::RegisterRoutes();
RegisterCDClientBrowserRoutes(); RegisterCDClientBrowserRoutes();

View File

@@ -20,6 +20,7 @@ set(DASHBOARDROUTES_SOURCES
"EconomyJobs.cpp" "EconomyJobs.cpp"
"LiveWorld.cpp" "LiveWorld.cpp"
"LiveUpdateRoutes.cpp" "LiveUpdateRoutes.cpp"
"WorldReloadRoutes.cpp"
"SettingsRoutes.cpp" "SettingsRoutes.cpp"
"Background.cpp" "Background.cpp"
"Scheduler.cpp" "Scheduler.cpp"

View 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"} });
});
}
}

View 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();
}

View File

@@ -80,6 +80,21 @@
</div> </div>
</div> </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&hellip;</p></div>
</div>
</div>
{% endif %}
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
@@ -219,4 +234,56 @@
loadLimits().then(loadZones); loadLimits().then(loadZones);
})(); })();
</script> </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 %} {% endblock %}