diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index c7ab77e30..3f00708ac 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -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(Master::SERVER_TRAFFIC, [](const ServerTraffic& report, const SystemAddress&) { Traffic::Ingest(report); }); handlers.On(Master::PROFILE_RESULT, [](const ProfileResult& result, const SystemAddress&) { Performance::IngestProfile(result); }); handlers.On(Master::LIVE_UPDATE_STATUS, [](const LiveUpdateStatus& status, const SystemAddress&) { LiveUpdateRoutes::HandleStatus(status); }); + handlers.On(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(); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index b914c7123..3b29f9804 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -20,6 +20,7 @@ set(DASHBOARDROUTES_SOURCES "EconomyJobs.cpp" "LiveWorld.cpp" "LiveUpdateRoutes.cpp" + "WorldReloadRoutes.cpp" "SettingsRoutes.cpp" "Background.cpp" "Scheduler.cpp" diff --git a/dDashboardServer/routes/WorldReloadRoutes.cpp b/dDashboardServer/routes/WorldReloadRoutes.cpp new file mode 100644 index 000000000..f135b267e --- /dev/null +++ b/dDashboardServer/routes/WorldReloadRoutes.cpp @@ -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([] { 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(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"} }); + }); + } +} diff --git a/dDashboardServer/routes/WorldReloadRoutes.h b/dDashboardServer/routes/WorldReloadRoutes.h new file mode 100644 index 000000000..32f9614dd --- /dev/null +++ b/dDashboardServer/routes/WorldReloadRoutes.h @@ -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(); +} diff --git a/dDashboardServer/templates/instances.jinja2 b/dDashboardServer/templates/instances.jinja2 index be6e6306a..9657801a7 100644 --- a/dDashboardServer/templates/instances.jinja2 +++ b/dDashboardServer/templates/instances.jinja2 @@ -80,6 +80,21 @@ + +{% if can.world_reload %} +
+
+
World files
+ +
+
+

The zone files each running world loaded (.luz, .lvl, .lutriggers, 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. Reload does the same for every instance of a zone now. Files read from the client's packs are not watched.

+

Waiting for the master server…

+
+
+{% endif %} {% endblock %} {% block scripts %} @@ -219,4 +234,56 @@ loadLimits().then(loadZones); })(); +{% if can.world_reload %} + +{% endif %} {% endblock %}