feat(dashboard): client system info, as reported by the client

Account pages get a Client system info (as reported) card (client_sysinfo):
every description the account's client sent at login, newest first, with the
raw values, the memory text split into numbers and each field's caveat. The
address is only shown with logs_audit. The Client System Info page (Logs &
Health) shows the spread across players from each account's newest report:
Windows version, video card, memory buckets, processor count and client build,
marked as client-reported and approximate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 19:07:46 -05:00
parent 05a99cef82
commit c44af311ad
12 changed files with 540 additions and 2 deletions

View File

@@ -56,6 +56,7 @@
#include "VanityRoutes.h"
#include "ChatRoutes.h"
#include "ChatFlagRoutes.h"
#include "ClientSysInfoRoutes.h"
#include "Strikes.h"
#include "ModerationTools.h"
#include "ModeratorHelper.h"
@@ -549,6 +550,7 @@ int main(int argc, char** argv) {
RegisterVanityRoutes();
RegisterChatRoutes();
ChatFlagRoutes::RegisterRoutes();
ClientSysInfoRoutes::RegisterRoutes();
RegisterStrikeRoutes();
RegisterModerationToolRoutes();
ModeratorHelper::RegisterRoutes();

View File

@@ -40,6 +40,8 @@ set(DASHBOARDROUTES_SOURCES
"ChatRoutes.cpp"
"ChatHistory.cpp"
"ChatFlagRoutes.cpp"
"ClientSysInfoRoutes.cpp"
"ClientSysInfoView.cpp"
"Strikes.cpp"
"Inspector.cpp"
"CDClientBrowser.cpp"

View File

@@ -0,0 +1,40 @@
#include "ClientSysInfoRoutes.h"
#include "ClientSysInfoView.h"
#include "Database.h"
#include "RouteUtils.h"
#include "eHTTPMethod.h"
using namespace RouteUtils;
namespace {
constexpr uint32_t HISTORY_ROWS = 200;
constexpr uint32_t SPREAD_ACCOUNTS = 100000;
}
void ClientSysInfoRoutes::RegisterRoutes() {
Route(eHTTPMethod::GET, "/client_sysinfo", Perm("client_sysinfo"), "Client system info: the spread across players, as reported by the client",
[](HTTPReply& reply, const HTTPContext& context) {
RenderPage(reply, context, "client_sysinfo.jinja2", "client_sysinfo");
});
Route(eHTTPMethod::GET, "/api/accounts/:id/client_sysinfo", Perm("client_sysinfo"),
"The system info an account's client sent at login, as reported by the client (compatibility values from old Windows calls, not "
"necessarily the real hardware): {rows: [...] newest first (at most 200), caveats: {field: text}, showsIp}. ip only with logs_audit",
[](HTTPReply& reply, const HTTPContext& context) {
const auto accountId = PathId<uint32_t>(context.path, 2);
if (!accountId) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "Invalid account");
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} });
});
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&) {
JsonSuccess(reply, { {"spread", ClientSysInfoView::Spread(Database::Get()->GetLatestClientSysInfo(SPREAD_ACCOUNTS))},
{"caveats", ClientSysInfoView::Caveats()} });
});
}

View File

@@ -0,0 +1,10 @@
#pragma once
/**
* Client system info (client_sysinfo): what each account's game client reported about its system at login, on the
* account page, and the spread across players on its own page. Values are as reported by the client and often
* compatibility defaults, not the real hardware (ClientSysInfoView::Caveats). Addresses only with logs_audit.
*/
namespace ClientSysInfoRoutes {
void RegisterRoutes();
}

View File

