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 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 08:06:02 -05:00
parent bd13297947
commit d94d84b5cb
16 changed files with 363 additions and 40 deletions

View File

@@ -2,6 +2,9 @@
#include "ClientSysInfoView.h"
#include "Database.h"
#include "GeneralUtils.h"
#include <algorithm>
#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<uint32_t>();
else if (it->is_string()) query.accountId = GeneralUtils::TryParse<uint32_t>(it->get<std::string>()).value_or(0);
}
query.order = static_cast<IClientSysInfo::eSysInfoOrder>(std::min<uint32_t>(request->orderColumn,
static_cast<uint32_t>(IClientSysInfo::eSysInfoOrder::FIRST_SEEN)));
query.ascending = request->orderAsc;
query.offset = request->start;
query.limit = std::clamp<uint32_t>(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} });
});
}

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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; }

View File

@@ -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] ? '<div class="small text-body-secondary">' + esc(caveats[key]) + '</div>' : ''; }
// 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' ? ' <span class="text-warning" aria-hidden="true">&#9888;</span>' : '';
var label = level === 'unreliable' ? 'Unreliable: ' : level === 'approximate' ? 'Approximate: ' : '';
return '<span class="sysinfo-tip sysinfo-' + esc(level || 'note') + '" tabindex="0" data-bs-toggle="tooltip" data-bs-title="' + esc(label + text) + '">' +
html + mark + '</span>';
}
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', '<code class="text-break">' + esc(r.memory_stats) + '</code>' + (m.complete ? '' : ' <span class="badge text-bg-secondary">not fully read</span>'), '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 '<div class="table-responsive"><table class="table table-sm mb-0"><tbody>' + fields(r).map(function (f) {
return '<tr><th class="fw-normal text-nowrap">' + esc(f[0]) + '</th><td>' + tip(f[2], f[1]) + '</td></tr>';
}).join('') + '</tbody></table></div>';
}
// 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 '<a href="/accounts/' + encodeURIComponent(r.account_id) + '">' + esc(d || ('#' + r.account_id)) + '</a>';
} },
{ data: 'logins', className: 'text-end' },
{ data: 'os_version', render: function (d, t, r) { return tip('osVersion', esc(d)) + '<div class="small text-body-secondary">' + esc(r.os_label) + '</div>'; } },
{ 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 = '<p class="text-body-secondary mb-0">Nothing on record: the client sends this at each game login (kept for log_client_sysinfo_days).</p>';
return;
}
list.innerHTML = d.rows.map(function (r, i) {
var summary = '<span class="fw-semibold">' + esc(fmt.unix(r.last_seen)) + '</span>' +
(r.first_seen !== r.last_seen ? ' <span class="text-body-secondary">since ' + esc(fmt.unix(r.first_seen)) + '</span>' : '') +
' · ' + 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 ? ' · <code>' + esc(r.ip) + '</code>' : '');
var rows = fields(r, d.showsIp).map(function (f) {
return '<tr><th class="fw-normal text-nowrap">' + esc(f[0]) + '</th><td>' + f[1] + caveat(d.caveats, f[2]) + '</td></tr>';
}).join('');
return '<details class="mb-2"' + (i === 0 ? ' open' : '') + '><summary>' + summary + '</summary>' +
'<div class="table-responsive"><table class="table table-sm mb-0"><tbody>' + rows + '</tbody></table></div></details>';
}).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 '<tr><td>' + esc(e.label) + '</td><td class="text-end text-nowrap">' + esc(e.count) + '</td>' +
// A video card opens the reports from that card
var label = pair[0] === 'video' && e.label !== 'Other' && e.label !== '(empty)'
? '<a href="#sysinfoAll" data-search="' + esc(e.label) + '">' + esc(e.label) + '</a>' : esc(e.label);
return '<tr><td>' + label + '</td><td class="text-end text-nowrap">' + esc(e.count) + '</td>' +
'<td style="width:40%"><div class="progress" role="progressbar" aria-label="' + esc(e.label) + '" aria-valuenow="' + pct + '" aria-valuemin="0" aria-valuemax="100">' +
'<div class="progress-bar" style="width:' + pct + '%"></div></div></td><td class="text-end text-nowrap small">' + pct + '%</td></tr>';
}).join('') || '<tr><td class="text-body-secondary">Nothing reported yet.</td></tr>';
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' ? '<span class="text-warning" aria-hidden="true">&#9888;</span> <strong>Unreliable.</strong> ' : '') + 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');

View File

