feat(dashboard): network summary, server detail and connection routes

The traffic topic now carries each server's rates with its split by
peer (null for servers that report none), link statistics and gauges.
New routes: /api/diagnostics/network, /network/server (message types
and a 10 minute series) and /network/connections (remote ends grouped
by address). Addresses need the new network_ips permission; without it
each is a salted token. They stay in memory from the last report only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-29 18:23:42 -05:00
parent 3b778065cf
commit 7e87f2a482
9 changed files with 466 additions and 10 deletions

View File

@@ -89,6 +89,7 @@ namespace {
{ "logs_audit", "Logs", "Audit log", "What staff did on the dashboard", 8 },
{ "logs_system", "Logs", "Server logs", "Server log files, log search and crash dumps", 8 },
{ "health_view", "Logs", "Server health", "Player counts, running worlds, uptime and memory over time", 8 },
{ "network_ips", "Logs", "Network addresses", "See the IP addresses of game and web clients on the Network page (without it they are shown as tokens)", 9 },
{ "metrics_view", "Logs", "Prometheus metrics", "Read /metrics (and /api/metrics) with an API token, when metrics_enabled is on", 8 },
{ "reports_view", "Economy and map", "Economy reports", "Coins, U-score, items, trades and mail, item traces, duplicate scans, flags and the world map", 3 },

View File

@@ -34,6 +34,7 @@ set(DASHBOARDROUTES_SOURCES
"LogBundle.cpp"
"Traffic.cpp"
"TrafficHistory.cpp"
"NetworkView.cpp"
"LeaderboardRoutes.cpp"
"VanityRoutes.cpp"
"ChatRoutes.cpp"

View File

@@ -0,0 +1,168 @@
#include "NetworkView.h"
#include <algorithm>
#include <cmath>
#include <cstdio>
#include <map>
#include "ServiceType.h"
#include "magic_enum.hpp"
namespace {
double Rate(uint64_t value, int64_t seconds) {
return std::round(static_cast<double>(value) / static_cast<double>(std::max<int64_t>(seconds, 1)) * 100.0) / 100.0;
}
nlohmann::json Rates(uint64_t packetsIn, uint64_t packetsOut, uint64_t bytesIn, uint64_t bytesOut, int64_t seconds) {
return { {"packets_in", Rate(packetsIn, seconds)}, {"packets_out", Rate(packetsOut, seconds)},
{"bytes_in", Rate(bytesIn, seconds)}, {"bytes_out", Rate(bytesOut, seconds)} };
}
nlohmann::json Rates(const TrafficStats::PeerCounts& c, int64_t seconds) {
return Rates(c.packetsIn, c.packetsOut, c.bytesIn, c.bytesOut, seconds);
}
nlohmann::json Rates(const TrafficStats::Connection& c, int64_t seconds) {
return Rates(c.packetsIn, c.packetsOut, c.bytesIn, c.bytesOut, seconds);
}
}
namespace NetworkView {
const char* PeerName(TrafficStats::Peer peer) {
switch (peer) {
case TrafficStats::Peer::MASTER: return "master";
case TrafficStats::Peer::SERVERS: return "servers";
default: return "clients";
}
}
std::string MaskAddress(const std::string& address, uint64_t salt) {
// FNV-1a over the salt's bytes, then the address
uint64_t hash = 14695981039346656037ull;
const auto mix = [&hash](uint8_t byte) { hash = (hash ^ byte) * 1099511628211ull; };
for (int i = 0; i < 8; i++) mix(static_cast<uint8_t>(salt >> (8 * i)));
for (const char c : address) mix(static_cast<uint8_t>(c));
char out[16]{};
std::snprintf(out, sizeof(out), "peer-%08x", static_cast<uint32_t>(hash ^ (hash >> 32)));
return out;
}
nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label) {
nlohmann::json servers = nlohmann::json::object();
for (const auto& [key, server] : history.Servers()) {
if (now - server.lastSeen > onlineSeconds || server.seconds.empty()) continue;
const auto last = server.seconds.back().time;
const auto first = last - (WINDOW - 1);
TrafficHistory::Point p;
for (auto it = server.seconds.rbegin(); it != server.seconds.rend() && it->time >= first; ++it) p.Add(*it);
nlohmann::json entry = Rates(p.packetsIn, p.packetsOut, p.bytesIn, p.bytesOut, WINDOW);
entry["key"] = key;
entry["label"] = label ? label(server) : key;
entry["type"] = std::string(magic_enum::enum_name(static_cast<ServiceType>(server.type)));
entry["zone"] = server.zoneId;
entry["instance"] = server.instanceId;
entry["http"] = Rate(p.httpRequests, WINDOW);
entry["http_bytes_out"] = Rate(p.httpBytesOut, WINDOW);
entry["link"] = { {"connections", server.link.connections}, {"ping_ms", server.link.averagePingMs}, {"resends", server.link.resends},
{"resend_queue", server.link.resendQueue} };
nlohmann::json gauges = nlohmann::json::object();
for (const auto& [name, value] : server.gauges) gauges[name] = value;
entry["gauges"] = gauges;
TrafficHistory::Split split;
if (server.peerSplit && history.SplitOf(key, first, last + 1, split)) {
entry["split"] = {
{"clients", Rates(split.peers[static_cast<size_t>(TrafficStats::Peer::CLIENTS)], WINDOW)},
{"master", Rates(split.peers[static_cast<size_t>(TrafficStats::Peer::MASTER)], WINDOW)},
{"servers", Rates(split.peers[static_cast<size_t>(TrafficStats::Peer::SERVERS)], WINDOW)},
{"http_from_servers", Rate(split.httpFromServers, WINDOW)},
{"http_from_servers_bytes_out", Rate(split.httpFromServersBytesOut, WINDOW)},
{"http_out", Rate(split.httpOutRequests, WINDOW)},
{"http_out_bytes_in", Rate(split.httpOutBytesIn, WINDOW)} };
} else {
entry["split"] = nullptr;
}
servers[key] = std::move(entry);
}
return { {"time", now}, {"window", WINDOW}, {"servers", servers} };
}
nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label) {
struct Group {
std::string address;
std::string kind = "server";
std::string account, character;
uint32_t accountId{};
uint64_t characterId{};
uint64_t bytes{};
nlohmann::json servers = nlohmann::json::array();
TrafficStats::Connection total;
int64_t seconds = 1;
};
const auto rank = [](const std::string& kind) { return kind == "game" ? 2 : kind == "web" ? 1 : 0; };
std::map<std::string, Group> groups;
nlohmann::json others = nlohmann::json::array();
bool anyReported = false;
for (const auto& [key, server] : history.Servers()) {
if (now - server.lastSeen > onlineSeconds || !server.hasConnections) continue;
anyReported = true;
const auto name = label ? label(server) : key;
const auto seconds = std::max<int64_t>(server.connectionsSeconds, 1);
for (const auto& c : server.connections) {
auto& group = groups[c.address];
group.address = c.address;
group.seconds = std::max(group.seconds, seconds);
const std::string kind = c.peer != TrafficStats::Peer::CLIENTS ? "server" : c.http ? "web" : "game";
if (rank(kind) > rank(group.kind)) group.kind = kind;
if (c.accountId && !group.accountId) {
group.accountId = c.accountId;
group.account = c.account;
group.characterId = c.characterId;
group.character = c.character;
}
group.bytes += c.Bytes();
group.total.Merge(c);
auto entry = Rates(c, seconds);
entry["server"] = key;
entry["label"] = name;
entry["peer"] = PeerName(c.peer);
entry["http"] = c.http;
if (!c.http) {
entry["port"] = showAddresses ? nlohmann::json(c.port) : nlohmann::json(nullptr);
entry["ping_ms"] = c.pingMs;
entry["resends"] = c.resends;
}
group.servers.push_back(std::move(entry));
}
if (server.otherConnectionCount) {
auto entry = Rates(server.otherConnections, seconds);
entry["server"] = key;
entry["label"] = name;
entry["count"] = server.otherConnectionCount;
others.push_back(std::move(entry));
}
}
std::vector<Group*> sorted;
for (auto& [_, group] : groups) sorted.push_back(&group);
std::sort(sorted.begin(), sorted.end(), [](const Group* a, const Group* b) { return a->bytes != b->bytes ? a->bytes > b->bytes : a->address < b->address; });
nlohmann::json list = nlohmann::json::array();
for (const auto* group : sorted) {
nlohmann::json entry = Rates(group->total, group->seconds);
entry["address"] = showAddresses ? group->address : MaskAddress(group->address, salt);
entry["kind"] = group->kind;
entry["servers"] = group->servers;
if (group->accountId) {
entry["account_id"] = group->accountId;
entry["account"] = group->account;
if (group->characterId) {
entry["character_id"] = std::to_string(group->characterId); // 64-bit: a string for JavaScript
entry["character"] = group->character;
}
}
list.push_back(std::move(entry));
}
return { {"time", now}, {"addresses_shown", showAddresses}, {"reported", anyReported}, {"peers", list}, {"others", others} };
}
}

