mirror of
https://github.com/DarkflameUniverse/DarkflameServer.git
synced 2026-11-14 07:47:13 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -23,6 +23,8 @@ set(DASHBOARDROUTES_SOURCES
|
||||
"WorldReloadRoutes.cpp"
|
||||
"SettingsRoutes.cpp"
|
||||
"Background.cpp"
|
||||
"DatabaseUsage.cpp"
|
||||
"DatabaseUsageReport.cpp"
|
||||
"Scheduler.cpp"
|
||||
"SettingsCatalog.cpp"
|
||||
"BackupRoutes.cpp"
|
||||
|
||||
155
dDashboardServer/routes/DatabaseUsage.cpp
Normal file
155
dDashboardServer/routes/DatabaseUsage.cpp
Normal file
@@ -0,0 +1,155 @@
|
||||
#include "DatabaseUsage.h"
|
||||
|
||||
#include <ctime>
|
||||
#include <filesystem>
|
||||
|
||||
#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<double>(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<double>(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<int64_t>(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<int64_t>(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<IDatabaseUsage::TableUsage>{}, last.time - first.time);
|
||||
out["fastest"] = samples.size() > 1 ? DatabaseUsageReport::Fastest(tables, FASTEST) : nlohmann::json::array();
|
||||
out["tables"] = tables;
|
||||
JsonSuccess(reply, out);
|
||||
});
|
||||
}
|
||||
}
|
||||
14
dDashboardServer/routes/DatabaseUsage.h
Normal file
14
dDashboardServer/routes/DatabaseUsage.h
Normal file
@@ -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();
|
||||
}
|
||||
@@ -217,6 +217,7 @@ namespace {
|
||||
std::tuple{ std::optional<eLog>(), "log_task_days", 90 },
|
||||
std::tuple{ std::optional<eLog>(), "health_days", 30 },
|
||||
std::tuple{ std::optional<eLog>(), "traffic_days", 30 },
|
||||
std::tuple{ std::optional<eLog>(), "database_usage_days", 365 },
|
||||
std::tuple{ std::optional<eLog>(), "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;
|
||||
|
||||
@@ -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).");
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user