@@ -0,0 +1,151 @@
#include "ClientSysInfoView.h"
#include <algorithm>
#include <map>
#include "ClientSysInfo.h"
namespace {
nlohmann::json Optional(const std::optional<int64_t>& value) {
return value ? nlohmann::json(*value) : nlohmann::json(nullptr);
}
// label -> count, most common first (then by label), the tail folded into "Other"
nlohmann::json Ranked(const std::map<std::string, uint64_t>& counts, size_t top) {
std::vector<std::pair<std::string, uint64_t>> sorted(counts.begin(), counts.end());
std::sort(sorted.begin(), sorted.end(), [](const auto& a, const auto& b) { return a.second != b.second ? a.second > b.second : a.first < b.first; });
nlohmann::json out = nlohmann::json::array();
uint64_t other = 0;
for (size_t i = 0; i < sorted.size(); i++) {
if (i < top) out.push_back({ {"label", sorted[i].first}, {"count", sorted[i].second} });
else other += sorted[i].second;
}
if (other > 0) out.push_back({ {"label", "Other"}, {"count", other} });
return out;
}
// The adapter part of the video card text: the client adds " (HAL-<vertex processing>)" after it
std::string Adapter(const std::string& videoCard) {
const auto paren = videoCard.rfind(" (");
const auto adapter = paren == std::string::npos ? videoCard : videoCard.substr(0, paren);
return adapter.empty() ? "(empty)" : adapter;
}
std::string ClientOsName(uint32_t clientOs) {
switch (clientOs) {
case 1: return "Windows";
case 2: return "Mac";
default: return "Unknown (" + std::to_string(clientOs) + ")";
}
}
}
const nlohmann::json& ClientSysInfoView::Caveats() {
static const nlohmann::json caveats = {
{"ip", "The address the login came from. Only kept while log_login_addresses is on, and only shown with logs_audit."},
{"clientOs", "1 Windows, 2 Mac: which build of the client this is (from its own settings), not the operating system it runs on."},
{"memoryStats", "The client's memory text, as sent (the newest login's while the rest stays the same). Physical memory and the commit "
"limit (pfile) are the system's totals from GlobalMemoryStatusEx; under Wine they are the host's. The vmem figures are the 32-bit "
"client's own address space (2 or 4 GB), not the system's. p/v bytes are the client process's own use at login. Memory load and "
"free amounts change every login."},
{"videoCard", "From Direct3D 9: the adapter description the driver reports, then the device type (HAL, REF or SW) and vertex processing "
"mode. Usually the real graphics card; under Wine or DXVK it is what the translation layer reports (normally the real card, "
"sometimes a stand-in). Cut at 127 characters."},
{"numberOfProcessors", "GetSystemInfo: logical processors a 32-bit program sees (at most 32). Under Wine, the host's count."},
{"processorType", "GetSystemInfo's old processor type: 586 for every x86 processor seen from a 32-bit program. Says nothing about the CPU."},
{"processorLevel", "GetSystemInfo: the CPU family number (6 for most Intel CPUs; AMD uses other families, such as 23 or 25 for Ryzen)."},
{"processorRevision", "GetSystemInfo: model (high byte) and stepping (low byte) within the family."},
{"osVersionInfoSize", "The size of the structure the client asked GetVersionExW to fill: always 276. Not system info."},
{"osVersion", "GetVersionExW. The client has no compatibility manifest, so Windows 8.1, 10 and 11 all report 6.2 build 9200. "
"A compatibility mode reports the version it imitates (XP SP3: 5.1.2600), and Wine or Proton report the Windows version they "
"are set to. If the call failed the fields hold whatever was in memory."},
{"platformId", "GetVersionExW: 2 (Windows NT) on every Windows the client runs on."},
};
return caveats;
}
std::string ClientSysInfoView::OsVersion(const IClientSysInfo::SysInfoRow& row) {
return std::to_string(row.majorVersion) + "." + std::to_string(row.minorVersion) + "." + std::to_string(row.buildNumber);
}
std::string ClientSysInfoView::OsLabel(const IClientSysInfo::SysInfoRow& row) {
const auto major = row.majorVersion, minor = row.minorVersion;
if (major == 5 && minor == 1) return "Windows XP (or XP compatibility mode)";
if (major == 5 && minor == 2) return "Windows XP x64 / Server 2003 (or compatibility mode)";
if (major == 6 && minor == 0) return "Windows Vista (or Vista compatibility mode)";
if (major == 6 && minor == 1) return "Windows 7 (or Windows 7 compatibility mode)";
if (major == 6 && minor == 2) return "Windows 8 or newer (8.1, 10 and 11 report 6.2 to this client)";
if (major == 6 && minor == 3) return "Windows 8.1";
if (major == 10 && minor == 0) return "Windows 10 or 11";
return "Unknown";
}
std::string ClientSysInfoView::MemoryBucket(const uint64_t totalKb) {
if (totalKb == 0) return "Not read";
constexpr uint64_t GB = 1024 * 1024;
// Totals are a little under the installed amount (memory the firmware keeps), so each bucket ends just above it
constexpr std::pair<uint64_t, const char*> BUCKETS[]{ { 2, "Under 2 GB" }, { 4, "2-4 GB" }, { 8, "4-8 GB" }, { 16, "8-16 GB" }, { 32, "16-32 GB" }, { 64, "32-64 GB" } };
for (const auto& [limit, label] : BUCKETS) {
if (totalKb <= limit * GB) return label;
}
return "Over 64 GB";
}
nlohmann::json ClientSysInfoView::RowJson(const IClientSysInfo::SysInfoRow& row, const bool showIp) {
const auto memory = ClientSysInfo::ParseMemoryStats(row.memoryStats);
nlohmann::json out = {
{"id", row.id}, {"account_id", row.accountId}, {"first_seen", row.firstSeen}, {"last_seen", row.lastSeen}, {"logins", row.logins},
{"client_os", row.clientOs}, {"client_os_name", ClientOsName(row.clientOs)},
{"memory_stats", row.memoryStats},
{"memory", {
{"complete", memory.complete},
{"working_set_bytes", Optional(memory.workingSetBytes)}, {"pagefile_usage_bytes", Optional(memory.pagefileUsageBytes)},
{"memory_load_percent", Optional(memory.memoryLoadPercent)},
{"total_phys_kb", Optional(memory.totalPhysKb)}, {"avail_phys_kb", Optional(memory.availPhysKb)},
{"total_pagefile_kb", Optional(memory.totalPageFileKb)}, {"avail_pagefile_kb", Optional(memory.availPageFileKb)},
{"total_virtual_kb", Optional(memory.totalVirtualKb)}, {"avail_virtual_kb", Optional(memory.availVirtualKb)},
{"peak_working_set_bytes", Optional(memory.peakWorkingSetBytes)}, {"peak_pagefile_usage_bytes", Optional(memory.peakPagefileUsageBytes)},
}},
{"memory_total_kb", row.memoryTotalKb}, {"memory_bucket", MemoryBucket(row.memoryTotalKb)},
{"video_card", row.videoCard},
{"number_of_processors", row.numberOfProcessors}, {"processor_type", row.processorType},
{"processor_level", row.processorLevel}, {"processor_revision", row.processorRevision},
{"processor_model", row.processorRevision >> 8}, {"processor_stepping", row.processorRevision & 0xFF},
{"os_version_info_size", row.osVersionInfoSize}, {"os_major_version", row.majorVersion}, {"os_minor_version", row.minorVersion},
{"os_build_number", row.buildNumber}, {"os_platform_id", row.platformId},
{"os_version", OsVersion(row)}, {"os_label", OsLabel(row)},
};
if (showIp) out["ip"] = row.ip;
return out;
}
nlohmann::json ClientSysInfoView::Spread(const std::vector<IClientSysInfo::SysInfoRow>& latest, const size_t top) {
std::map<std::string, uint64_t> os, video, memory, processors, clientOs;
for (const auto& row : latest) {
os[OsVersion(row) + " - " + OsLabel(row)]++;
video[Adapter(row.videoCard)]++;
memory[MemoryBucket(row.memoryTotalKb)]++;
processors[std::to_string(row.numberOfProcessors)]++;
clientOs[ClientOsName(row.clientOs)]++;
}
// Memory buckets and processor counts read best in their own order
auto ordered = [](const std::map<std::string, uint64_t>& counts, const std::vector<std::string>& order) {
nlohmann::json out = nlohmann::json::array();
for (const auto& label : order) {
const auto it = counts.find(label);
if (it != counts.end()) out.push_back({ {"label", label}, {"count", it->second} });
}
return out;
};
std::vector<std::string> cpuOrder;
for (const auto& [label, _] : processors) cpuOrder.push_back(label);
std::sort(cpuOrder.begin(), cpuOrder.end(), [](const std::string& a, const std::string& b) { return a.size() != b.size() ? a.size() < b.size() : a < b; });
return {
{"accounts", latest.size()},
{"os", Ranked(os, top)},
{"video", Ranked(video, top)},
{"memory", ordered(memory, { "Under 2 GB", "2-4 GB", "4-8 GB", "8-16 GB", "16-32 GB", "32-64 GB", "Over 64 GB", "Not read" })},
{"processors", ordered(processors, cpuOrder)},
{"clientOs", Ranked(clientOs, top)},
};
}