@@ -164,7 +164,13 @@
<p class="small text-body-secondary mb-2">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 %}</p>
<div id="sysinfoList"><p class="text-body-secondary mb-0">Loading&hellip;</p></div>
<p class="small text-body-secondary mb-2">Dotted values have a tooltip saying what they're worth; &#9888; marks values that say little or nothing about the
player's real system. Click a row for every field.</p>
<table id="sysinfoTable" class="table table-sm table-hover table-stack align-middle w-100">
<thead><tr><th>Last seen</th><th>Account</th><th class="text-end">Logins</th><th data-caveat="os">Windows reported</th><th data-caveat="video">Video card</th>
<th class="text-end" data-caveat="cpus">CPUs</th><th class="text-end" data-caveat="memory">Memory</th><th data-caveat="client">Client build</th><th>First seen</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
{% endif %}

View File

@@ -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.
</div>
<p class="text-body-secondary">From each account's newest report (<span id="sysinfoAccounts">&hellip;</span> accounts), kept for <code>log_client_sysinfo_days</code>.
Each account's own history is on its account page.</p>
Every report is listed below; each account's own reports are also on its account page.</p>
<div class="row g-4">
<div class="col-lg-6">
@@ -60,6 +60,26 @@
</div>
</div>
</div>
<div class="card mt-4" id="sysinfoAll">
<div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<h5 class="mb-0">All reports</h5>
<div class="form-check form-switch mb-0 small">
<input class="form-check-input" type="checkbox" id="sysinfoLatest" data-pref="client_sysinfo.latest">
<label class="form-check-label" for="sysinfoLatest">Only each account's newest</label>
</div>
</div>
<div class="card-body">
<p class="small text-body-secondary mb-2">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; &#9888; 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.</p>
<table id="sysinfoTable" class="table table-sm table-hover table-stack align-middle w-100">
<thead><tr><th>Last seen</th><th>Account</th><th class="text-end">Logins</th><th data-caveat="os">Windows reported</th><th data-caveat="video">Video card</th>
<th class="text-end" data-caveat="cpus">CPUs</th><th class="text-end" data-caveat="memory">Memory</th><th data-caveat="client">Client build</th><th>First seen</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,34 @@
#pragma once
#include <string>
#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;
}
}

View File

@@ -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<SysInfoRow> 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<SysInfoRow> ListClientSysInfo(const SysInfoQuery& query) = 0;
virtual uint64_t CountClientSysInfo(const SysInfoQuery& query) = 0;
};
#endif //!__ICLIENTSYSINFO__H__

View File

@@ -351,6 +351,8 @@ public:
void RecordClientSysInfo(const SysInfoRow& info) override;
std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override;
std::vector<SysInfoRow> GetLatestClientSysInfo(uint32_t limit) override;
std::vector<SysInfoRow> 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;

View File

@@ -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<IClientSysInfo::SysInfoRow> MySQLDatabase::GetLatestClientSysInfo(co
while (result->next()) rows.push_back(Row(*result.m_resultSet));
return rows;
}
std::vector<IClientSysInfo::SysInfoRow> 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<SysInfoRow> 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;
}

View File

@@ -335,6 +335,8 @@ public:
void RecordClientSysInfo(const SysInfoRow& info) override;
std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override;
std::vector<SysInfoRow> GetLatestClientSysInfo(uint32_t limit) override;
std::vector<SysInfoRow> 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;

View File

@@ -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<uint64_t>(r.getInt64Field("id"));
s.accountId = static_cast<uint32_t>(r.getInt64Field("account_id"));
@@ -22,6 +25,7 @@ namespace {
s.minorVersion = static_cast<uint32_t>(r.getInt64Field("os_minor_version"));
s.buildNumber = static_cast<uint32_t>(r.getInt64Field("os_build_number"));
s.platformId = static_cast<uint32_t>(r.getInt64Field("os_platform_id"));
if (withName) s.accountName = r.getStringField("account_name");
return s;
}
}
@@ -58,3 +62,19 @@ std::vector<IClientSysInfo::SysInfoRow> SQLiteDatabase::GetLatestClientSysInfo(c
for (; !result.eof(); result.nextRow()) rows.push_back(Row(result));
return rows;
}
std::vector<IClientSysInfo::SysInfoRow> 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<SysInfoRow> 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<uint64_t>(result.getInt64Field("count"));
}

View File

@@ -384,6 +384,8 @@ class TestSQLDatabase : public GameDatabase {
void RecordClientSysInfo(const SysInfoRow& info) override {}
std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override { return {}; }
std::vector<SysInfoRow> GetLatestClientSysInfo(uint32_t limit) override { return {}; }
std::vector<SysInfoRow> 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 {}; }

View File

@@ -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);
}

View File

@@ -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);