From d94d84b5cb449acc7dc556e21b80d190599c9cf3 Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Wed, 30 Sep 2026 08:06:02 -0500 Subject: [PATCH] feat(dashboard): browse every client system info report, per account and across accounts The account page lists the account's reports as a table and the Client System Info page lists every report (or each account's newest), sortable and searchable by account name or video card; a row opens every field. Values that say little about the real system are marked, and every caveat is a tooltip on the value and column. Co-Authored-By: Claude Opus 5.5 --- .../routes/ClientSysInfoRoutes.cpp | 41 ++++- dDashboardServer/routes/ClientSysInfoView.cpp | 10 ++ dDashboardServer/routes/ClientSysInfoView.h | 4 + dDashboardServer/static/css/dashboard.css | 5 + dDashboardServer/static/js/client-sysinfo.js | 147 ++++++++++++++---- .../templates/account-view.jinja2 | 8 +- .../templates/client_sysinfo.jinja2 | 22 ++- .../GameDatabase/ITables/ClientSysInfoSql.h | 34 ++++ .../GameDatabase/ITables/IClientSysInfo.h | 18 +++ dDatabase/GameDatabase/MySQL/MySQLDatabase.h | 2 + .../MySQL/Tables/ClientSysInfo.cpp | 22 ++- .../GameDatabase/SQLite/SQLiteDatabase.h | 2 + .../SQLite/Tables/ClientSysInfo.cpp | 22 ++- .../GameDatabase/TestSQL/TestSQLDatabase.h | 2 + .../dDatabaseTests/ChatHistorySqliteTests.cpp | 37 +++++ tests/dDatabaseTests/DatabaseParityTests.cpp | 27 ++++ 16 files changed, 363 insertions(+), 40 deletions(-) create mode 100644 dDatabase/GameDatabase/ITables/ClientSysInfoSql.h diff --git a/dDashboardServer/routes/ClientSysInfoRoutes.cpp b/dDashboardServer/routes/ClientSysInfoRoutes.cpp index daf66f1ff..3b59a9bd4 100644 --- a/dDashboardServer/routes/ClientSysInfoRoutes.cpp +++ b/dDashboardServer/routes/ClientSysInfoRoutes.cpp @@ -2,6 +2,9 @@ #include "ClientSysInfoView.h" #include "Database.h" +#include "GeneralUtils.h" + +#include #include "RouteUtils.h" #include "eHTTPMethod.h" @@ -27,14 +30,46 @@ void ClientSysInfoRoutes::RegisterRoutes() { const bool showIp = Can(context, "logs_audit"); nlohmann::json rows = nlohmann::json::array(); for (const auto& row : Database::Get()->GetClientSysInfo(*accountId, HISTORY_ROWS)) rows.push_back(ClientSysInfoView::RowJson(row, showIp)); - JsonSuccess(reply, { {"rows", rows}, {"caveats", ClientSysInfoView::Caveats()}, {"showsIp", showIp} }); + JsonSuccess(reply, { {"rows", rows}, {"caveats", ClientSysInfoView::Caveats()}, {"trust", ClientSysInfoView::Trust()}, {"showsIp", showIp} }); }); Route(eHTTPMethod::GET, "/api/client_sysinfo/spread", Perm("client_sysinfo"), "How the system info clients report is spread across players, from each account's newest row (approximate: as reported by the " "client): {spread: {accounts, os, video, memory, processors, clientOs: [{label, count}]}, caveats}", - [](HTTPReply& reply, const HTTPContext&) { + [](HTTPReply& reply, const HTTPContext& context) { JsonSuccess(reply, { {"spread", ClientSysInfoView::Spread(Database::Get()->GetLatestClientSysInfo(SPREAD_ACCOUNTS))}, - {"caveats", ClientSysInfoView::Caveats()} }); + {"caveats", ClientSysInfoView::Caveats()}, {"trust", ClientSysInfoView::Trust()}, {"showsIp", Can(context, "logs_audit")} }); + }); + + Route(eHTTPMethod::POST, "/api/tables/client_sysinfo", Perm("client_sysinfo"), + "Every client system info row across accounts for browsing (DataTables), as reported by the client; rows as " + "/api/accounts/:id/client_sysinfo plus account_name. Search: part of the account name or video card. Body adds {latest (bool: only each " + "account's newest row), account (ID)}. Columns: last seen, account, logins, Windows version, video card, processors, memory, client build, " + "first seen. ip only with logs_audit", + [](HTTPReply& reply, const HTTPContext& context) { + const auto request = ParseDataTablesRequest(context.body); + const auto body = ParseBody(context); + if (!request || !body) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid JSON"); + IClientSysInfo::SysInfoQuery query; + query.search = request->search; + query.latestOnly = body->value("latest", false); + if (const auto it = body->find("account"); it != body->end()) { + if (it->is_number_unsigned()) query.accountId = it->get(); + else if (it->is_string()) query.accountId = GeneralUtils::TryParse(it->get()).value_or(0); + } + query.order = static_cast(std::min(request->orderColumn, + static_cast(IClientSysInfo::eSysInfoOrder::FIRST_SEEN))); + query.ascending = request->orderAsc; + query.offset = request->start; + query.limit = std::clamp(request->length, 1, 500); + const bool showIp = Can(context, "logs_audit"); + nlohmann::json rows = nlohmann::json::array(); + for (const auto& row : Database::Get()->ListClientSysInfo(query)) rows.push_back(ClientSysInfoView::RowJson(row, showIp)); + IClientSysInfo::SysInfoQuery all; + all.latestOnly = query.latestOnly; + all.accountId = query.accountId; + JsonReply(reply, eHTTPStatusCode::OK, { {"draw", request->draw}, {"recordsTotal", Database::Get()->CountClientSysInfo(all)}, + {"recordsFiltered", Database::Get()->CountClientSysInfo(query)}, {"data", rows}, {"caveats", ClientSysInfoView::Caveats()}, + {"trust", ClientSysInfoView::Trust()}, {"showsIp", showIp} }); }); } diff --git a/dDashboardServer/routes/ClientSysInfoView.cpp b/dDashboardServer/routes/ClientSysInfoView.cpp index 34a37a1cc..6982eb6dc 100644 --- a/dDashboardServer/routes/ClientSysInfoView.cpp +++ b/dDashboardServer/routes/ClientSysInfoView.cpp @@ -64,6 +64,15 @@ const nlohmann::json& ClientSysInfoView::Caveats() { return caveats; } +const nlohmann::json& ClientSysInfoView::Trust() { + static const nlohmann::json trust = { + {"clientOs", "unreliable"}, {"processorType", "unreliable"}, {"osVersionInfoSize", "unreliable"}, {"osVersion", "unreliable"}, + {"platformId", "unreliable"}, {"memoryStats", "approximate"}, {"videoCard", "approximate"}, {"numberOfProcessors", "approximate"}, + {"processorLevel", "approximate"}, {"processorRevision", "approximate"}, + }; + return trust; +} + std::string ClientSysInfoView::OsVersion(const IClientSysInfo::SysInfoRow& row) { return std::to_string(row.majorVersion) + "." + std::to_string(row.minorVersion) + "." + std::to_string(row.buildNumber); } @@ -116,6 +125,7 @@ nlohmann::json ClientSysInfoView::RowJson(const IClientSysInfo::SysInfoRow& row, {"os_version", OsVersion(row)}, {"os_label", OsLabel(row)}, }; if (showIp) out["ip"] = row.ip; + if (!row.accountName.empty()) out["account_name"] = row.accountName; return out; } diff --git a/dDashboardServer/routes/ClientSysInfoView.h b/dDashboardServer/routes/ClientSysInfoView.h index 244b38a2e..c9b0d7344 100644 --- a/dDashboardServer/routes/ClientSysInfoView.h +++ b/dDashboardServer/routes/ClientSysInfoView.h @@ -17,6 +17,10 @@ namespace ClientSysInfoView { // field -> what it is and how far to trust it, for the page and the API const nlohmann::json& Caveats(); + // field -> "unreliable" (says little or nothing about the player's system) or "approximate" (usually right, with + // exceptions); fields not listed can be taken as sent + const nlohmann::json& Trust(); + // One row as the dashboard sends it: the raw values, plus labels and the memory text split into numbers. The // address is only included when showIp (logs_audit). nlohmann::json RowJson(const IClientSysInfo::SysInfoRow& row, bool showIp); diff --git a/dDashboardServer/static/css/dashboard.css b/dDashboardServer/static/css/dashboard.css index e7ae8af6e..6a8a404ca 100644 --- a/dDashboardServer/static/css/dashboard.css +++ b/dDashboardServer/static/css/dashboard.css @@ -262,3 +262,8 @@ html.nav-loading main { opacity: .65; transition: opacity .2s; } border-radius: .2rem; background: var(--bs-warning-bg-subtle); color: var(--bs-warning-text-emphasis); max-width: 9rem; overflow: hidden; text-overflow: ellipsis; } .timeline-marker:hover > span, .timeline-marker:focus-visible > span { max-width: none; z-index: 1; } + +/* Client system info: values whose caveat is a tooltip; unreliable ones stand out more */ +.sysinfo-tip { text-decoration: underline dotted; text-underline-offset: .2em; cursor: help; } +.sysinfo-unreliable { text-decoration-color: var(--bs-warning); } +#sysinfoTable tbody tr { cursor: pointer; } diff --git a/dDashboardServer/static/js/client-sysinfo.js b/dDashboardServer/static/js/client-sysinfo.js index 2b1ee6ff1..657a39d22 100644 --- a/dDashboardServer/static/js/client-sysinfo.js +++ b/dDashboardServer/static/js/client-sysinfo.js @@ -1,27 +1,44 @@ /** - * Client system info (client_sysinfo): the card on an account's page (#sysinfoCard) with every description the - * account's client sent at login, and the Client System Info page (#sysinfoPage) with the spread across players. - * Everything here is as reported by the client, which often reports compatibility values rather than the real - * hardware; each field is shown with what it is worth (the caveats the API sends). + * Client system info (client_sysinfo): the table on an account's page (#sysinfoCard) with every description the + * account's client sent at login, and the Client System Info page (#sysinfoPage) with the spread across players and + * every report browsable (#sysinfoTable). Everything here is as reported by the client, which often reports + * compatibility values rather than the real hardware: values that can't be trusted are marked, and their tooltip says + * what they are worth (the caveats and trust levels the API sends). */ (function () { 'use strict'; + var caveats = {}, trust = {}, showsIp = false; + function kb(n) { return n === null || n === undefined ? '-' : fmt.bytes(Number(n) * 1024); } function bytes(n) { return n === null || n === undefined ? '-' : fmt.bytes(n); } function hex(n) { return '0x' + Number(n).toString(16); } - function caveat(caveats, key) { return caveats[key] ? '
' + esc(caveats[key]) + '
' : ''; } + + // A value with its caveat as a tooltip: unreliable values get a warning mark, approximate ones a dotted underline + function tip(key, html) { + var level = trust[key], text = caveats[key]; + if (!text) return html; + var mark = level === 'unreliable' ? ' ' : ''; + var label = level === 'unreliable' ? 'Unreliable: ' : level === 'approximate' ? 'Approximate: ' : ''; + return '' + + html + mark + ''; + } + + function tooltips(root) { + if (!window.bootstrap) return; + root.querySelectorAll('[data-bs-toggle="tooltip"]').forEach(function (el) { bootstrap.Tooltip.getOrCreateInstance(el); }); + } // Every field of one row: [label, value (html), caveat key] - function fields(r, showsIp) { + function fields(r) { var m = r.memory; var list = [ ['Client build (clientOS)', esc(r.client_os) + ' (' + esc(r.client_os_name) + ')', 'clientOs'], ['Memory text (memoryStats), as sent', '' + esc(r.memory_stats) + '' + (m.complete ? '' : ' not fully read'), 'memoryStats'], - ['Physical memory: total / free', kb(m.total_phys_kb) + ' / ' + kb(m.avail_phys_kb) + (m.memory_load_percent === null ? '' : ' (' + esc(m.memory_load_percent) + '% in use)'), ''], - ['Commit limit (pfile): total / free', kb(m.total_pagefile_kb) + ' / ' + kb(m.avail_pagefile_kb), ''], - ['Client address space (vmem): total / free', kb(m.total_virtual_kb) + ' / ' + kb(m.avail_virtual_kb), ''], - ['Client process: working set / private (peak)', bytes(m.working_set_bytes) + ' / ' + bytes(m.pagefile_usage_bytes) + ' (' + bytes(m.peak_working_set_bytes) + ' / ' + bytes(m.peak_pagefile_usage_bytes) + ')', ''], + ['Physical memory: total / free', kb(m.total_phys_kb) + ' / ' + kb(m.avail_phys_kb) + (m.memory_load_percent === null ? '' : ' (' + esc(m.memory_load_percent) + '% in use)'), 'memoryStats'], + ['Commit limit (pfile): total / free', kb(m.total_pagefile_kb) + ' / ' + kb(m.avail_pagefile_kb), 'memoryStats'], + ['Client address space (vmem): total / free', kb(m.total_virtual_kb) + ' / ' + kb(m.avail_virtual_kb), 'memoryStats'], + ['Client process: working set / private (peak)', bytes(m.working_set_bytes) + ' / ' + bytes(m.pagefile_usage_bytes) + ' (' + bytes(m.peak_working_set_bytes) + ' / ' + bytes(m.peak_pagefile_usage_bytes) + ')', 'memoryStats'], ['Video card (videoCard)', esc(r.video_card || '(empty)'), 'videoCard'], ['Processors (numberOfProcessors)', esc(r.number_of_processors), 'numberOfProcessors'], ['Processor type (processorType)', esc(r.processor_type), 'processorType'], @@ -35,34 +52,83 @@ return list; } + function details(r) { + return '
' + fields(r).map(function (f) { + return ''; + }).join('') + '
' + esc(f[0]) + '' + tip(f[2], f[1]) + '
'; + } + + // The columns, in the order the server sorts by (last seen, account, logins, Windows, video card, processors, memory, + // client build, first seen) + function columns(withAccount) { + return [ + { data: 'last_seen', render: function (d, t) { return t === 'display' ? esc(fmt.unix(d)) : d; } }, + { data: 'account_name', visible: withAccount, render: function (d, t, r) { + return '' + esc(d || ('#' + r.account_id)) + ''; + } }, + { data: 'logins', className: 'text-end' }, + { data: 'os_version', render: function (d, t, r) { return tip('osVersion', esc(d)) + '
' + esc(r.os_label) + '
'; } }, + { data: 'video_card', render: function (d) { return tip('videoCard', esc(d || '(empty)')); } }, + { data: 'number_of_processors', className: 'text-end', render: function (d) { return tip('numberOfProcessors', esc(d)); } }, + { data: 'memory_total_kb', className: 'text-end text-nowrap', render: function (d) { return tip('memoryStats', d ? kb(d) : 'Not read'); } }, + { data: 'client_os_name', render: function (d) { return tip('clientOs', esc(d)); } }, + { data: 'first_seen', render: function (d, t) { return t === 'display' ? esc(fmt.unix(d)) : d; } } + ]; + } + + // A browsable table of reports; clicking a row opens every field + function reportTable(selector, extra, withAccount) { + var el = document.querySelector(selector); + var table = serverTable(selector, '/api/tables/client_sysinfo', columns(withAccount), { + extra: extra, + dataTable: { order: [[0, 'desc']], language: { searchPlaceholder: withAccount ? 'Account or video card' : 'Video card' } } + }); + table.on('xhr', function (e, settings, json) { + if (!json) return; + caveats = json.caveats || caveats; + trust = json.trust || trust; + showsIp = !!json.showsIp; + }); + table.on('draw', function () { tooltips(el); }); + $(el).on('click', 'tbody tr', function (e) { + if (e.target.closest('a')) return; + var row = table.row(this); + if (!row.data()) return; + if (row.child.isShown()) { row.child.hide(); this.classList.remove('shown'); return; } + row.child(details(row.data())).show(); + this.classList.add('shown'); + tooltips(row.child()[0]); + }); + return table; + } + + // Column headers carry the caveat of what they show, once the first page brings the caveats + function headerTips(el) { + var keys = { os: 'osVersion', video: 'videoCard', cpus: 'numberOfProcessors', memory: 'memoryStats', client: 'clientOs' }; + el.querySelectorAll('thead th[data-caveat]').forEach(function (th) { + if (th.dataset.tipped) return; + var key = keys[th.dataset.caveat]; + if (!caveats[key]) return; + th.dataset.tipped = '1'; + th.innerHTML = tip(key, th.innerHTML); + }); + tooltips(el.querySelector('thead')); + } + function accountCard(card) { var accountId = card.dataset.account; - api.get('/api/accounts/' + accountId + '/client_sysinfo').then(function (d) { - if (!d.success) return; - document.getElementById('sysinfoCount').textContent = d.rows.length; - var list = document.getElementById('sysinfoList'); - if (!d.rows.length) { - list.innerHTML = '