View File

@@ -0,0 +1,40 @@
#pragma once
#include <cstdint>
#include <functional>
#include <string>
#include "json.hpp"
#include "TrafficHistory.h"
/**
* What the Network page (and the `traffic` WebSocket topic) is sent, built from TrafficHistory. Pure (no database,
* network or clock), so it is unit tested; Traffic.cpp serves it.
*/
namespace NetworkView {
constexpr int64_t WINDOW = 5; // seconds the live rates are over (one report)
// A server's name for people ("World 1200 Nimbus Station #3")
using Labeler = std::function<std::string(const TrafficHistory::Server& server)>;
/**
* Every server that reported in the last `onlineSeconds`, by key: its packets and bytes per second in and out over
* its last WINDOW reported seconds, HTTP requests, RakNet link statistics, gauges, and the split of the packets by
* peer (clients, master, servers) with its HTTP requests from and to other servers, or split: null when its reports
* don't have one (an older server). Never holds addresses.
*/
nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label);
/**
* The remote ends of every server (from each one's last report) grouped by address: game clients by their RakNet
* connections, HTTP clients of the dashboard and the UGC server, and the servers' own links. Rates per second.
* Without `showAddresses` each address is replaced by a token that stays the same for the same address and `salt`.
*/
nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label);
// "peer-" and 8 hex digits: the same for the same address and salt, and says nothing about the address
std::string MaskAddress(const std::string& address, uint64_t salt);
// "clients", "master" or "servers"
const char* PeerName(TrafficStats::Peer peer);
}