View File

@@ -0,0 +1,37 @@
#pragma once
#include <cstdint>
#include <string>
#include <vector>
#include "IClientSysInfo.h"
#include "json.hpp"
/**
* How the dashboard shows the system info clients send at login (client_sysinfo), without the database or web server.
* Every value is as reported by the client: old Windows calls in a 32-bit program without a compatibility manifest,
* so several fields are compatibility values rather than the player's real hardware. Caveats() says what each field
* is worth; the routes are in ClientSysInfoRoutes.cpp.
*/
namespace ClientSysInfoView {
// field -> what it is and how far to trust it, for the page and the API
const nlohmann::json& Caveats();
// 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);
// "6.2.9200", and what Windows version that is reported as
std::string OsVersion(const IClientSysInfo::SysInfoRow& row);
std::string OsLabel(const IClientSysInfo::SysInfoRow& row);
// A physical memory bucket by the reported total: "Under 2 GB", "2-4 GB", ... "64 GB or more", "Not read"
std::string MemoryBucket(uint64_t totalKb);
/**
* The spread across players from each account's newest row: {accounts, os: [{label, count}], video: [...],
* memory: [...], processors: [...], clientOs: [...]}, most common first, at most top entries each (the rest are
* one "Other" entry).
*/
nlohmann::json Spread(const std::vector<IClientSysInfo::SysInfoRow>& latest, size_t top = 20);
}