Nothing on record: the client sends this at each game login (kept for log_client_sysinfo_days).

'; - return; - } - list.innerHTML = d.rows.map(function (r, i) { - var summary = '' + esc(fmt.unix(r.last_seen)) + '' + - (r.first_seen !== r.last_seen ? ' since ' + esc(fmt.unix(r.first_seen)) + '' : '') + - ' · ' + esc(r.logins) + ' login' + (r.logins === 1 ? '' : 's') + - ' · reports Windows ' + esc(r.os_version) + ' · ' + esc(r.video_card || 'no video card') + ' · ' + esc(r.number_of_processors) + ' CPUs · ' + kb(r.memory_total_kb) + - (d.showsIp && r.ip ? ' · ' + esc(r.ip) + '' : ''); - var rows = fields(r, d.showsIp).map(function (f) { - return '' + esc(f[0]) + '' + f[1] + caveat(d.caveats, f[2]) + ''; - }).join(''); - return '
' + summary + '' + - '
' + rows + '
'; - }).join(''); - }).catch(function () {}); + var table = reportTable('#sysinfoTable', function () { return { account: accountId }; }, false); + table.on('xhr', function (e, settings, json) { + if (json) document.getElementById('sysinfoCount').textContent = json.recordsTotal; + }); + table.on('draw', function () { headerTips(card); }); } function spreadPage() { api.get('/api/client_sysinfo/spread').then(function (d) { if (!d.success) return; + caveats = d.caveats || {}; + trust = d.trust || {}; var s = d.spread; document.getElementById('sysinfoAccounts').textContent = s.accounts; [['os', 'osVersion'], ['video', 'videoCard'], ['memory', 'memoryStats'], ['processors', 'numberOfProcessors'], ['clientOs', 'clientOs']].forEach(function (pair) { @@ -71,15 +137,30 @@ var rows = s[pair[0]]; var html = rows.map(function (e) { var pct = s.accounts ? Math.round(e.count * 1000 / s.accounts) / 10 : 0; - return '' + esc(e.label) + '' + esc(e.count) + '' + + // A video card opens the reports from that card + var label = pair[0] === 'video' && e.label !== 'Other' && e.label !== '(empty)' + ? '' + esc(e.label) + '' : esc(e.label); + return '' + label + '' + esc(e.count) + '' + '
' + '
' + pct + '%'; }).join('') || 'Nothing reported yet.'; el.querySelector('tbody').innerHTML = html; var note = el.querySelector('[data-caveat]'); - if (note) note.textContent = d.caveats[pair[1]] || ''; + if (note) note.innerHTML = (trust[pair[1]] === 'unreliable' ? ' Unreliable. ' : '') + esc(caveats[pair[1]] || ''); }); }).catch(function () {}); + + var latest = document.getElementById('sysinfoLatest'); + var table = reportTable('#sysinfoTable', function () { return { latest: !!(latest && latest.checked) }; }, true); + table.on('draw', function () { headerTips(document.getElementById('sysinfoAll')); }); + if (latest) latest.addEventListener('change', function () { table.ajax.reload(); }); + document.getElementById('sysinfoPage').addEventListener('click', function (e) { + var link = e.target.closest('[data-search]'); + if (!link) return; + e.preventDefault(); + table.search(link.dataset.search).draw(); + document.getElementById('sysinfoAll').scrollIntoView({ behavior: 'smooth' }); + }); } var card = document.getElementById('sysinfoCard'); diff --git a/dDashboardServer/templates/account-view.jinja2 b/dDashboardServer/templates/account-view.jinja2 index e4ed6be76..53f21407f 100644 --- a/dDashboardServer/templates/account-view.jinja2 +++ b/dDashboardServer/templates/account-view.jinja2 @@ -164,7 +164,13 @@