View File

@@ -5,6 +5,7 @@
#include <cmath>
#include <map>
#include <mutex>
#include <random>
#include "Background.h"
#include "Database.h"
@@ -12,6 +13,7 @@
#include "Logger.h"
#include "MessageIdentifiers.h"
#include "MetricsFormat.h"
#include "NetworkView.h"
#include "Permissions.h"
#include "RouteUtils.h"
#include "ServerState.h"
@@ -37,6 +39,7 @@ using namespace RouteUtils;
namespace {
constexpr const char* PERMISSION = "health_view";
constexpr const char* TOPIC = "traffic";
constexpr const char* IPS_PERMISSION = "network_ips";
constexpr auto PUSH_INTERVAL = std::chrono::seconds(2);
constexpr auto WRITE_INTERVAL = std::chrono::seconds(15);
constexpr int64_t ONLINE_SECONDS = 20; // a server that reported this recently is shown as up
@@ -275,18 +278,46 @@ namespace {
{"messages_since", since}, {"routes", routes} };
}
// What open pages get every few seconds: each server's rates over its last five reported seconds
// What open pages get every few seconds (the Network page draws it; Diagnostics reloads on it)
nlohmann::json Summary(int64_t now) {
nlohmann::json servers = nlohmann::json::object();
for (const auto& [key, server] : g_History.Servers()) {
if (now - server.lastSeen > ONLINE_SECONDS || server.seconds.empty()) continue;
TrafficHistory::Point p;
const auto first = server.seconds.back().time - 4;
for (auto it = server.seconds.rbegin(); it != server.seconds.rend() && it->time >= first; ++it) p.Add(*it);
servers[key] = { {"packets_in", Rate(p.packetsIn, 5)}, {"packets_out", Rate(p.packetsOut, 5)}, {"bytes_in", Rate(p.bytesIn, 5)},
{"bytes_out", Rate(p.bytesOut, 5)}, {"http", Rate(p.httpRequests, 5)} };
return NetworkView::Summary(g_History, now, ONLINE_SECONDS, LabelOf);
}
uint64_t AddressSalt() {
static const uint64_t salt = [] {
std::random_device device;
return (static_cast<uint64_t>(device()) << 32) ^ device();
}();
return salt;
}
// One server for the Network page's detail panel: what ServerInfo says, its busiest message types each way over the
// last 5 minutes, and its packets and bytes per second over the last 10 minutes (5 second steps)
nlohmann::json NetworkServer(const std::string& key, int64_t now) {
auto info = ServerInfo(key, now);
nlohmann::json messages = nlohmann::json::array();
for (const auto& count : g_History.TopMessages(key, now - 300, 8)) {
const auto names = NamesOf(count.key);
messages.push_back({ {"direction", count.key.outbound ? "out" : "in"}, {"service", names.service}, {"packet", names.packet},
{"game_message", names.gameMessage}, {"count", count.count}, {"bytes", count.bytes} });
}
return { {"time", now}, {"servers", servers} };
info["messages"] = messages;
constexpr int64_t STEP = 5, SPAN = 600;
const int64_t to = now - (now % STEP), from = to - SPAN;
nlohmann::json times = nlohmann::json::array(), packetsIn = nlohmann::json::array(), packetsOut = nlohmann::json::array(),
bytesIn = nlohmann::json::array(), bytesOut = nlohmann::json::array();
const auto buckets = g_History.Buckets(from, to, STEP);
const auto it = buckets.find(key);
for (int64_t i = 0; i < SPAN / STEP; i++) {
times.push_back(from + i * STEP);
const auto* p = it != buckets.end() ? &it->second[static_cast<size_t>(i)] : nullptr;
packetsIn.push_back(p ? Rate(p->packetsIn, STEP) : 0.0);
packetsOut.push_back(p ? Rate(p->packetsOut, STEP) : 0.0);
bytesIn.push_back(p ? Rate(p->bytesIn, STEP) : 0.0);
bytesOut.push_back(p ? Rate(p->bytesOut, STEP) : 0.0);
}
info["series"] = { {"step", STEP}, {"times", times}, {"packets_in", packetsIn}, {"packets_out", packetsOut}, {"bytes_in", bytesIn}, {"bytes_out", bytesOut} };
return info;
}
void Write() {
@@ -401,6 +432,26 @@ namespace Traffic {
TrafficStats::Local().SetGauge("workers_queued", [] { return static_cast<double>(Workers::Pool().Queued()); });
TrafficStats::Local().SetGauge("workers_threads", [] { return static_cast<double>(Workers::Pool().Threads()); });
Route(eHTTPMethod::GET, "/api/diagnostics/network", Perm(PERMISSION),
"The Network page's live view: every reporting server's packets and bytes per second over its last report, its split by peer (clients, master, other servers; null for servers too old to report one), HTTP requests, RakNet link statistics and gauges",
[](HTTPReply& reply, const HTTPContext&) {
JsonSuccess(reply, Summary(TrafficStats::Now()));
});
Route(eHTTPMethod::GET, "/api/diagnostics/network/server", Perm(PERMISSION),
"One server for the Network page. Query: ?key=world:1200:3 (or master, auth, chat, dashboard, ugc). Its link statistics and gauges, busiest message types over 5 minutes, packets and bytes per second over 10 minutes",
[](HTTPReply& reply, const HTTPContext& context) {
const auto key = QueryValue(context.queryString, "key");
if (key.empty() || key.size() > 64) return JsonError(reply, eHTTPStatusCode::BAD_REQUEST, "key is required");
JsonSuccess(reply, NetworkServer(key, TrafficStats::Now()));
});
Route(eHTTPMethod::GET, "/api/diagnostics/network/connections", Perm(PERMISSION),
"Every server's remote ends from its last report, grouped by address (game clients' RakNet connections, HTTP clients, server links), with rates, ping and the logged-in player. Addresses only with network_ips; otherwise a token per address",
[](HTTPReply& reply, const HTTPContext& context) {
JsonSuccess(reply, NetworkView::Connections(g_History, TrafficStats::Now(), ONLINE_SECONDS, Can(context, IPS_PERMISSION), AddressSalt(), LabelOf));
});
Route(eHTTPMethod::GET, "/api/diagnostics/traffic", Perm(PERMISSION),
"Packets, bytes and HTTP requests per second of every server. Query: ?range=5m|1h|24h|7d. Series are per-second rates of each step, latency in ms",
[](HTTPReply& reply, const HTTPContext& context) {

View File

@@ -90,6 +90,38 @@ void TrafficHistory::Point::Add(const Point& other) {
latencySum += other.latencySum;
}
void TrafficHistory::Split::Add(const TrafficStats::Second& second) {
for (size_t i = 0; i < peers.size(); i++) peers[i].Merge(second.peers[i]);
httpRequests += second.httpRequests;
httpBytesOut += second.httpBytesOut;
httpFromServers += second.httpFromServers;
httpFromServersBytesOut += second.httpFromServersBytesOut;
httpOutRequests += second.httpOutRequests;
httpOutBytesIn += second.httpOutBytesIn;
}
void TrafficHistory::Split::Add(const Split& other) {
for (size_t i = 0; i < peers.size(); i++) peers[i].Merge(other.peers[i]);
httpRequests += other.httpRequests;
httpBytesOut += other.httpBytesOut;
httpFromServers += other.httpFromServers;
httpFromServersBytesOut += other.httpFromServersBytesOut;
httpOutRequests += other.httpOutRequests;
httpOutBytesIn += other.httpOutBytesIn;
}
bool TrafficHistory::SplitOf(const std::string& serverKey, int64_t from, int64_t to, Split& out) const {
const auto it = m_Servers.find(serverKey);
if (it == m_Servers.end()) return false;
bool any = false;
for (const auto& split : it->second.splits) {
if (split.time < from || split.time >= to) continue;
out.Add(split);
any = true;
}
return any;
}
TrafficStats::Histogram TrafficHistory::Point::Latency() const {
return TrafficStats::Histogram::FromSparse(latency, latencySum);
}
@@ -122,6 +154,12 @@ void TrafficHistory::Ingest(uint16_t serviceType, uint32_t zoneId, uint32_t inst
server.lastSeen = now;
server.link = report.link;
server.gauges = report.gauges;
server.peerSplit = report.peerSplit;
server.hasConnections = report.hasConnections;
server.connections = report.connections;
server.otherConnections = report.otherConnections;
server.otherConnectionCount = report.otherConnectionCount;
server.connectionsSeconds = std::max<int64_t>(1, static_cast<int64_t>(report.seconds.size()));
auto& totals = server.totals;
int64_t lastSecond = now;
@@ -134,6 +172,13 @@ void TrafficHistory::Ingest(uint16_t serviceType, uint32_t zoneId, uint32_t inst
totals.bytesIn += second.bytesIn;
totals.bytesOut += second.bytesOut;
if (report.peerSplit) {
auto& splits = server.splits;
auto at = std::lower_bound(splits.begin(), splits.end(), second.time, [](const Split& x, int64_t t) { return x.time < t; });
if (at == splits.end() || at->time != second.time) at = splits.insert(at, Split{ .time = second.time });
at->Add(second);
}
if (server.seconds.empty() || server.seconds.back().time < second.time) {
server.seconds.push_back(Point{ .time = second.time });
server.seconds.back().Add(second);
@@ -154,6 +199,7 @@ void TrafficHistory::Ingest(uint16_t serviceType, uint32_t zoneId, uint32_t inst
}
}
while (!server.seconds.empty() && server.seconds.front().time < now - SECONDS_KEPT) server.seconds.pop_front();
while (!server.splits.empty() && server.splits.front().time < now - SPLIT_SECONDS) server.splits.pop_front();
const auto& link = report.link;
totals.datagramsSent += link.datagramsSent;
@@ -198,6 +244,10 @@ void TrafficHistory::Forget(int64_t now) {
for (auto it = m_Servers.begin(); it != m_Servers.end();) {
auto& seconds = it->second.seconds;
while (!seconds.empty() && seconds.front().time < now - SECONDS_KEPT) seconds.pop_front();
auto& splits = it->second.splits;
while (!splits.empty() && splits.front().time < now - SPLIT_SECONDS) splits.pop_front();
// Addresses are only kept while the server reports
if (it->second.lastSeen < now - 60) it->second.connections.clear();
if (it->second.lastSeen < now - FORGET_AFTER && it->second.minutes.empty()) it = m_Servers.erase(it);
else ++it;
}

View File

@@ -26,6 +26,7 @@ public:
static constexpr size_t MAX_TOTAL_MESSAGES = 256; // message types a server's totals keep
static constexpr size_t MAX_TOTAL_ROUTES = 128; // routes a server's totals keep
static constexpr size_t ROUTE_MINUTES = 60; // per-minute route stats kept
static constexpr int64_t SPLIT_SECONDS = 300; // per-second splits kept (memory only)
// One second (or a bucket of them) of one server
struct Point {
@@ -47,6 +48,18 @@ public:
using MessageCounts = std::map<uint64_t, TrafficStats::MessageCount>; // by MessageKey::Packed
// One second's packets split by peer, and its HTTP requests from and to other servers (the network page's links)
struct Split {
int64_t time{};
std::array<TrafficStats::PeerCounts, TrafficStats::PEER_CLASSES> peers{};
uint64_t httpRequests{}, httpBytesOut{}; // all HTTP requests answered
uint64_t httpFromServers{}, httpFromServersBytesOut{}; // of those, from other servers
uint64_t httpOutRequests{}, httpOutBytesIn{}; // made to other servers
void Add(const TrafficStats::Second& second);
void Add(const Split& other);
};
struct Totals {
uint64_t packetsIn{}, packetsOut{}, bytesIn{}, bytesOut{};
uint64_t datagramsSent{}, datagramsReceived{}, linkBytesSent{}, linkBytesReceived{}, resends{};
@@ -70,6 +83,14 @@ public:
std::deque<std::pair<int64_t, MessageCounts>> messageMinutes;
std::deque<std::pair<int64_t, MessageCounts>> messageHours;
std::deque<std::pair<int64_t, std::map<std::string, TrafficStats::RouteStats>>> routeMinutes;
bool peerSplit{}; // whether the last report had the split (older servers' don't)
std::deque<Split> splits; // oldest first, up to SPLIT_SECONDS
// The last report's remote ends (memory only, never written: they hold addresses)
bool hasConnections{};
std::vector<TrafficStats::Connection> connections;
TrafficStats::Connection otherConnections;
uint32_t otherConnectionCount{};
int64_t connectionsSeconds{}; // the seconds the last report covered, to make rates of them
};
// "master", "auth", "chat", "dashboard", "ugc", "world:<zone>:<instance>"
@@ -94,6 +115,9 @@ public:
// The busiest message types since `since` (minute counts for the last hour, hour counts before), one server or all ("")
std::vector<TrafficStats::MessageCount> TopMessages(const std::string& server, int64_t since, size_t limit) const;
// A server's seconds from `from` (inclusive) to `to` (exclusive) summed; whether any split was reported in them
bool SplitOf(const std::string& server, int64_t from, int64_t to, Split& out) const;
// The HTTP routes since `since` (the last hour at most) of every server with a web server, as (server, route); busiest first
std::vector<std::pair<std::string, TrafficStats::RouteStats>> Routes(int64_t since) const;

View File

@@ -53,6 +53,8 @@ set(DWEBTESTS_SOURCES
"MetricsFormatTests.cpp"
"TrafficHistoryTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/TrafficHistory.cpp"
"NetworkViewTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/NetworkView.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/routes/SettingsCatalog.cpp"
"AlertsAndTwoFactorTests.cpp"
"${PROJECT_SOURCE_DIR}/dDashboardServer/auth/Totp.cpp"

View File

@@ -0,0 +1,119 @@
#include <gtest/gtest.h>
#include "NetworkView.h"
#include "ServiceType.h"
using namespace TrafficStats;
namespace {
constexpr int64_t NOW = 1700000100;
// Five seconds of a world: 10 packets in and 20 out a second, split between its players and master
Report WorldReport(bool split) {
Report report;
for (int64_t t = NOW - 5; t < NOW; t++) {
Second s{ .time = t, .packetsIn = 10, .packetsOut = 20, .bytesIn = 1000, .bytesOut = 4000 };
s.peers[static_cast<size_t>(Peer::CLIENTS)] = { 8, 18, 800, 3800 };
s.peers[static_cast<size_t>(Peer::MASTER)] = { 1, 1, 100, 100 };
s.peers[static_cast<size_t>(Peer::SERVERS)] = { 1, 1, 100, 100 };
report.seconds.push_back(s);
}
report.peerSplit = split;
report.link = { .connections = 3, .resends = 2, .averagePingMs = 40 };
return report;
}
std::string Label(const TrafficHistory::Server& server) { return "label of " + server.key; }
}
TEST(NetworkViewTest, SummaryHasRatesAndTheSplit) {
TrafficHistory history;
history.Ingest(static_cast<uint16_t>(ServiceType::WORLD), 1200, 3, WorldReport(true), NOW);
const auto json = NetworkView::Summary(history, NOW, 20, Label);
ASSERT_TRUE(json["servers"].contains("world:1200:3"));
const auto& world = json["servers"]["world:1200:3"];
EXPECT_EQ(world["label"], "label of world:1200:3");
EXPECT_EQ(world["type"], "WORLD");
EXPECT_EQ(world["zone"], 1200);
EXPECT_DOUBLE_EQ(world["packets_in"].get<double>(), 10.0);
EXPECT_DOUBLE_EQ(world["bytes_out"].get<double>(), 4000.0);
EXPECT_EQ(world["link"]["connections"], 3);
EXPECT_EQ(world["link"]["ping_ms"], 40);
ASSERT_TRUE(world["split"].is_object());
EXPECT_DOUBLE_EQ(world["split"]["clients"]["packets_out"].get<double>(), 18.0);
EXPECT_DOUBLE_EQ(world["split"]["master"]["bytes_in"].get<double>(), 100.0);
EXPECT_DOUBLE_EQ(world["split"]["servers"]["packets_in"].get<double>(), 1.0);
EXPECT_EQ(json["window"], NetworkView::WINDOW);
}
TEST(NetworkViewTest, OlderServersHaveNoSplit) {
TrafficHistory history;
history.Ingest(static_cast<uint16_t>(ServiceType::AUTH), 0, 0, WorldReport(false), NOW);
const auto json = NetworkView::Summary(history, NOW, 20, Label);
ASSERT_TRUE(json["servers"].contains("auth"));
EXPECT_TRUE(json["servers"]["auth"]["split"].is_null());
EXPECT_DOUBLE_EQ(json["servers"]["auth"]["packets_in"].get<double>(), 10.0);
// Silent servers are left out
EXPECT_TRUE(NetworkView::Summary(history, NOW + 60, 20, Label)["servers"].empty());
}
TEST(NetworkViewTest, HttpSplitOfTheUgcServer) {
// The UGC server: all its requests came from the dashboard (the wire format is tested in ServerTrafficTests)
auto report = WorldReport(true);
report.seconds[0].httpRequests = 5;
report.seconds[0].httpFromServers = 5;
TrafficHistory history;
history.Ingest(static_cast<uint16_t>(ServiceType::UGC), 0, 0, report, NOW);
const auto json = NetworkView::Summary(history, NOW, 20, nullptr);
const auto& ugc = json["servers"]["ugc"];
EXPECT_EQ(ugc["label"], "ugc");
EXPECT_DOUBLE_EQ(ugc["http"].get<double>(), 1.0);
EXPECT_DOUBLE_EQ(ugc["split"]["http_from_servers"].get<double>(), 1.0);
}
TEST(NetworkViewTest, ConnectionsGroupByAddressAndHideIt) {
TrafficHistory history;
auto world = WorldReport(true);
Connection player{ .address = "203.0.113.5", .port = 50000, .peer = Peer::CLIENTS, .packetsIn = 50, .packetsOut = 100, .bytesIn = 5000, .bytesOut = 10000,
.resends = 1, .pingMs = 45, .accountId = 7, .characterId = 1152921504606846976ull, .account = "alice", .character = "Alice" };
Connection master{ .address = "127.0.0.1", .port = 2000, .peer = Peer::MASTER, .bytesIn = 10, .bytesOut = 10 };
world.connections = { player, master };
world.otherConnections = Connection{ .bytesIn = 500 };
world.otherConnectionCount = 4;
world.hasConnections = true;
history.Ingest(static_cast<uint16_t>(ServiceType::WORLD), 1200, 3, world, NOW);
Report ugc;
ugc.seconds = { Second{ .time = NOW - 1 } };
ugc.connections = { Connection{ .address = "203.0.113.5", .peer = Peer::CLIENTS, .http = true, .packetsIn = 2, .packetsOut = 2, .bytesIn = 300, .bytesOut = 90000 } };
ugc.hasConnections = true;
history.Ingest(static_cast<uint16_t>(ServiceType::UGC), 0, 0, ugc, NOW);
const auto shown = NetworkView::Connections(history, NOW, 20, true, 1234, Label);
EXPECT_TRUE(shown["addresses_shown"].get<bool>());
ASSERT_EQ(shown["peers"].size(), 2u);
const auto& first = shown["peers"][0]; // the busiest: the player on the world and the UGC server
EXPECT_EQ(first["address"], "203.0.113.5");
EXPECT_EQ(first["kind"], "game");
EXPECT_EQ(first["account"], "alice");
EXPECT_EQ(first["character"], "Alice");
EXPECT_EQ(first["character_id"], "1152921504606846976");
ASSERT_EQ(first["servers"].size(), 2u);
EXPECT_EQ(shown["peers"][1]["kind"], "server");
ASSERT_EQ(shown["others"].size(), 1u);
EXPECT_EQ(shown["others"][0]["count"], 4);
// Without network_ips: a token instead of the address, the same for the same address, no ports
const auto hidden = NetworkView::Connections(history, NOW, 20, false, 1234, Label);
const auto dump = hidden.dump();
EXPECT_EQ(dump.find("203.0.113.5"), std::string::npos);
EXPECT_EQ(dump.find("127.0.0.1"), std::string::npos);
EXPECT_EQ(hidden["peers"][0]["address"], NetworkView::MaskAddress("203.0.113.5", 1234));
for (const auto& entry : hidden["peers"][0]["servers"]) EXPECT_TRUE(!entry.contains("port") || entry["port"].is_null());
EXPECT_NE(NetworkView::MaskAddress("203.0.113.5", 1234), NetworkView::MaskAddress("203.0.113.5", 99));
EXPECT_NE(NetworkView::MaskAddress("203.0.113.5", 1234), NetworkView::MaskAddress("203.0.113.6", 1234));
// The summary never carries addresses
EXPECT_EQ(NetworkView::Summary(history, NOW, 20, Label).dump().find("203.0.113.5"), std::string::npos);
}