From c44af311adbe92014c1e5966c4622e629affe31f Mon Sep 17 00:00:00 2001 From: Aaron Kimbrell Date: Tue, 29 Sep 2026 19:07:46 -0500 Subject: [PATCH] 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 --- dDashboardServer/DashboardServer.cpp | 2 + dDashboardServer/routes/CMakeLists.txt | 2 + .../routes/ClientSysInfoRoutes.cpp | 40 +++++ dDashboardServer/routes/ClientSysInfoRoutes.h | 10 ++ dDashboardServer/routes/ClientSysInfoView.cpp | 151 ++++++++++++++++++ dDashboardServer/routes/ClientSysInfoView.h | 37 +++++ dDashboardServer/static/js/client-sysinfo.js | 88 ++++++++++ .../templates/account-view.jinja2 | 13 ++ .../templates/client_sysinfo.jinja2 | 68 ++++++++ dDashboardServer/templates/header.jinja2 | 5 +- tests/dWebTests/CMakeLists.txt | 2 + tests/dWebTests/ClientSysInfoViewTests.cpp | 124 ++++++++++++++ 12 files changed, 540 insertions(+), 2 deletions(-) create mode 100644 dDashboardServer/routes/ClientSysInfoRoutes.cpp create mode 100644 dDashboardServer/routes/ClientSysInfoRoutes.h create mode 100644 dDashboardServer/routes/ClientSysInfoView.cpp create mode 100644 dDashboardServer/routes/ClientSysInfoView.h create mode 100644 dDashboardServer/static/js/client-sysinfo.js create mode 100644 dDashboardServer/templates/client_sysinfo.jinja2 create mode 100644 tests/dWebTests/ClientSysInfoViewTests.cpp diff --git a/dDashboardServer/DashboardServer.cpp b/dDashboardServer/DashboardServer.cpp index 130a3fda4..ce25fc403 100644 --- a/dDashboardServer/DashboardServer.cpp +++ b/dDashboardServer/DashboardServer.cpp @@ -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(); diff --git a/dDashboardServer/routes/CMakeLists.txt b/dDashboardServer/routes/CMakeLists.txt index 1b6a64bd0..6116a62bc 100644 --- a/dDashboardServer/routes/CMakeLists.txt +++ b/dDashboardServer/routes/CMakeLists.txt @@ -40,6 +40,8 @@ set(DASHBOARDROUTES_SOURCES "ChatRoutes.cpp" "ChatHistory.cpp" "ChatFlagRoutes.cpp" + "ClientSysInfoRoutes.cpp" + "ClientSysInfoView.cpp" "Strikes.cpp" "Inspector.cpp" "CDClientBrowser.cpp" diff --git a/dDashboardServer/routes/ClientSysInfoRoutes.cpp b/dDashboardServer/routes/ClientSysInfoRoutes.cpp new file mode 100644 index 000000000..daf66f1ff --- /dev/null +++ b/dDashboardServer/routes/ClientSysInfoRoutes.cpp @@ -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(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()} }); + }); +} diff --git a/dDashboardServer/routes/ClientSysInfoRoutes.h b/dDashboardServer/routes/ClientSysInfoRoutes.h new file mode 100644 index 000000000..46c822406 --- /dev/null +++ b/dDashboardServer/routes/ClientSysInfoRoutes.h @@ -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(); +} diff --git a/dDashboardServer/routes/ClientSysInfoView.cpp b/dDashboardServer/routes/ClientSysInfoView.cpp new file mode 100644 index 000000000..34a37a1cc --- /dev/null +++ b/dDashboardServer/routes/ClientSysInfoView.cpp @@ -0,0 +1,151 @@ +#include "ClientSysInfoView.h" + +#include +#include + +#include "ClientSysInfo.h" + +namespace { + nlohmann::json Optional(const std::optional& 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& counts, size_t top) { + std::vector> 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-)" 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 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& latest, const size_t top) { + std::map 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& counts, const std::vector& 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 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)}, + }; +} diff --git a/dDashboardServer/routes/ClientSysInfoView.h b/dDashboardServer/routes/ClientSysInfoView.h new file mode 100644 index 000000000..244b38a2e --- /dev/null +++ b/dDashboardServer/routes/ClientSysInfoView.h @@ -0,0 +1,37 @@ +#pragma once + +#include +#include +#include + +#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& latest, size_t top = 20); +} diff --git a/dDashboardServer/static/js/client-sysinfo.js b/dDashboardServer/static/js/client-sysinfo.js new file mode 100644 index 000000000..2b1ee6ff1 --- /dev/null +++ b/dDashboardServer/static/js/client-sysinfo.js @@ -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] ? '
' + esc(caveats[key]) + '
' : ''; } + + // 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', '' + esc(r.memory_stats) + '' + (m.complete ? '' : ' not fully read'), 'memoryStats'], + ['Physical memory: total / free', kb(m.total_phys_kb) + ' / ' + kb(m.avail_phys_kb) + (m.memory_load_percent === null ? '' : ' (' + esc(m.memory_load_percent) + '% in use)'), ''], + ['Commit limit (pfile): total / free', kb(m.total_pagefile_kb) + ' / ' + kb(m.avail_pagefile_kb), ''], + ['Client address space (vmem): total / free', kb(m.total_virtual_kb) + ' / ' + kb(m.avail_virtual_kb), ''], + ['Client process: working set / private (peak)', bytes(m.working_set_bytes) + ' / ' + bytes(m.pagefile_usage_bytes) + ' (' + bytes(m.peak_working_set_bytes) + ' / ' + bytes(m.peak_pagefile_usage_bytes) + ')', ''], + ['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) + ' ' + esc(r.os_label) + '', '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 ? '' + esc(r.ip) + '' : 'not kept', '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 = '

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

'; + return; + } + list.innerHTML = d.rows.map(function (r, i) { + var summary = '' + esc(fmt.unix(r.last_seen)) + '' + + (r.first_seen !== r.last_seen ? ' since ' + esc(fmt.unix(r.first_seen)) + '' : '') + + ' · ' + esc(r.logins) + ' login' + (r.logins === 1 ? '' : 's') + + ' · reports Windows ' + esc(r.os_version) + ' · ' + esc(r.video_card || 'no video card') + ' · ' + esc(r.number_of_processors) + ' CPUs · ' + kb(r.memory_total_kb) + + (d.showsIp && r.ip ? ' · ' + esc(r.ip) + '' : ''); + var rows = fields(r, d.showsIp).map(function (f) { + return '' + esc(f[0]) + '' + f[1] + caveat(d.caveats, f[2]) + ''; + }).join(''); + return '
' + summary + '' + + '
' + rows + '
'; + }).join(''); + }).catch(function () {}); + } + + 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 '' + esc(e.label) + '' + esc(e.count) + '' + + '
' + + '
' + pct + '%'; + }).join('') || 'Nothing reported yet.'; + el.querySelector('tbody').innerHTML = html; + var note = el.querySelector('[data-caveat]'); + if (note) note.textContent = d.caveats[pair[1]] || ''; + }); + }).catch(function () {}); + } + + var card = document.getElementById('sysinfoCard'); + if (card) accountCard(card); + if (document.getElementById('sysinfoPage')) spreadPage(); +})(); diff --git a/dDashboardServer/templates/account-view.jinja2 b/dDashboardServer/templates/account-view.jinja2 index 03e71c105..e4ed6be76 100644 --- a/dDashboardServer/templates/account-view.jinja2 +++ b/dDashboardServer/templates/account-view.jinja2 @@ -157,6 +157,18 @@ {% endif %} +{% if can.client_sysinfo %} +
+
Client system info (as reported)
+
+

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

+

Loading…

+
+
+{% endif %} + {% if can.accounts_notes %}
Moderation history
@@ -701,6 +713,7 @@ function disconnectMailAccount() { + +{% endblock %} diff --git a/dDashboardServer/templates/header.jinja2 b/dDashboardServer/templates/header.jinja2 index 7132069c8..9215c921e 100644 --- a/dDashboardServer/templates/header.jinja2 +++ b/dDashboardServer/templates/header.jinja2 @@ -105,8 +105,8 @@
{% 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 %} diff --git a/tests/dWebTests/CMakeLists.txt b/tests/dWebTests/CMakeLists.txt index da50220b9..e169fbd94 100644 --- a/tests/dWebTests/CMakeLists.txt +++ b/tests/dWebTests/CMakeLists.txt @@ -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" diff --git a/tests/dWebTests/ClientSysInfoViewTests.cpp b/tests/dWebTests/ClientSysInfoViewTests.cpp new file mode 100644 index 000000000..8988286d8 --- /dev/null +++ b/tests/dWebTests/ClientSysInfoViewTests.cpp @@ -0,0 +1,124 @@ +#include + +#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().empty()) << key; + } + // The Windows version caveat says why it's not the real version + EXPECT_NE(caveats["osVersion"].get().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 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().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 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); +}