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 "ClientSysInfoView.h"
#include "Database.h" #include "Database.h"
#include "GeneralUtils.h"
#include <algorithm>
#include "RouteUtils.h" #include "RouteUtils.h"
#include "eHTTPMethod.h" #include "eHTTPMethod.h"
@@ -27,14 +30,46 @@ void ClientSysInfoRoutes::RegisterRoutes() {
const bool showIp = Can(context, "logs_audit"); const bool showIp = Can(context, "logs_audit");
nlohmann::json rows = nlohmann::json::array(); nlohmann::json rows = nlohmann::json::array();
for (const auto& row : Database::Get()->GetClientSysInfo(*accountId, HISTORY_ROWS)) rows.push_back(ClientSysInfoView::RowJson(row, showIp)); 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"), 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 " "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}", "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))}, 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; 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) { std::string ClientSysInfoView::OsVersion(const IClientSysInfo::SysInfoRow& row) {
return std::to_string(row.majorVersion) + "." + std::to_string(row.minorVersion) + "." + std::to_string(row.buildNumber); 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)}, {"os_version", OsVersion(row)}, {"os_label", OsLabel(row)},
}; };
if (showIp) out["ip"] = row.ip; if (showIp) out["ip"] = row.ip;
if (!row.accountName.empty()) out["account_name"] = row.accountName;
return out; 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 // field -> what it is and how far to trust it, for the page and the API
const nlohmann::json& Caveats(); 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 // 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). // address is only included when showIp (logs_audit).
nlohmann::json RowJson(const IClientSysInfo::SysInfoRow& row, bool showIp); 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; border-radius: .2rem; background: var(--bs-warning-bg-subtle); color: var(--bs-warning-text-emphasis); max-width: 9rem; overflow: hidden;
text-overflow: ellipsis; } text-overflow: ellipsis; }
.timeline-marker:hover > span, .timeline-marker:focus-visible > span { max-width: none; z-index: 1; } .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 * 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. * account's client sent at login, and the Client System Info page (#sysinfoPage) with the spread across players and
* Everything here is as reported by the client, which often reports compatibility values rather than the real * every report browsable (#sysinfoTable). Everything here is as reported by the client, which often reports
* hardware; each field is shown with what it is worth (the caveats the API sends). * 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 () { (function () {
'use strict'; 'use strict';
var caveats = {}, trust = {}, showsIp = false;
function kb(n) { return n === null || n === undefined ? '-' : fmt.bytes(Number(n) * 1024); } 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 bytes(n) { return n === null || n === undefined ? '-' : fmt.bytes(n); }
function hex(n) { return '0x' + Number(n).toString(16); } 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] // Every field of one row: [label, value (html), caveat key]
function fields(r, showsIp) { function fields(r) {
var m = r.memory; var m = r.memory;
var list = [ var list = [
['Client build (clientOS)', esc(r.client_os) + ' (' + esc(r.client_os_name) + ')', 'clientOs'], ['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'], ['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)'), ''], ['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), ''], ['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), ''], ['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) + ')', ''], ['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'], ['Video card (videoCard)', esc(r.video_card || '(empty)'), 'videoCard'],
['Processors (numberOfProcessors)', esc(r.number_of_processors), 'numberOfProcessors'], ['Processors (numberOfProcessors)', esc(r.number_of_processors), 'numberOfProcessors'],
['Processor type (processorType)', esc(r.processor_type), 'processorType'], ['Processor type (processorType)', esc(r.processor_type), 'processorType'],
@@ -35,34 +52,83 @@
return list; 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) { function accountCard(card) {
var accountId = card.dataset.account; var accountId = card.dataset.account;
api.get('/api/accounts/' + accountId + '/client_sysinfo').then(function (d) { var table = reportTable('#sysinfoTable', function () { return { account: accountId }; }, false);
if (!d.success) return; table.on('xhr', function (e, settings, json) {
document.getElementById('sysinfoCount').textContent = d.rows.length; if (json) document.getElementById('sysinfoCount').textContent = json.recordsTotal;
var list = document.getElementById('sysinfoList'); });
if (!d.rows.length) { table.on('draw', function () { headerTips(card); });
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 () {});
} }
function spreadPage() { function spreadPage() {
api.get('/api/client_sysinfo/spread').then(function (d) { api.get('/api/client_sysinfo/spread').then(function (d) {
if (!d.success) return; if (!d.success) return;
caveats = d.caveats || {};
trust = d.trust || {};
var s = d.spread; var s = d.spread;
document.getElementById('sysinfoAccounts').textContent = s.accounts; document.getElementById('sysinfoAccounts').textContent = s.accounts;
[['os', 'osVersion'], ['video', 'videoCard'], ['memory', 'memoryStats'], ['processors', 'numberOfProcessors'], ['clientOs', 'clientOs']].forEach(function (pair) { [['os', 'osVersion'], ['video', 'videoCard'], ['memory', 'memoryStats'], ['processors', 'numberOfProcessors'], ['clientOs', 'clientOs']].forEach(function (pair) {
@@ -71,15 +137,30 @@
var rows = s[pair[0]]; var rows = s[pair[0]];
var html = rows.map(function (e) { var html = rows.map(function (e) {
var pct = s.accounts ? Math.round(e.count * 1000 / s.accounts) / 10 : 0; 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">' + '<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>'; '<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>'; }).join('') || '<tr><td class="text-body-secondary">Nothing reported yet.</td></tr>';
el.querySelector('tbody').innerHTML = html; el.querySelector('tbody').innerHTML = html;
var note = el.querySelector('[data-caveat]'); 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 () {}); }).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'); 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 <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. 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> 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>
</div> </div>
{% endif %} {% 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. and under Wine or Proton the answers are whatever Wine reports. Treat this as what clients claim, not as players' real hardware.
</div> </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>. <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="row g-4">
<div class="col-lg-6"> <div class="col-lg-6">
@@ -60,6 +60,26 @@
</div> </div>
</div> </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> </div>
{% endblock %} {% 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 minorVersion{};
uint32_t buildNumber{}; uint32_t buildNumber{};
uint32_t platformId{}; 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) // 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 { bool SameAs(const SysInfoRow& other) const {
@@ -53,6 +54,23 @@ public:
// Each account's newest row, for the spread across players // Each account's newest row, for the spread across players
virtual std::vector<SysInfoRow> GetLatestClientSysInfo(uint32_t limit) = 0; 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__ #endif //!__ICLIENTSYSINFO__H__

View File

@@ -351,6 +351,8 @@ public:
void RecordClientSysInfo(const SysInfoRow& info) override; void RecordClientSysInfo(const SysInfoRow& info) override;
std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override; std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override;
std::vector<SysInfoRow> GetLatestClientSysInfo(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; 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; nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override;
Totp GetTotp(uint32_t accountId) override; Totp GetTotp(uint32_t accountId) override;

View File

@@ -1,7 +1,10 @@
#include "MySQLDatabase.h" #include "MySQLDatabase.h"
#include "ClientSysInfoSql.h"
#include "GeneralUtils.h"
namespace { namespace {
IClientSysInfo::SysInfoRow Row(sql::ResultSet& r) { IClientSysInfo::SysInfoRow Row(sql::ResultSet& r, const bool withName = false) {
IClientSysInfo::SysInfoRow s; IClientSysInfo::SysInfoRow s;
s.id = r.getUInt64("id"); s.id = r.getUInt64("id");
s.accountId = r.getUInt("account_id"); s.accountId = r.getUInt("account_id");
@@ -22,6 +25,7 @@ namespace {
s.minorVersion = r.getUInt("os_minor_version"); s.minorVersion = r.getUInt("os_minor_version");
s.buildNumber = r.getUInt("os_build_number"); s.buildNumber = r.getUInt("os_build_number");
s.platformId = r.getUInt("os_platform_id"); s.platformId = r.getUInt("os_platform_id");
if (withName) s.accountName = r.getString("account_name").c_str();
return s; return s;
} }
} }
@@ -56,3 +60,19 @@ std::vector<IClientSysInfo::SysInfoRow> MySQLDatabase::GetLatestClientSysInfo(co
while (result->next()) rows.push_back(Row(*result.m_resultSet)); while (result->next()) rows.push_back(Row(*result.m_resultSet));
return rows; 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; void RecordClientSysInfo(const SysInfoRow& info) override;
std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override; std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override;
std::vector<SysInfoRow> GetLatestClientSysInfo(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; 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; nlohmann::json GetModerationDecisions(const std::string& kind, int64_t subjectId, uint32_t limit) override;
Totp GetTotp(uint32_t accountId) override; Totp GetTotp(uint32_t accountId) override;

View File

@@ -1,7 +1,10 @@
#include "SQLiteDatabase.h" #include "SQLiteDatabase.h"
#include "ClientSysInfoSql.h"
#include "GeneralUtils.h"
namespace { namespace {
IClientSysInfo::SysInfoRow Row(CppSQLite3Query& r) { IClientSysInfo::SysInfoRow Row(CppSQLite3Query& r, const bool withName = false) {
IClientSysInfo::SysInfoRow s; IClientSysInfo::SysInfoRow s;
s.id = static_cast<uint64_t>(r.getInt64Field("id")); s.id = static_cast<uint64_t>(r.getInt64Field("id"));
s.accountId = static_cast<uint32_t>(r.getInt64Field("account_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.minorVersion = static_cast<uint32_t>(r.getInt64Field("os_minor_version"));
s.buildNumber = static_cast<uint32_t>(r.getInt64Field("os_build_number")); s.buildNumber = static_cast<uint32_t>(r.getInt64Field("os_build_number"));
s.platformId = static_cast<uint32_t>(r.getInt64Field("os_platform_id")); s.platformId = static_cast<uint32_t>(r.getInt64Field("os_platform_id"));
if (withName) s.accountName = r.getStringField("account_name");
return s; return s;
} }
} }
@@ -58,3 +62,19 @@ std::vector<IClientSysInfo::SysInfoRow> SQLiteDatabase::GetLatestClientSysInfo(c
for (; !result.eof(); result.nextRow()) rows.push_back(Row(result)); for (; !result.eof(); result.nextRow()) rows.push_back(Row(result));
return rows; 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 {} void RecordClientSysInfo(const SysInfoRow& info) override {}
std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override { return {}; } std::vector<SysInfoRow> GetClientSysInfo(uint32_t accountId, uint32_t limit) override { return {}; }
std::vector<SysInfoRow> GetLatestClientSysInfo(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 {} 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(); } 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 {}; } 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_TRUE(g_Db->GetClientSysInfo(903, 10).empty());
EXPECT_EQ(g_Db->GetClientSysInfo(904, 10).size(), 1u); 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; 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("PruneLog client sysinfo", [](GameDatabase& db) { return db.PruneLog(IDashboardAdmin::eLog::CLIENT_SYSINFO, 1700000150); });
Both("InsertModerationDecision", [](GameDatabase& db) { Both("InsertModerationDecision", [](GameDatabase& db) {
db.InsertModerationDecision("name", CHAR_ALICE2, "AliceRenamed", false, "Not allowed", 1700000000); db.InsertModerationDecision("name", CHAR_ALICE2, "AliceRenamed", false, "Not allowed", 1700000000);