View File

@@ -0,0 +1,88 @@
/**
* 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).
*/
(function () {
'use strict';
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>' : ''; }
// Every field of one row: [label, value (html), caveat key]
function fields(r, showsIp) {
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) + ')', ''],
['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'],
['Processor level (processorLevel)', esc(r.processor_level), 'processorLevel'],
['Processor revision (processorRevision)', esc(r.processor_revision) + ' (' + hex(r.processor_revision) + ': model ' + esc(r.processor_model) + ', stepping ' + esc(r.processor_stepping) + ')', 'processorRevision'],
['Windows version (major.minor.build)', esc(r.os_version) + ' <span class="text-body-secondary">' + esc(r.os_label) + '</span>', 'osVersion'],
['Platform ID (platformID)', esc(r.os_platform_id), 'platformId'],
['Version info size (osVersionInfoSize)', esc(r.os_version_info_size), 'osVersionInfoSize']
];
if (showsIp) list.unshift(['Address', r.ip ? '<code>' + esc(r.ip) + '</code>' : '<span class="text-body-secondary">not kept</span>', 'ip']);
return list;
}
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 () {});
}
function spreadPage() {
api.get('/api/client_sysinfo/spread').then(function (d) {
if (!d.success) return;
var s = d.spread;
document.getElementById('sysinfoAccounts').textContent = s.accounts;
[['os', 'osVersion'], ['video', 'videoCard'], ['memory', 'memoryStats'], ['processors', 'numberOfProcessors'], ['clientOs', 'clientOs']].forEach(function (pair) {
var el = document.getElementById('spread-' + pair[0]);
if (!el) return;
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>' +
'<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]] || '';
});
}).catch(function () {});
}
var card = document.getElementById('sysinfoCard');
if (card) accountCard(card);
if (document.getElementById('sysinfoPage')) spreadPage();
})();

View File