What this account's game client said about its system at each login, exactly as sent. The client asks old Windows functions from a 32-bit program, so several values are compatibility defaults, not the player's real hardware; each field says how far it can be trusted. A new entry starts when anything but the memory in use changes.{% if not can.logs_audit %} Addresses are only shown with logs_audit.{% endif %}

-

Loading…

+

Dotted values have a tooltip saying what they're worth; ⚠ marks values that say little or nothing about the + player's real system. Click a row for every field.

+ + + + +
Last seenAccountLoginsWindows reportedVideo cardCPUsMemoryClient buildFirst seen
{% endif %} diff --git a/dDashboardServer/templates/client_sysinfo.jinja2 b/dDashboardServer/templates/client_sysinfo.jinja2 index 3eaef9b14..6b356bac1 100644 --- a/dDashboardServer/templates/client_sysinfo.jinja2 +++ b/dDashboardServer/templates/client_sysinfo.jinja2 @@ -11,7 +11,7 @@ and under Wine or Proton the answers are whatever Wine reports. Treat this as what clients claim, not as players' real hardware.

From each account's newest report (… accounts), kept for log_client_sysinfo_days. - Each account's own history is on its account page.

+ Every report is listed below; each account's own reports are also on its account page.

@@ -60,6 +60,26 @@
+ +
+
+
All reports
+
+ + +
+
+
+

