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