@@ -157,6 +157,18 @@
</div>
{% endif %}
{% if can.client_sysinfo %}
<div class="card mb-4" id="sysinfoCard" data-account="{{ account.id }}">
<div class="card-header"><h5 class="mb-0">Client system info (as reported) <span class="badge rounded-pill text-bg-secondary align-middle" id="sysinfoCount"></span></h5></div>
<div class="card-body">
<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>
</div>
</div>
{% endif %}
{% if can.accounts_notes %}
<div class="card mb-4" id="historyCard">
<div class="card-header d-flex justify-content-between align-items-center"><h5 class="mb-0">Moderation history</h5><span class="small text-body-secondary" id="warningCount"></span></div>
@@ -701,6 +713,7 @@ function disconnectMailAccount() {
<script src="/js/related.js"></script>
<script src="/js/strikes.js"></script>
<script src="/js/account-links.js"></script>
<script src="/js/client-sysinfo.js"></script>
<script src="/js/api-keys.js"></script>
<script src="/js/grants.js"></script>
<script>

View File

@@ -0,0 +1,68 @@
{% extends "base.jinja2" %}
{% block title %}Client System Info - DarkflameServer{% endblock %}
{% block content %}
<div id="sysinfoPage">
<h2 class="mb-1">Client System Info</h2>
<div class="alert alert-warning small" role="note">
<strong>As reported by the client, approximate.</strong> The game client describes its system with old Windows functions called from a 32-bit program
with no compatibility manifest. Windows answers with compatibility values (8.1, 10 and 11 all say 6.2 build 9200; the processor type is always 586),
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>
<div class="row g-4">
<div class="col-lg-6">
<div class="card h-100" id="spread-os">
<div class="card-header"><h5 class="mb-0">Windows version reported</h5></div>
<div class="card-body">
<p class="small text-body-secondary" data-caveat></p>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><tbody><tr><td class="text-body-secondary">Loading&hellip;</td></tr></tbody></table></div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100" id="spread-video">
<div class="card-header"><h5 class="mb-0">Video card (Direct3D adapter)</h5></div>
<div class="card-body">
<p class="small text-body-secondary" data-caveat></p>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><tbody><tr><td class="text-body-secondary">Loading&hellip;</td></tr></tbody></table></div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100" id="spread-memory">
<div class="card-header"><h5 class="mb-0">Physical memory reported</h5></div>
<div class="card-body">
<p class="small text-body-secondary" data-caveat></p>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><tbody><tr><td class="text-body-secondary">Loading&hellip;</td></tr></tbody></table></div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100" id="spread-processors">
<div class="card-header"><h5 class="mb-0">Logical processors reported</h5></div>
<div class="card-body">
<p class="small text-body-secondary" data-caveat></p>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><tbody><tr><td class="text-body-secondary">Loading&hellip;</td></tr></tbody></table></div>
</div>
</div>
</div>
<div class="col-lg-6">
<div class="card h-100" id="spread-clientOs">
<div class="card-header"><h5 class="mb-0">Client build</h5></div>
<div class="card-body">
<p class="small text-body-secondary" data-caveat></p>
<div class="table-responsive"><table class="table table-sm align-middle mb-0"><tbody><tr><td class="text-body-secondary">Loading&hellip;</td></tr></tbody></table></div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="/js/client-sysinfo.js"></script>
{% endblock %}

View File

@@ -105,8 +105,8 @@
</div>
{% endif %}
{% set logsPages = ["health", "instances", "diagnostics", "network", "activity_log", "command_log", "audit_log", "system_log"] %}
{% if can.health_view or can.logs_activity or can.logs_command or can.logs_audit or can.logs_system %}
{% set logsPages = ["health", "instances", "diagnostics", "network", "activity_log", "command_log", "audit_log", "system_log", "client_sysinfo"] %}
{% if can.health_view or can.logs_activity or can.logs_command or can.logs_audit or can.logs_system or can.client_sysinfo %}
<div class="nav-group">
<button class="nav-group-toggle" type="button" data-bs-toggle="collapse" data-bs-target="#nav-logs" aria-expanded="{% if current_page in logsPages %}true{% else %}false{% endif %}" aria-controls="nav-logs">
Logs &amp; Health<span class="nav-group-dot" title="Something in here needs attention"></span>
@@ -117,6 +117,7 @@
{% if can.logs_activity %}<a href="/activity_log" class="list-group-item list-group-item-action{% if current_page == "activity_log" %} active{% endif %}">Activity Log</a>{% endif %}
{% if can.logs_command %}<a href="/command_log" class="list-group-item list-group-item-action{% if current_page == "command_log" %} active{% endif %}">Command Log</a>{% endif %}
{% if can.logs_audit %}<a href="/audit_log" class="list-group-item list-group-item-action{% if current_page == "audit_log" %} active{% endif %}">Audit Log</a>{% endif %}
{% if can.client_sysinfo %}<a href="/client_sysinfo" class="list-group-item list-group-item-action{% if current_page == "client_sysinfo" %} active{% endif %}">Client System Info</a>{% endif %}
{% if can.logs_system %}<a href="/system_log" class="list-group-item list-group-item-action{% if current_page == "system_log" %} active{% endif %}">System Log</a>{% endif %}
</div>
</div>

View File

@@ -35,6 +35,8 @@ set(DWEBTESTS_SOURCES
"LocaleTextTests.cpp"
"ModeratorHelperTests.cpp"
"ChatHistoryTests.cpp"
"ClientSysInfoViewTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ClientSysInfoView.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/ChatHistory.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ClaudeClient.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/ai/ModeratorPrompt.cpp"

View File

@@ -0,0 +1,124 @@
#include <gtest/gtest.h>
#include "ClientSysInfoView.h"
#include "Permissions.h"
namespace {
IClientSysInfo::SysInfoRow Row(uint32_t account, uint32_t build, const std::string& card, uint32_t cpus, uint64_t memoryKb) {
IClientSysInfo::SysInfoRow r;
r.id = account;
r.accountId = account;
r.firstSeen = 1700000000;
r.lastSeen = 1700000100;
r.logins = 2;
r.ip = "203.0.113.7";
r.clientOs = 1;
r.memoryStats = " 12345 p, 67890 vbytes.40 n-use." + std::to_string(memoryKb) + " TKb-pmem.";
r.memoryTotalKb = memoryKb;
r.videoCard = card;
r.numberOfProcessors = cpus;
r.processorType = 586;
r.processorLevel = 6;
r.processorRevision = 0x9e0a;
r.osVersionInfoSize = 276;
r.majorVersion = build == 2600 ? 5 : 6;
r.minorVersion = build == 2600 ? 1 : 2;
r.buildNumber = build;
r.platformId = 2;
return r;
}
}
// What GET /api/accounts/:id/client_sysinfo sends for each row
TEST(ClientSysInfoViewTests, RowKeepsTheRawValues) {
const auto row = Row(7, 9200, "NVIDIA GeForce GTX 1080 (HAL-hw vp)", 16, 16717048);
const auto json = ClientSysInfoView::RowJson(row, true);
EXPECT_EQ(json["memory_stats"], row.memoryStats);
EXPECT_EQ(json["video_card"], row.videoCard);
EXPECT_EQ(json["processor_type"], 586);
EXPECT_EQ(json["processor_revision"], 0x9e0a);
EXPECT_EQ(json["processor_model"], 0x9e);
EXPECT_EQ(json["processor_stepping"], 0x0a);
EXPECT_EQ(json["os_version"], "6.2.9200");
EXPECT_EQ(json["os_version_info_size"], 276);
EXPECT_EQ(json["memory"]["total_phys_kb"], 16717048);
EXPECT_EQ(json["memory"]["memory_load_percent"], 40);
EXPECT_TRUE(json["memory"]["avail_phys_kb"].is_null()); // not in the text
EXPECT_EQ(json["memory_bucket"], "8-16 GB");
EXPECT_EQ(json["ip"], "203.0.113.7");
}
TEST(ClientSysInfoViewTests, AddressOnlyWithLogsAudit) {
const auto json = ClientSysInfoView::RowJson(Row(7, 9200, "Card", 4, 0), false);
EXPECT_FALSE(json.contains("ip"));
EXPECT_EQ(json["memory_bucket"], "Not read");
}
TEST(ClientSysInfoViewTests, EveryShownFieldHasACaveat) {
const auto& caveats = ClientSysInfoView::Caveats();
for (const auto* key : { "ip", "clientOs", "memoryStats", "videoCard", "numberOfProcessors", "processorType", "processorLevel",
"processorRevision", "osVersionInfoSize", "osVersion", "platformId" }) {
ASSERT_TRUE(caveats.contains(key)) << key;
EXPECT_FALSE(caveats[key].get<std::string>().empty()) << key;
}
// The Windows version caveat says why it's not the real version
EXPECT_NE(caveats["osVersion"].get<std::string>().find("6.2 build 9200"), std::string::npos);
}
TEST(ClientSysInfoViewTests, OsLabelsSayWhatIsReported) {
EXPECT_NE(ClientSysInfoView::OsLabel(Row(1, 9200, "", 1, 0)).find("8.1, 10 and 11"), std::string::npos);
EXPECT_NE(ClientSysInfoView::OsLabel(Row(1, 2600, "", 1, 0)).find("compatibility mode"), std::string::npos);
}
TEST(ClientSysInfoViewTests, MemoryBuckets) {
constexpr uint64_t GB = 1024 * 1024;
EXPECT_EQ(ClientSysInfoView::MemoryBucket(1 * GB), "Under 2 GB");
EXPECT_EQ(ClientSysInfoView::MemoryBucket(4 * GB - 100000), "2-4 GB"); // a 4 GB machine reports a little under 4 GB
EXPECT_EQ(ClientSysInfoView::MemoryBucket(16 * GB), "8-16 GB");
EXPECT_EQ(ClientSysInfoView::MemoryBucket(33 * GB), "32-64 GB");
EXPECT_EQ(ClientSysInfoView::MemoryBucket(128 * GB), "Over 64 GB");
}
// What GET /api/client_sysinfo/spread sends
TEST(ClientSysInfoViewTests, SpreadCountsEachAccountOnce) {
constexpr uint64_t GB = 1024 * 1024;
const std::vector<IClientSysInfo::SysInfoRow> latest{
Row(1, 9200, "NVIDIA GeForce GTX 1080 (HAL-hw vp)", 16, 16 * GB),
Row(2, 9200, "NVIDIA GeForce GTX 1080 (HAL-mixed vp)", 8, 8 * GB),
Row(3, 2600, "AMD Radeon RX 580 (HAL-hw vp)", 16, 32 * GB),
};
const auto spread = ClientSysInfoView::Spread(latest);
EXPECT_EQ(spread["accounts"], 3);
// The same adapter with a different vertex processing mode is one card
ASSERT_EQ(spread["video"].size(), 2u);
EXPECT_EQ(spread["video"][0]["label"], "NVIDIA GeForce GTX 1080");
EXPECT_EQ(spread["video"][0]["count"], 2);
EXPECT_EQ(spread["os"][0]["count"], 2);
EXPECT_NE(spread["os"][0]["label"].get<std::string>().find("6.2.9200"), std::string::npos);
// Memory in size order, processors by count
ASSERT_EQ(spread["memory"].size(), 3u);
EXPECT_EQ(spread["memory"][0]["label"], "4-8 GB");
EXPECT_EQ(spread["memory"][2]["label"], "16-32 GB");
ASSERT_EQ(spread["processors"].size(), 2u);
EXPECT_EQ(spread["processors"][0]["label"], "8");
EXPECT_EQ(spread["processors"][1]["count"], 2);
EXPECT_EQ(spread["clientOs"][0]["label"], "Windows");
}
TEST(ClientSysInfoViewTests, SpreadFoldsTheTail) {
std::vector<IClientSysInfo::SysInfoRow> latest;
for (uint32_t i = 0; i < 5; i++) latest.push_back(Row(i, 9200, "Card " + std::to_string(i), 4, 0));
const auto spread = ClientSysInfoView::Spread(latest, 2);
ASSERT_EQ(spread["video"].size(), 3u);
EXPECT_EQ(spread["video"][2]["label"], "Other");
EXPECT_EQ(spread["video"][2]["count"], 3);
}
TEST(ClientSysInfoViewTests, StaffOnlyPermission) {
const auto* permission = Permissions::Find("client_sysinfo");
ASSERT_NE(permission, nullptr);
EXPECT_EQ(permission->category, "Accounts");
EXPECT_GE(permission->defaultLevel, 1);
EXPECT_FALSE(permission->locked); // can be granted per account
ASSERT_NE(Permissions::Find("logs_audit"), nullptr);
}