From f8134ac4183eddacdce8361cb8b0919128594342 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Fri, 2 Oct 2026 17:57:31 -0500 Subject: [PATCH] feat(dashboard): Database usage task, API and settings The database_usage task (hourly) measures on the background connection, checks the free space on the database's disk (database_disk_path, else the SQLite file or a local MySQL data directory) and alerts once when it drops under database_disk_warn_percent. Log pruning keeps database_usage_days. GET /api/database/usage?range=day|week|month. The settings are on the Settings page. Co-Authored-By: Claude Opus 5.5 --- dDashboardServer/DashboardServer.cpp | 3 + dDashboardServer/routes/CMakeLists.txt | 2 + dDashboardServer/routes/DatabaseUsage.cpp | 155 ++++++++++++++++++++ dDashboardServer/routes/DatabaseUsage.h | 14 ++ dDashboardServer/routes/EconomyJobs.cpp | 2 + dDashboardServer/routes/SettingsCatalog.cpp | 5 + resources/dashboardconfig.ini | 11 ++ 7 files changed, 192 insertions(+) create mode 100644 dDashboardServer/routes/DatabaseUsage.cpp create mode 100644 dDashboardServer/routes/DatabaseUsage.h diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index b84ace835..38032023f 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -102,6 +102,7 @@ #include "master/ServerTraffic.h" #include "master/Profiling.h" #include "Performance.h" +#include "DatabaseUsage.h" #include "Background.h" #include "master/DashboardMessages.h" #include "master/DataChanged.h" @@ -543,6 +544,7 @@ int main(int argc, char** argv) { UpdateChecker::Initialize(); Background::Initialize(); RegisterEconomyTasks(); + DatabaseUsage::RegisterTask(); RegisterMaintenanceTasks(); RegisterBackupTask(); RegisterReportViewTask(); @@ -607,6 +609,7 @@ int main(int argc, char** argv) { PrometheusMetrics::RegisterRoutes(); Traffic::RegisterRoutes(); Performance::RegisterRoutes(); + DatabaseUsage::RegisterRoutes(); RegisterPublicRoutes(); RegisterShowcaseRoutes(); FeaturedProperties::RegisterRoutes(); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 684a2eb21..80b4a4d0d 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -23,6 +23,8 @@ set(DASHBOARDROUTES_SOURCES "WorldReloadRoutes.cpp" "SettingsRoutes.cpp" "Background.cpp" + "DatabaseUsage.cpp" + "DatabaseUsageReport.cpp" "Scheduler.cpp" "SettingsCatalog.cpp" "BackupRoutes.cpp" diff --git a/dDashboardServer/routes/DatabaseUsage.cpp b/dDashboardServer/routes/DatabaseUsage.cpp new file mode 100644 index 000000000..474effb60 --- /dev/null +++ b/dDashboardServer/routes/DatabaseUsage.cpp @@ -0,0 +1,155 @@ +#include "DatabaseUsage.h" + +#include +#include + +#include "Alerts.h" +#include "Background.h" +#include "Database.h" +#include "DatabaseUsageReport.h" +#include "dConfig.h" +#include "eHTTPMethod.h" +#include "Game.h" +#include "GeneralUtils.h" +#include "RouteUtils.h" +#include "Scheduler.h" + +using namespace RouteUtils; + +namespace { + constexpr const char* VIEW = "health_view"; + constexpr int64_t DAY = 24 * 60 * 60; + constexpr size_t CHART_POINTS = 240; + constexpr size_t FASTEST = 10; + + // What the last measurement found that isn't stored (main thread only) + struct Last { + std::string backend; + std::string location; + std::string diskPath; + bool sizesKnown{ true }; + bool diskLow{}; + } g_Last; + + double WarnPercent() { + if (!Game::config) return 10; + return GeneralUtils::TryParse(Game::config->GetValue("database_disk_warn_percent")).value_or(10); + } + + std::string FormatBytes(uint64_t bytes) { + const char* units[] = { "B", "KB", "MB", "GB", "TB" }; + double value = static_cast(bytes); + int unit = 0; + while (value >= 1024 && unit < 4) { value /= 1024; unit++; } + char text[32]; + std::snprintf(text, sizeof(text), unit == 0 ? "%.0f %s" : "%.1f %s", value, units[unit]); + return text; + } + + void Measure(Scheduler::RunPtr run) { + // Config on the main thread; the worker only gets plain values + const auto backend = Database::GetMigrationFolder(); + const auto mysqlHost = Game::config ? Game::config->GetValue("mysql_host") : ""; + const auto diskOverride = Game::config ? Game::config->GetValue("database_disk_path") : ""; + const bool localDatabase = backend == "sqlite" || DatabaseUsageReport::IsLocalHost(mysqlHost); + run->Log("Measuring the " + backend + " database" + (diskOverride.empty() ? "" : ", disk space of " + diskOverride + " (database_disk_path)")); + + const bool queued = Background::Run("database_usage", [diskOverride, localDatabase](GameDatabase& db) -> nlohmann::json { + const auto measurement = db.MeasureDatabaseUsage(); + IDatabaseUsage::UsageTotals totals; + totals.time = static_cast(std::time(nullptr)); + totals.totalBytes = measurement.totalBytes; + totals.freeBytes = measurement.freeBytes; + for (const auto& table : measurement.tables) { + totals.dataBytes += table.dataBytes; + totals.indexBytes += table.indexBytes; + totals.rows += table.rows; + } + // The database's volume: what database_disk_path says, else the SQLite file or a local MySQL's data directory + const std::string diskPath = !diskOverride.empty() ? diskOverride : localDatabase ? measurement.location : ""; + std::string diskError; + if (!diskPath.empty()) { + std::error_code ec; + const auto space = std::filesystem::space(diskPath, ec); + if (ec) diskError = ec.message(); + else { + totals.diskFree = space.available; + totals.diskTotal = space.capacity; + } + } + db.InsertDatabaseUsage(totals, measurement.tables); + return { {"time", totals.time}, {"total", totals.totalBytes}, {"tables", measurement.tables.size()}, {"location", measurement.location}, + {"sizes_known", measurement.sizesKnown}, {"disk_path", diskPath}, {"disk_error", diskError}, {"disk_free", totals.diskFree}, + {"disk_total", totals.diskTotal} }; + }, [run, backend](nlohmann::json result, const std::string& error) { + if (!error.empty()) return run->Finish(false, "Failed: " + error); + g_Last.backend = backend; + g_Last.location = result.value("location", ""); + g_Last.diskPath = result.value("disk_path", ""); + g_Last.sizesKnown = result.value("sizes_known", true); + if (!g_Last.sizesKnown) run->Log("SQLite has no dbstat table: only the total size and row counts are known"); + const auto total = result.value("total", uint64_t{}); + run->Log(std::to_string(result.value("tables", 0)) + " tables, " + FormatBytes(total) + " (" + g_Last.location + ")"); + const auto diskError = result.value("disk_error", ""); + if (!diskError.empty()) run->Log("Free disk space of " + g_Last.diskPath + " unknown: " + diskError); + const auto disk = DatabaseUsageReport::DiskState(result.value("disk_free", uint64_t{}), result.value("disk_total", uint64_t{}), WarnPercent()); + if (disk.known) { + run->Log("Disk: " + FormatBytes(disk.free) + " free of " + FormatBytes(disk.total)); + if (disk.low && !g_Last.diskLow) { + Alerts::Emit("server", "Database disk space low", "Only " + FormatBytes(disk.free) + " of " + FormatBytes(disk.total) + + " is free on the database's disk (warning under database_disk_warn_percent). The database is " + FormatBytes(total) + ".", {}, "/database_usage"); + } + g_Last.diskLow = disk.low; + } else if (g_Last.diskPath.empty()) { + run->Log("Free disk space not checked: the database is on another machine (set database_disk_path to check a local path)"); + } + run->Finish(true, FormatBytes(total) + (disk.low ? ", disk space low" : "")); + }); + if (!queued) run->Finish(false, "A measurement is already running"); + } +} + +namespace DatabaseUsage { + void RegisterTask() { + Scheduler::Register({ "database_usage", "Database usage", + "Measures the game database (its size and each table's data, indexes and rows) and the free space on its disk for the Database usage page, " + "and alerts when the disk's free space drops under database_disk_warn_percent. Samples are kept for database_usage_days. " + "On a big SQLite database it counts every table's rows, on the background connection.", + "0 * * * *", Measure, 60 * 60 }); + } + + void RegisterRoutes() { + Route(eHTTPMethod::GET, "/api/database/usage", Perm(VIEW), + "Database usage over a range. Query: ?range=day|week|month (default week). The latest sample's totals and disk space, the growth chart's " + "series, every table now with its growth over the range, and the fastest-growing tables", + [](HTTPReply& reply, const HTTPContext& context) { + const auto range = QueryValue(context.queryString, "range"); + const int64_t span = range == "day" ? DAY : range == "month" ? 30 * DAY : 7 * DAY; + const auto now = static_cast(std::time(nullptr)); + const auto samples = Database::Get()->GetDatabaseUsageTotals(now - span, now); + const auto warn = WarnPercent(); + nlohmann::json out = { + {"range", range == "day" || range == "month" ? range : "week"}, {"warn_percent", warn}, {"backend", Database::GetMigrationFolder()}, + {"location", g_Last.location}, {"disk_path", g_Last.diskPath}, {"sizes_known", g_Last.sizesKnown}, + {"can_run", Can(context, "tasks_manage")}, {"running", Background::IsRunning("database_usage")}, + }; + if (samples.empty()) { + out["latest"] = nullptr; + out["series"] = DatabaseUsageReport::Series({}, CHART_POINTS); + out["tables"] = nlohmann::json::array(); + out["fastest"] = nlohmann::json::array(); + return JsonSuccess(reply, out); + } + const auto& first = samples.front(); + const auto& last = samples.back(); + out["latest"] = DatabaseUsageReport::TotalsJson(last, warn); + out["first"] = DatabaseUsageReport::TotalsJson(first, warn); + out["series"] = DatabaseUsageReport::Series(samples, CHART_POINTS); + const auto tables = DatabaseUsageReport::Tables(Database::Get()->GetDatabaseUsageTables(last.time), + samples.size() > 1 ? Database::Get()->GetDatabaseUsageTables(first.time) : std::vector{}, last.time - first.time); + out["fastest"] = samples.size() > 1 ? DatabaseUsageReport::Fastest(tables, FASTEST) : nlohmann::json::array(); + out["tables"] = tables; + JsonSuccess(reply, out); + }); + } +} diff --git a/dDashboardServer/routes/DatabaseUsage.h b/dDashboardServer/routes/DatabaseUsage.h new file mode 100644 index 000000000..369e9a1ad --- /dev/null +++ b/dDashboardServer/routes/DatabaseUsage.h @@ -0,0 +1,14 @@ +#pragma once + +/** + * Database usage (docs/Dashboard.md, "Database usage"): a scheduled task (database_usage, hourly by default) measures + * the game database (whole size, each table's data, indexes and rows) and the free space on its disk on the + * background connection, keeps the samples for database_usage_days, and alerts when the disk's free space drops under + * database_disk_warn_percent. The page shows the totals, the growth chart, the fastest-growing tables and every table. + */ +namespace DatabaseUsage { + // Before Scheduler::Initialize + void RegisterTask(); + + void RegisterRoutes(); +} diff --git a/dDashboardServer/routes/EconomyJobs.cpp b/dDashboardServer/routes/EconomyJobs.cpp index 64e6e2011..df5282727 100644 --- a/dDashboardServer/routes/EconomyJobs.cpp +++ b/dDashboardServer/routes/EconomyJobs.cpp @@ -217,6 +217,7 @@ namespace { std::tuple{ std::optional(), "log_task_days", 90 }, std::tuple{ std::optional(), "health_days", 30 }, std::tuple{ std::optional(), "traffic_days", 30 }, + std::tuple{ std::optional(), "database_usage_days", 365 }, std::tuple{ std::optional(), "position_history_days", 3 } }) { const auto days = Setting(key, fallback); run->Log(std::string(key) + " = " + (days > 0 ? std::to_string(days) : "0 (keep everything)")); @@ -230,6 +231,7 @@ namespace { const auto before = now - days * DAY_SECONDS; pruned[key] = log ? db.PruneLog(*log, before) : key == std::string("health_days") ? db.PruneHealthSamples(before) : key == std::string("traffic_days") ? db.PruneTrafficMinutes(before) + : key == std::string("database_usage_days") ? db.PruneDatabaseUsage(before) : key == std::string("position_history_days") ? db.PrunePositionSamples(before) : db.PruneTaskRuns(before); } return pruned; diff --git a/dDashboardServer/routes/SettingsCatalog.cpp b/dDashboardServer/routes/SettingsCatalog.cpp index 4f5edb3bc..5139873d8 100644 --- a/dDashboardServer/routes/SettingsCatalog.cpp +++ b/dDashboardServer/routes/SettingsCatalog.cpp @@ -405,6 +405,10 @@ namespace { c.Add(Bool(WORLD, "contraband_ignore_staff", "Don't check staff", "Accounts with a GM level keep listed items and aren't flagged.", true)); c.Add(Bool(WORLD, "contraband_notify_players", "Tell players", "A mail (at login) or a chat message (when received) says which item was removed and why.", true)); + c.AddSection("Database usage", "The Database usage page. How often the database is measured is the Database usage task's schedule (Tasks page, hourly by default); samples are kept for database_usage_days."); + c.Add(Unit(Float(DASHBOARD, "database_disk_warn_percent", "Warn when disk free is under", "An alert (webhooks subscribed to server events) and a warning on the page when the free space on the database's disk drops under this share of the disk. 0: never.", "10", 0, 100), "%")); + c.Add(Format(Text(DASHBOARD, "database_disk_path", "Disk to check", "A path on the database's disk, for the free space. Empty: the SQLite file, or a MySQL/MariaDB data directory when mysql_host is this machine; a database on another machine is not checked."), eFormat::PATH)); + c.AddSection("Backups"); c.Add(Format(Text(DASHBOARD, "backup_folder", "Backup folder", "Relative to the server binaries unless absolute.", "backups"), eFormat::PATH)); c.Add(Unit(Int(DASHBOARD, "backup_keep", "Backups to keep", "Older backups are deleted after each new one. 0 keeps them all.", "7", 0, 1000), "backups")); @@ -423,6 +427,7 @@ namespace { c.Add(Days("log_login_address_days", "Login addresses", "Addresses an account hasn't logged in from for this long are forgotten.", "90")); c.Add(Days("log_client_sysinfo_days", "Client system info", "System descriptions clients sent at login that haven't been seen for this long are forgotten.", "90")); c.Add(Days("health_days", "Server health history", "Minute-by-minute player counts and uptime.", "30")); + c.Add(Days("database_usage_days", "Database usage history", "Samples of the database's size and its tables (Database usage page).", "365")); c.Add(Days("traffic_days", "Server traffic history", "Minute-by-minute packets, bytes and HTTP requests of every server (Diagnostics). The last hour at one second is only kept in memory.", "30")); c.AddSection("Log bundles", "Server log files downloaded together from the System Log page (Download logs)."); diff --git a/resources/dashboardconfig.ini b/resources/dashboardconfig.ini index 012c61735..8b45c0be2 100644 --- a/resources/dashboardconfig.ini +++ b/resources/dashboardconfig.ini @@ -243,6 +243,15 @@ ai_helper_per_day=200 # Chat around the item: How far before and after a report or message the player's chat is included. (minutes) ai_helper_chat_minutes=10 +# ---- Database usage ---- +# How often the database is measured is the Database usage task's schedule (Tasks page, hourly by default). +# Warn when disk free is under: An alert and a warning on the Database usage page when the free space on the database's +# disk drops under this share of the disk. 0: never. (%) +database_disk_warn_percent=10 +# Disk to check: A path on the database's disk, for the free space. Empty: the SQLite file, or a MySQL/MariaDB data +# directory when mysql_host is this machine; a database on another machine is not checked. +database_disk_path= + # ---- Backups ---- # Backup folder: Relative to the server binaries unless absolute. backup_folder=backups @@ -263,6 +272,8 @@ health_days=30 # Server traffic history: Minute-by-minute packets, bytes and HTTP requests of every server (Diagnostics). The last hour # at one second is only kept in memory. (days) traffic_days=30 +# Database usage history: Samples of the database's size and its tables (Database usage page). (days) +database_usage_days=365 # ---- Log bundles ---- # At most: How much log text (before compression) one download may hold. Bundles are built in the system's temporary