chore(dashboard): remove the Maintenance page

The page held one-off repairs from Nexus Dashboard (approve known pet
names, find/delete orphaned pet names, remove every buff, fix property
clone IDs, list mission rewards without a commendation price). Removed
the page, its sidebar entry, its /api/maintenance/* routes, the
`maintenance` permission and the two database calls only it used
(GetAllPetNames, FixPropertyCloneIds). The scheduled tasks (lift expired
bans, fill in pet owners, approve known pet names) and property model
import/removal stay.

Check: the Admin sidebar group has no Maintenance link; /maintenance is
a 404; the Tasks page still lists "Approve known pet names" and "Fill in
pet owners"; property import still works; the Permissions page no
longer lists "Data maintenance".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-28 23:19:41 -05:00
parent 7440e5744d
commit b0dab03807
14 changed files with 5 additions and 297 deletions

View File

@@ -1,8 +1,6 @@
#include "MaintenanceRoutes.h"
#include <chrono>
#include <map>
#include <set>
#include <sstream>
#include "RouteUtils.h"
@@ -10,10 +8,7 @@
#include "WSRoutes.h"
#include "Background.h"
#include "Scheduler.h"
#include "PlayerActions.h"
#include "ClientAssets.h"
#include "Database.h"
#include "CDClientDatabase.h"
#include "Game.h"
#include "Logger.h"
#include "eHTTPMethod.h"
@@ -30,11 +25,6 @@ namespace {
// Requests are capped at 3 MB by the web server, and JSON escaping makes the body bigger than the LXFML
constexpr size_t MAX_IMPORT_BYTES = 2 * 1024 * 1024;
bool AnyoneOnline() {
const auto state = ServerState::GetServerStateJson();
return state["stats"].value("onlinePlayers", 0u) > 0;
}
// Every pet in every inventory: pet id -> the character holding it
std::map<LWOOBJID, LWOOBJID> PetHolders(GameDatabase& db, size_t& characters) {
std::map<LWOOBJID, LWOOBJID> holders;
@@ -103,132 +93,6 @@ void RegisterMaintenanceTasks() {
}
void RegisterMaintenanceRoutes() {
Route(eHTTPMethod::POST, "/api/maintenance/pet_names/auto_approve", Perm("maintenance"), "Approve pending pet names that were approved for another pet before",
[](HTTPReply& reply, const HTTPContext& context) {
const auto approved = Database::Get()->ApprovePreviouslyApprovedPetNames();
Audit(context, "auto_approve_pet_names", std::to_string(approved) + " pet name(s)");
if (approved) BroadcastTableChanged("pet_names");
JsonSuccess(reply, { {"message", "Approved " + std::to_string(approved) + " pet name(s)"} });
});
Route(eHTTPMethod::POST, "/api/maintenance/pet_names/orphans", Perm("maintenance"),
"Find (or with {delete: true}, remove) pet names whose pet no longer exists. Runs in the background: returns {requestId}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const bool remove = body && body->value("delete", false);
const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(30));
const auto actor = context;
const bool queued = Background::Run("pet_name_orphans", [remove](GameDatabase& db) -> nlohmann::json {
size_t characters = 0;
const auto holders = PetHolders(db, characters);
const auto names = db.GetAllPetNames();
nlohmann::json orphans = nlohmann::json::array();
for (const auto& [id, name] : names) {
if (!holders.contains(id)) orphans.push_back({ {"id", std::to_string(id)}, {"name", name} });
}
nlohmann::json result{
{"characters", characters}, {"petsFound", holders.size()}, {"petNames", names.size()},
{"orphaned", orphans.size()}, {"sample", nlohmann::json(orphans.begin(), orphans.begin() + std::min<size_t>(orphans.size(), 20))}
};
if (!remove) return result;
// A mismatch in how ids are stored would make every name look orphaned; never delete in that case
if (!names.empty() && orphans.size() * 2 > names.size()) {
result["refused"] = "Refusing to delete: more than half of all pet names look orphaned, which suggests an ID mismatch rather than real orphans";
return result;
}
for (const auto& orphan : orphans) db.RejectPetName(std::stoll(orphan["id"].get<std::string>()));
result["deleted"] = orphans.size();
return result;
}, [requestId, actor](nlohmann::json result, const std::string& error) {
if (!error.empty()) return PlayerActions::Finish(requestId, { false, "The scan failed: " + error });
if (result.contains("refused")) return PlayerActions::Finish(requestId, { false, result["refused"].get<std::string>(), result });
if (result.contains("deleted")) {
Audit(actor, "delete_orphaned_pet_names", std::to_string(result["deleted"].get<size_t>()) + " pet name(s)");
BroadcastTableChanged("pet_names");
}
PlayerActions::Finish(requestId, { true, std::to_string(result["orphaned"].get<size_t>()) + " orphaned pet name(s)" +
(result.contains("deleted") ? ", deleted" : ""), result });
});
if (!queued) {
PlayerActions::Finish(requestId, { false, "This scan is already running" });
return JsonError(reply, eHTTPStatusCode::CONFLICT, "This scan is already running");
}
JsonSuccess(reply, { {"requestId", requestId} });
});
Route(eHTTPMethod::POST, "/api/maintenance/remove_buffs", Perm("maintenance"),
"Remove every buff from every character (only while nobody is online). Body: {dry_run}. Runs in the background: returns {requestId}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto body = ParseBody(context);
const bool dryRun = !body || body->value("dry_run", true);
if (!dryRun && AnyoneOnline()) return JsonError(reply, eHTTPStatusCode::CONFLICT, "Players are online; their worlds would overwrite this. Try again when nobody is online.");
const auto requestId = PlayerActions::Begin(context.accountId, std::chrono::minutes(30));
const auto actor = context;
const bool queued = Background::Run("remove_buffs", [dryRun](GameDatabase& db) -> nlohmann::json {
// First pass only records ids so no writes happen while the cursor is open
std::vector<LWOOBJID> withBuffs;
db.ForEachCharacterXml([&](LWOOBJID id, const std::string& xml) {
if (xml.find("<buff") != std::string::npos) withBuffs.push_back(id);
});
if (dryRun) return { {"characters", withBuffs.size()}, {"dryRun", true} };
uint32_t changed = 0;
for (const auto id : withBuffs) {
tinyxml2::XMLDocument doc;
const auto xml = db.GetCharacterXml(id);
if (doc.Parse(xml.c_str()) != tinyxml2::XML_SUCCESS) continue;
auto* dest = doc.FirstChildElement("obj") ? doc.FirstChildElement("obj")->FirstChildElement("dest") : nullptr;
auto* buff = dest ? dest->FirstChildElement("buff") : nullptr;
if (!buff) continue;
dest->DeleteChild(buff);
tinyxml2::XMLPrinter printer(nullptr, true);
doc.Print(&printer);
db.UpdateCharacterXml(id, printer.CStr());
changed++;
}
return { {"characters", changed}, {"dryRun", false} };
}, [requestId, actor](nlohmann::json result, const std::string& error) {
if (!error.empty()) return PlayerActions::Finish(requestId, { false, "Failed: " + error });
const auto count = result["characters"].get<size_t>();
if (result["dryRun"].get<bool>()) return PlayerActions::Finish(requestId, { true, std::to_string(count) + " character(s) have buffs", result });
Audit(actor, "remove_all_buffs", std::to_string(count) + " character(s)");
PlayerActions::Finish(requestId, { true, "Removed buffs from " + std::to_string(count) + " character(s)", result });
});
if (!queued) {
PlayerActions::Finish(requestId, { false, "This is already running" });
return JsonError(reply, eHTTPStatusCode::CONFLICT, "This is already running");
}
JsonSuccess(reply, { {"requestId", requestId} });
});
Route(eHTTPMethod::POST, "/api/maintenance/fix_clone_ids", Perm("maintenance"), "Repair properties whose clone ID doesn't match their owner's",
[](HTTPReply& reply, const HTTPContext& context) {
const auto fixed = Database::Get()->FixPropertyCloneIds();
Audit(context, "fix_property_clone_ids", std::to_string(fixed) + " propert(ies)");
if (fixed) BroadcastTableChanged("properties");
JsonSuccess(reply, { {"fixed", fixed}, {"message", "Fixed " + std::to_string(fixed) + " propert" + (fixed == 1 ? "y" : "ies")} });
});
Route(eHTTPMethod::GET, "/api/maintenance/missing_commendation_items", Perm("maintenance"), "Mission reward items with no commendation vendor price (game data check)",
[](HTTPReply& reply, const HTTPContext& context) {
auto rows = CDClientDatabase::ExecuteQuery(
"SELECT DISTINCT r.lot FROM ("
"SELECT reward_item1 AS lot FROM Missions UNION SELECT reward_item2 FROM Missions "
"UNION SELECT reward_item3 FROM Missions UNION SELECT reward_item4 FROM Missions) r "
"LEFT JOIN ComponentsRegistry cr ON cr.id = r.lot AND cr.component_type = 11 "
"LEFT JOIN ItemComponent ic ON ic.id = cr.component_id "
"WHERE r.lot > 0 AND (ic.commendationLOT IS NULL OR ic.commendationCost IS NULL) ORDER BY r.lot;");
nlohmann::json items = nlohmann::json::array();
while (!rows.eof()) {
const LOT lot = rows.getIntField("lot");
items.push_back({ {"lot", lot}, {"name", ClientAssets::ItemName(lot)} });
rows.nextRow();
}
JsonReply(reply, eHTTPStatusCode::OK, { {"items", items} });
});
Route(eHTTPMethod::POST, "/api/properties/:id/import", Perm("properties_import"), "Import LXFML models onto a property. Body: {lxfml, position: [x,y,z] (optional)}",
[](HTTPReply& reply, const HTTPContext& context) {
const auto propertyId = PathId<LWOOBJID>(context.path, 2);