One row per account while its client reports the same system; a new row starts when anything but the memory + in use changes. Dotted values have a tooltip saying what they're worth; ⚠ marks values that say little or nothing about the player's + real system. Click a row for every field; a video card above filters the list to it.

+ + + + +
Last seenAccountLoginsWindows reportedVideo cardCPUsMemoryClient buildFirst seen
+
+
{% endblock %} diff --git a/dDatabase/GameDatabase/ITables/ClientSysInfoSql.h b/dDatabase/GameDatabase/ITables/ClientSysInfoSql.h new file mode 100644 index 000000000..97fbd13de --- /dev/null +++ b/dDatabase/GameDatabase/ITables/ClientSysInfoSql.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include "IClientSysInfo.h" + +// The SQL both databases use to browse client_sysinfo; `like` is the pattern for "contains ?". Parameters, in order: +// accountId, accountId, search, pattern, pattern. +namespace ClientSysInfoSql { + inline std::string From(const IClientSysInfo::SysInfoQuery& q, const std::string& like) { + std::string from = " FROM client_sysinfo AS c LEFT JOIN accounts AS a ON a.id = c.account_id"; + if (q.latestOnly) from += " JOIN (SELECT MAX(id) AS id FROM client_sysinfo GROUP BY account_id) AS n ON n.id = c.id"; + return from + " WHERE (? = 0 OR c.account_id = ?) AND (? = '' OR a.name LIKE " + like + " ESCAPE '!' OR c.video_card LIKE " + like + " ESCAPE '!')"; + } + + inline std::string Order(const IClientSysInfo::SysInfoQuery& q) { + using eSysInfoOrder = IClientSysInfo::eSysInfoOrder; + const std::string dir = q.ascending ? " ASC" : " DESC"; + std::string columns; + switch (q.order) { + case eSysInfoOrder::LAST_SEEN: columns = "c.last_seen" + dir; break; + case eSysInfoOrder::ACCOUNT: columns = "a.name" + dir; break; + case eSysInfoOrder::LOGINS: columns = "c.logins" + dir; break; + case eSysInfoOrder::OS_VERSION: columns = "c.os_major_version" + dir + ", c.os_minor_version" + dir + ", c.os_build_number" + dir; break; + case eSysInfoOrder::VIDEO_CARD: columns = "c.video_card" + dir; break; + case eSysInfoOrder::PROCESSORS: columns = "c.number_of_processors" + dir; break; + case eSysInfoOrder::MEMORY: columns = "c.memory_total_kb" + dir; break; + case eSysInfoOrder::CLIENT_OS: columns = "c.client_os" + dir; break; + case eSysInfoOrder::FIRST_SEEN: columns = "c.first_seen" + dir; break; + } + // Equal values come in row order, the same on every database + return " ORDER BY " + columns + ", c.id" + dir; + } +} diff --git a/dDatabase/GameDatabase/ITables/IClientSysInfo.h b/dDatabase/GameDatabase/ITables/IClientSysInfo.h index a07de2a0e..51e7127e1 100644 --- a/dDatabase/GameDatabase/ITables/IClientSysInfo.h +++ b/dDatabase/GameDatabase/ITables/IClientSysInfo.h @@ -32,6 +32,7 @@ public: uint32_t minorVersion{}; uint32_t buildNumber{}; uint32_t platformId{}; + std::string accountName; // only filled by ListClientSysInfo // Whether two logins describe the same client (memory in use and the time are left out: they change each login) bool SameAs(const SysInfoRow& other) const { @@ -53,6 +54,23 @@ public: // Each account's newest row, for the spread across players virtual std::vector GetLatestClientSysInfo(uint32_t limit) = 0; + + // What ListClientSysInfo sorts by + enum class eSysInfoOrder : uint8_t { LAST_SEEN, ACCOUNT, LOGINS, OS_VERSION, VIDEO_CARD, PROCESSORS, MEMORY, CLIENT_OS, FIRST_SEEN }; + + struct SysInfoQuery { + std::string search; // part of the account name or video card + uint32_t accountId{}; // 0: every account + bool latestOnly{}; // only each account's newest row + eSysInfoOrder order{ eSysInfoOrder::LAST_SEEN }; + bool ascending{}; + uint32_t offset{}; + uint32_t limit{ 25 }; + }; + + // Rows across accounts for browsing, with the account's name + virtual std::vector ListClientSysInfo(const SysInfoQuery& query) = 0; + virtual uint64_t CountClientSysInfo(const SysInfoQuery& query) = 0; }; #endif //!__ICLIENTSYSINFO__H__ diff --git a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h index c1a0b5a0b..3f996f9c9 100644 --- a/dDatabase/GameDatabase/MySQL/MySQLDatabase.h +++ b/dDatabase/GameDatabase/MySQL/MySQLDatabase.h @@ -351,6 +351,8 @@ public: void RecordClientSysInfo(const SysInfoRow& info) override; std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) override; std::vector GetLatestClientSysInfo(uint32_t limit) override; + std::vector ListClientSysInfo(const SysInfoQuery& query) override; + uint64_t CountClientSysInfo(const SysInfoQuery& query) override; void InsertModerationDecision(const std::string& kind, int64_t subjectId, const std::string& subject, bool approved, const std::string& reason, int64_t time) override; nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override; Totp GetTotp(uint32_t accountId) override; diff --git a/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp b/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp index ccea54b57..115ddcb3b 100644 --- a/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp +++ b/dDatabase/GameDatabase/MySQL/Tables/ClientSysInfo.cpp @@ -1,7 +1,10 @@ #include "MySQLDatabase.h" +#include "ClientSysInfoSql.h" +#include "GeneralUtils.h" + namespace { - IClientSysInfo::SysInfoRow Row(sql::ResultSet& r) { + IClientSysInfo::SysInfoRow Row(sql::ResultSet& r, const bool withName = false) { IClientSysInfo::SysInfoRow s; s.id = r.getUInt64("id"); s.accountId = r.getUInt("account_id"); @@ -22,6 +25,7 @@ namespace { s.minorVersion = r.getUInt("os_minor_version"); s.buildNumber = r.getUInt("os_build_number"); s.platformId = r.getUInt("os_platform_id"); + if (withName) s.accountName = r.getString("account_name").c_str(); return s; } } @@ -56,3 +60,19 @@ std::vector MySQLDatabase::GetLatestClientSysInfo(co while (result->next()) rows.push_back(Row(*result.m_resultSet)); return rows; } + +std::vector MySQLDatabase::ListClientSysInfo(const SysInfoQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto result = ExecuteSelect("SELECT c.*, COALESCE(a.name, '') AS account_name" + ClientSysInfoSql::From(q, "CONCAT('%', ?, '%')") + + ClientSysInfoSql::Order(q) + " LIMIT ? OFFSET ?;", q.accountId, q.accountId, q.search, pattern, pattern, q.limit, q.offset); + std::vector rows; + while (result->next()) rows.push_back(Row(*result.m_resultSet, true)); + return rows; +} + +uint64_t MySQLDatabase::CountClientSysInfo(const SysInfoQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto result = ExecuteSelect("SELECT COUNT(*) AS count" + ClientSysInfoSql::From(q, "CONCAT('%', ?, '%')") + ";", + q.accountId, q.accountId, q.search, pattern, pattern); + return result->next() ? result->getUInt64("count") : 0; +} diff --git a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h index 49de5fdd9..4da7960c4 100644 --- a/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h +++ b/dDatabase/GameDatabase/SQLite/SQLiteDatabase.h @@ -335,6 +335,8 @@ public: void RecordClientSysInfo(const SysInfoRow& info) override; std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) override; std::vector GetLatestClientSysInfo(uint32_t limit) override; + std::vector ListClientSysInfo(const SysInfoQuery& query) override; + uint64_t CountClientSysInfo(const SysInfoQuery& query) override; void InsertModerationDecision(const std::string& kind, int64_t subjectId, const std::string& subject, bool approved, const std::string& reason, int64_t time) override; nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override; Totp GetTotp(uint32_t accountId) override; diff --git a/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp b/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp index 0073cfd06..3df4da781 100644 --- a/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp +++ b/dDatabase/GameDatabase/SQLite/Tables/ClientSysInfo.cpp @@ -1,7 +1,10 @@ #include "SQLiteDatabase.h" +#include "ClientSysInfoSql.h" +#include "GeneralUtils.h" + namespace { - IClientSysInfo::SysInfoRow Row(CppSQLite3Query& r) { + IClientSysInfo::SysInfoRow Row(CppSQLite3Query& r, const bool withName = false) { IClientSysInfo::SysInfoRow s; s.id = static_cast(r.getInt64Field("id")); s.accountId = static_cast(r.getInt64Field("account_id")); @@ -22,6 +25,7 @@ namespace { s.minorVersion = static_cast(r.getInt64Field("os_minor_version")); s.buildNumber = static_cast(r.getInt64Field("os_build_number")); s.platformId = static_cast(r.getInt64Field("os_platform_id")); + if (withName) s.accountName = r.getStringField("account_name"); return s; } } @@ -58,3 +62,19 @@ std::vector SQLiteDatabase::GetLatestClientSysInfo(c for (; !result.eof(); result.nextRow()) rows.push_back(Row(result)); return rows; } + +std::vector SQLiteDatabase::ListClientSysInfo(const SysInfoQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto [_, result] = ExecuteSelect("SELECT c.*, COALESCE(a.name, '') AS account_name" + ClientSysInfoSql::From(q, "'%' || ? || '%'") + + ClientSysInfoSql::Order(q) + " LIMIT ? OFFSET ?;", q.accountId, q.accountId, q.search, pattern, pattern, q.limit, q.offset); + std::vector rows; + for (; !result.eof(); result.nextRow()) rows.push_back(Row(result, true)); + return rows; +} + +uint64_t SQLiteDatabase::CountClientSysInfo(const SysInfoQuery& q) { + const auto pattern = GeneralUtils::LikeEscape(q.search, '!'); + auto [_, result] = ExecuteSelect("SELECT COUNT(*) AS count" + ClientSysInfoSql::From(q, "'%' || ? || '%'") + ";", + q.accountId, q.accountId, q.search, pattern, pattern); + return result.eof() ? 0 : static_cast(result.getInt64Field("count")); +} diff --git a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h index 0a128daca..20a8602e8 100644 --- a/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h +++ b/dDatabase/GameDatabase/TestSQL/TestSQLDatabase.h @@ -384,6 +384,8 @@ class TestSQLDatabase : public GameDatabase { void RecordClientSysInfo(const SysInfoRow& info) override {} std::vector GetClientSysInfo(uint32_t accountId, uint32_t limit) override { return {}; } std::vector GetLatestClientSysInfo(uint32_t limit) override { return {}; } + std::vector ListClientSysInfo(const SysInfoQuery& query) override { return {}; } + uint64_t CountClientSysInfo(const SysInfoQuery& query) override { return 0; } void InsertModerationDecision(const std::string& kind, int64_t subjectId, const std::string& subject, bool approved, const std::string& reason, int64_t time) override {} nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override { return nlohmann::json::array(); } Totp GetTotp(uint32_t accountId) override { return {}; } diff --git a/tests/dDatabaseTests/ChatHistorySqliteTests.cpp b/tests/dDatabaseTests/ChatHistorySqliteTests.cpp index 407808411..82fd0db12 100644 --- a/tests/dDatabaseTests/ChatHistorySqliteTests.cpp +++ b/tests/dDatabaseTests/ChatHistorySqliteTests.cpp @@ -389,3 +389,40 @@ TEST(ClientSysInfoSqlite, PruningDropsRowsNotSeenSince) { EXPECT_TRUE(g_Db->GetClientSysInfo(903, 10).empty()); EXPECT_EQ(g_Db->GetClientSysInfo(904, 10).size(), 1u); } + +TEST(ClientSysInfoSqlite, ListsAcrossAccounts) { + auto s = SysInfo(910, 7000); + g_Db->RecordClientSysInfo(s); + s.buildNumber = 2600; + s.lastSeen = 7100; + g_Db->RecordClientSysInfo(s); + s.accountId = 911; + s.videoCard = "Other Adapter (HAL-hw vp)"; + g_Db->RecordClientSysInfo(s); + + IClientSysInfo::SysInfoQuery q; + q.accountId = 910; + auto rows = g_Db->ListClientSysInfo(q); + ASSERT_EQ(rows.size(), 2u); + EXPECT_EQ(rows[0].buildNumber, 2600u); // newest first + EXPECT_EQ(g_Db->CountClientSysInfo(q), 2u); + q.ascending = true; + q.order = IClientSysInfo::eSysInfoOrder::OS_VERSION; + EXPECT_EQ(g_Db->ListClientSysInfo(q)[0].buildNumber, 2600u); + + IClientSysInfo::SysInfoQuery search; + search.search = "Other Adapter"; + rows = g_Db->ListClientSysInfo(search); + ASSERT_EQ(rows.size(), 1u); + EXPECT_EQ(rows[0].accountId, 911u); + // A LIKE wildcard in the search is taken literally + search.search = "%"; + EXPECT_EQ(g_Db->CountClientSysInfo(search), 0u); + + IClientSysInfo::SysInfoQuery latest; + latest.latestOnly = true; + latest.accountId = 910; + rows = g_Db->ListClientSysInfo(latest); + ASSERT_EQ(rows.size(), 1u); + EXPECT_EQ(rows[0].buildNumber, 2600u); +} diff --git a/tests/dDatabaseTests/DatabaseParityTests.cpp b/tests/dDatabaseTests/DatabaseParityTests.cpp index 004f38efa..58911e9d7 100644 --- a/tests/dDatabaseTests/DatabaseParityTests.cpp +++ b/tests/dDatabaseTests/DatabaseParityTests.cpp @@ -1485,6 +1485,33 @@ TEST_F(ParitySeeded, Moderation) { } return out; }); + Both("ListClientSysInfo", [](GameDatabase& db) { + json out = json::array(); + const auto list = [&db, &out](const IClientSysInfo::SysInfoQuery& q) { + json rows = json::array(); + for (const auto& r : db.ListClientSysInfo(q)) rows.push_back({ r.accountId, r.accountName, r.buildNumber, r.logins }); + out.push_back({ rows, db.CountClientSysInfo(q) }); + }; + IClientSysInfo::SysInfoQuery q; + list(q); + q.latestOnly = true; + list(q); + q = {}; + q.accountId = 1; + q.order = IClientSysInfo::eSysInfoOrder::OS_VERSION; + q.ascending = true; + list(q); + q = {}; + q.search = "Card"; + q.order = IClientSysInfo::eSysInfoOrder::ACCOUNT; + q.limit = 2; + q.offset = 1; + list(q); + q = {}; + q.search = "no such card"; + list(q); + return out; + }); Both("PruneLog client sysinfo", [](GameDatabase& db) { return db.PruneLog(IDashboardAdmin::eLog::CLIENT_SYSINFO, 1700000150); }); Both("InsertModerationDecision", [](GameDatabase& db) { db.InsertModerationDecision("name", CHAR_ALICE2, "AliceRenamed", false, "Not allowed", 1700000000);