feat(dashboard): Network summary has each server's port and machine

The dashboard keeps master's server list endpoints and joins them into the
Network summary: its listening port and its machine as a token, since the
summary goes to every open page. The connection list maps the tokens to
addresses for viewers with network_ips. The dashboard reports its web port.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Aaron Kimbrell
2026-09-30 02:13:41 -05:00
parent a93f5f810b
commit b0819df5b6
6 changed files with 124 additions and 15 deletions

View File

@@ -94,6 +94,7 @@
#include "WorldView.h" #include "WorldView.h"
#include "PrometheusMetrics.h" #include "PrometheusMetrics.h"
#include "Traffic.h" #include "Traffic.h"
#include "TrafficHistory.h"
#include "master/ServerTraffic.h" #include "master/ServerTraffic.h"
#include "master/Profiling.h" #include "master/Profiling.h"
#include "Performance.h" #include "Performance.h"
@@ -135,6 +136,7 @@ namespace ServerState {
uint32_t g_UgcPid{}; uint32_t g_UgcPid{};
std::vector<WorldInstanceInfo> g_WorldInstances{}; std::vector<WorldInstanceInfo> g_WorldInstances{};
std::vector<WorldInstanceInfo> g_PendingWorlds{}; std::vector<WorldInstanceInfo> g_PendingWorlds{};
std::map<std::string, ServerEndpoint> g_Endpoints{};
std::mutex g_StatusMutex{}; std::mutex g_StatusMutex{};
} }
@@ -238,6 +240,12 @@ namespace {
} }
} }
ServerState::g_Endpoints.clear();
for (const auto& endpoint : list.endpoints) {
const auto key = TrafficHistory::KeyFor(static_cast<uint16_t>(endpoint.type), endpoint.zoneID, endpoint.instanceID);
ServerState::g_Endpoints[key] = { endpoint.ip.string, endpoint.port, endpoint.host.string };
}
LOG_DEBUG("Received server list: auth=%s chat=%s ugc=%s worlds=%u", LOG_DEBUG("Received server list: auth=%s chat=%s ugc=%s worlds=%u",
list.authOnline ? "online" : "offline", list.authOnline ? "online" : "offline",
list.chatOnline ? "online" : "offline", list.chatOnline ? "online" : "offline",
@@ -451,6 +459,8 @@ int main(int argc, char** argv) {
&Game::lastSignal, &Game::lastSignal,
masterPassword masterPassword
); );
// Master (and the Network page) show the port browsers use
g_Server->SetAdvertisedPort(ourPort);
Game::server = g_Server; Game::server = g_Server;
// What the worker threads read from the CDClient and the settings, read now on this thread: workers never query // What the worker threads read from the CDClient and the settings, read now on this thread: workers never query

View File

@@ -47,7 +47,7 @@ namespace NetworkView {
return out; return out;
} }
nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label) { nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label, const Endpoints& endpoints, uint64_t salt) {
nlohmann::json servers = nlohmann::json::object(); nlohmann::json servers = nlohmann::json::object();
for (const auto& [key, server] : history.Servers()) { for (const auto& [key, server] : history.Servers()) {
if (now - server.lastSeen > onlineSeconds || server.seconds.empty()) continue; if (now - server.lastSeen > onlineSeconds || server.seconds.empty()) continue;
@@ -62,6 +62,10 @@ namespace NetworkView {
entry["type"] = std::string(magic_enum::enum_name(static_cast<ServiceType>(server.type))); entry["type"] = std::string(magic_enum::enum_name(static_cast<ServiceType>(server.type)));
entry["zone"] = server.zoneId; entry["zone"] = server.zoneId;
entry["instance"] = server.instanceId; entry["instance"] = server.instanceId;
const auto endpoint = endpoints.find(key);
const bool known = endpoint != endpoints.end();
entry["port"] = known && endpoint->second.port ? nlohmann::json(endpoint->second.port) : nlohmann::json(nullptr);
entry["host"] = known && !endpoint->second.host.empty() ? nlohmann::json(MaskAddress(endpoint->second.host, salt)) : nlohmann::json(nullptr);
entry["http"] = Rate(p.httpRequests, WINDOW); entry["http"] = Rate(p.httpRequests, WINDOW);
entry["http_bytes_out"] = Rate(p.httpBytesOut, WINDOW); entry["http_bytes_out"] = Rate(p.httpBytesOut, WINDOW);
entry["link"] = { {"connections", server.link.connections}, {"ping_ms", server.link.averagePingMs}, {"resends", server.link.resends}, entry["link"] = { {"connections", server.link.connections}, {"ping_ms", server.link.averagePingMs}, {"resends", server.link.resends},
@@ -88,7 +92,8 @@ namespace NetworkView {
return { {"time", now}, {"window", WINDOW}, {"servers", servers} }; 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) { nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label,
const Endpoints& endpoints) {
struct Group { struct Group {
std::string address; std::string address;
std::string kind = "server"; std::string kind = "server";
@@ -173,6 +178,13 @@ namespace NetworkView {
} }
list.push_back(std::move(entry)); list.push_back(std::move(entry));
} }
return { {"time", now}, {"addresses_shown", showAddresses}, {"reported", anyReported}, {"peers", list}, {"others", others} }; // The machines the Summary names by token
nlohmann::json hosts = nlohmann::json::object();
if (showAddresses) {
for (const auto& [_, endpoint] : endpoints) {
if (!endpoint.host.empty()) hosts[MaskAddress(endpoint.host, salt)] = endpoint.host;
}
}
return { {"time", now}, {"addresses_shown", showAddresses}, {"reported", anyReported}, {"peers", list}, {"others", others}, {"hosts", hosts} };
} }
} }

View File

@@ -2,6 +2,7 @@
#include <cstdint> #include <cstdint>
#include <functional> #include <functional>
#include <map>
#include <string> #include <string>
#include "json.hpp" #include "json.hpp"
@@ -17,22 +18,34 @@ namespace NetworkView {
// A server's name for people ("World 1200 Nimbus Station #3") // A server's name for people ("World 1200 Nimbus Station #3")
using Labeler = std::function<std::string(const TrafficHistory::Server& server)>; using Labeler = std::function<std::string(const TrafficHistory::Server& server)>;
// Where a server listens and the machine it runs on, from master's server list
struct Endpoint {
uint32_t port{};
std::string host; // the machine's address as master sees it (empty: unknown)
};
// By TrafficHistory key ("master", "auth", "world:1200:3")
using Endpoints = std::map<std::string, Endpoint>;
/** /**
* Every server that reported in the last `onlineSeconds`, by key: its packets and bytes per second in and out over * 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 * 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 * 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. * don't have one (an older server). With `endpoints`, its listening `port` and its machine as `host`, a token
* (MaskAddress with `salt`; null when not known): the summary goes to every viewer, so it never holds addresses.
*/ */
nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label); nlohmann::json Summary(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, const Labeler& label,
const Endpoints& endpoints = {}, uint64_t salt = 0);
/** /**
* The remote ends of every server (from each one's last report): game clients by player (else by RakNet connection), * The remote ends of every server (from each one's last report): game clients by player (else by RakNet connection),
* HTTP clients of the dashboard and the UGC server by address and signed-in account (`user`, the dashboard account * HTTP clients of the dashboard and the UGC server by address and signed-in account (`user`, the dashboard account
* the requests were signed in as), and the servers' own links. Rates per second. Account and user names are always * the requests were signed in as), and the servers' own links. Rates per second. Account and user names are always
* included; without `showAddresses` each address is replaced by a token that stays the same for the same address * included; without `showAddresses` each address is replaced by a token that stays the same for the same address
* and `salt`, and ports are left out. * and `salt`, and ports are left out. With `showAddresses`, `hosts` maps each machine's token in the Summary to its
* address (empty otherwise).
*/ */
nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label); nlohmann::json Connections(const TrafficHistory& history, int64_t now, int64_t onlineSeconds, bool showAddresses, uint64_t salt, const Labeler& label,
const Endpoints& endpoints = {});
// "peer-" and 8 hex digits: the same for the same address and salt, and says nothing about the address // "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); std::string MaskAddress(const std::string& address, uint64_t salt);

View File

@@ -44,6 +44,13 @@ struct WorldInstanceInfo {
std::string state{}; std::string state{};
}; };
// Where a server listens and the machine it runs on, from master's server list
struct ServerEndpoint {
std::string ip{};
uint32_t port{0};
std::string host{};
};
namespace ServerState { namespace ServerState {
extern ServerStatus g_AuthStatus; extern ServerStatus g_AuthStatus;
extern ServerStatus g_ChatStatus; extern ServerStatus g_ChatStatus;
@@ -54,6 +61,8 @@ namespace ServerState {
extern std::vector<WorldInstanceInfo> g_WorldInstances; extern std::vector<WorldInstanceInfo> g_WorldInstances;
// Worlds master launched that aren't connected yet, or that are shutting down (kept apart: not running) // Worlds master launched that aren't connected yet, or that are shutting down (kept apart: not running)
extern std::vector<WorldInstanceInfo> g_PendingWorlds; extern std::vector<WorldInstanceInfo> g_PendingWorlds;
// Every server master listed, by traffic key ("master", "auth", "world:1200:3")
extern std::map<std::string, ServerEndpoint> g_Endpoints;
extern std::mutex g_StatusMutex; extern std::mutex g_StatusMutex;
inline nlohmann::json GetServerStateJson() { inline nlohmann::json GetServerStateJson() {

View File

@@ -278,11 +278,6 @@ namespace {
{"messages_since", since}, {"routes", routes} }; {"messages_since", since}, {"routes", routes} };
} }
// What open pages get every few seconds (the Network page draws it; Diagnostics reloads on it)
nlohmann::json Summary(int64_t now) {
return NetworkView::Summary(g_History, now, ONLINE_SECONDS, LabelOf);
}
uint64_t AddressSalt() { uint64_t AddressSalt() {
static const uint64_t salt = [] { static const uint64_t salt = [] {
std::random_device device; std::random_device device;
@@ -291,6 +286,19 @@ namespace {
return salt; return salt;
} }
// Each server's listening port and machine, from master's last server list
NetworkView::Endpoints Endpoints() {
NetworkView::Endpoints endpoints;
std::lock_guard lock(ServerState::g_StatusMutex);
for (const auto& [key, endpoint] : ServerState::g_Endpoints) endpoints[key] = { endpoint.port, endpoint.host };
return endpoints;
}
// What open pages get every few seconds (the Network page draws it; Diagnostics reloads on it)
nlohmann::json Summary(int64_t now) {
return NetworkView::Summary(g_History, now, ONLINE_SECONDS, LabelOf, Endpoints(), AddressSalt());
}
// One server for the Network page's detail panel: what ServerInfo says, its busiest message types each way over the // 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) // 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) { nlohmann::json NetworkServer(const std::string& key, int64_t now) {
@@ -448,7 +456,7 @@ namespace Traffic {
TrafficStats::Local().SetGauge("workers_threads", [] { return static_cast<double>(Workers::Pool().Threads()); }); TrafficStats::Local().SetGauge("workers_threads", [] { return static_cast<double>(Workers::Pool().Threads()); });
Route(eHTTPMethod::GET, "/api/diagnostics/network", Perm(PERMISSION), 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", "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, gauges, its listening port and its machine (host: a token; the connections route maps it to the address with network_ips)",
[](HTTPReply& reply, const HTTPContext&) { [](HTTPReply& reply, const HTTPContext&) {
JsonSuccess(reply, Summary(TrafficStats::Now())); JsonSuccess(reply, Summary(TrafficStats::Now()));
}); });
@@ -462,9 +470,9 @@ namespace Traffic {
}); });
Route(eHTTPMethod::GET, "/api/diagnostics/network/connections", Perm(PERMISSION), 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", "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 and remote ports only with network_ips; otherwise a token per address. hosts: each machine token of the summary to its address (network_ips only)",
[](HTTPReply& reply, const HTTPContext& context) { [](HTTPReply& reply, const HTTPContext& context) {
JsonSuccess(reply, NetworkView::Connections(g_History, TrafficStats::Now(), ONLINE_SECONDS, Can(context, IPS_PERMISSION), AddressSalt(), LabelOf)); JsonSuccess(reply, NetworkView::Connections(g_History, TrafficStats::Now(), ONLINE_SECONDS, Can(context, IPS_PERMISSION), AddressSalt(), LabelOf, Endpoints()));
}); });
Route(eHTTPMethod::GET, "/api/diagnostics/traffic", Perm(PERMISSION), Route(eHTTPMethod::GET, "/api/diagnostics/traffic", Perm(PERMISSION),

View File

@@ -183,3 +183,60 @@ TEST(NetworkViewTest, WebClientsBySignedInAccountWithTheirUserName) {
EXPECT_EQ(hidden["peers"][1]["user"], "bob"); EXPECT_EQ(hidden["peers"][1]["user"], "bob");
for (const auto& p : hidden["peers"]) EXPECT_EQ(p["address"], NetworkView::MaskAddress("198.51.100.7", 5)); for (const auto& p : hidden["peers"]) EXPECT_EQ(p["address"], NetworkView::MaskAddress("198.51.100.7", 5));
} }
TEST(NetworkViewTest, ListeningPortsAndMachinesFromTheServerList) {
// Auth and a world on master's machine, a second world on another; chat is reporting but not in the list yet
TrafficHistory history;
history.Ingest(static_cast<uint16_t>(ServiceType::AUTH), 0, 0, WorldReport(true), NOW);
history.Ingest(static_cast<uint16_t>(ServiceType::WORLD), 1200, 3, WorldReport(true), NOW);
history.Ingest(static_cast<uint16_t>(ServiceType::WORLD), 1200, 4, WorldReport(true), NOW);
history.Ingest(static_cast<uint16_t>(ServiceType::CHAT), 0, 0, WorldReport(true), NOW);
const NetworkView::Endpoints endpoints{ { "auth", { 1001, "192.0.2.1" } }, { "world:1200:3", { 3015, "192.0.2.1" } },
{ "world:1200:4", { 3016, "198.51.100.20" } }, { "master", { 2000, "192.0.2.1" } } };
const auto json = NetworkView::Summary(history, NOW, 20, Label, endpoints, 77);
const auto& servers = json["servers"];
EXPECT_EQ(servers["auth"]["port"], 1001);
EXPECT_EQ(servers["world:1200:3"]["port"], 3015);
EXPECT_EQ(servers["world:1200:4"]["port"], 3016);
// The machine is a token, the same for servers on the same machine
EXPECT_EQ(servers["auth"]["host"], NetworkView::MaskAddress("192.0.2.1", 77));
EXPECT_EQ(servers["auth"]["host"], servers["world:1200:3"]["host"]);
EXPECT_EQ(servers["world:1200:4"]["host"], NetworkView::MaskAddress("198.51.100.20", 77));
EXPECT_NE(servers["auth"]["host"], servers["world:1200:4"]["host"]);
// Not in the list: no port or machine
EXPECT_TRUE(servers["chat"]["port"].is_null());
EXPECT_TRUE(servers["chat"]["host"].is_null());
// It goes to everyone: no addresses
const auto dump = json.dump();
EXPECT_EQ(dump.find("192.0.2.1"), std::string::npos);
EXPECT_EQ(dump.find("198.51.100.20"), std::string::npos);
// The connection list names the machines only with network_ips
const auto shown = NetworkView::Connections(history, NOW, 20, true, 77, Label, endpoints);
ASSERT_EQ(shown["hosts"].size(), 2u);
EXPECT_EQ(shown["hosts"][NetworkView::MaskAddress("192.0.2.1", 77)], "192.0.2.1");
EXPECT_EQ(shown["hosts"][NetworkView::MaskAddress("198.51.100.20", 77)], "198.51.100.20");
const auto hidden = NetworkView::Connections(history, NOW, 20, false, 77, Label, endpoints);
EXPECT_TRUE(hidden["hosts"].empty());
EXPECT_EQ(hidden.dump().find("192.0.2.1"), std::string::npos);
}
TEST(NetworkViewTest, RemotePortsOnlyWithAddresses) {
// A player's and a server link's remote port: shown with network_ips, left out without
TrafficHistory history;
auto world = WorldReport(true);
world.connections = { Connection{ .address = "203.0.113.9", .port = 51234, .peer = Peer::CLIENTS, .bytesIn = 10, .accountId = 3, .account = "carol" },
Connection{ .address = "192.0.2.1", .port = 2000, .peer = Peer::MASTER, .bytesIn = 10 } };
world.hasConnections = true;
history.Ingest(static_cast<uint16_t>(ServiceType::WORLD), 1200, 3, world, NOW);
for (const bool show : { true, false }) {
const auto json = NetworkView::Connections(history, NOW, 20, show, 1, Label);
ASSERT_EQ(json["peers"].size(), 2u);
for (const auto& p : json["peers"]) {
const auto& port = p["servers"][0]["port"];
if (!show) EXPECT_TRUE(port.is_null());
else EXPECT_EQ(port, p["kind"] == "game" ? 51234 : 2000);
}
}
}