diff --git a/.circleci/config.yml b/.circleci/config.yml index dfb76b5eff..b4dc4a88ec 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -74,6 +74,7 @@ jobs: ca-certificates clang cmake + curl git libbenchmark-dev libconfig-dev @@ -84,7 +85,11 @@ jobs: libvpx-dev llvm-dev ninja-build - pkg-config + nlohmann-json3-dev + pkg-config && + curl -L -o ftxui.deb https://github.com/ArthurSonzogni/FTXUI/releases/download/v6.1.9/ftxui-6.1.9-Linux.deb && + apt-get install -y ./ftxui.deb && + rm ftxui.deb - run: apt-get install -y --no-install-recommends ca-certificates diff --git a/CMakeLists.txt b/CMakeLists.txt index 02b774db86..419d344832 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -180,6 +180,7 @@ if(BOOTSTRAP_DAEMON AND WIN32) endif() option(BUILD_FUZZ_TESTS "Build fuzzing harnesses" OFF) +option(BUILD_NETPROF "Build netprof utility" OFF) if(MSVC) option(MSVC_STATIC_SODIUM "Whether to link libsodium statically for MSVC" OFF) @@ -693,6 +694,17 @@ if(BOOTSTRAP_DAEMON) endif() endif() +if(BUILD_NETPROF) + if(NOT FTXUI_FOUND) + message(WARNING "Option BUILD_NETPROF is enabled but required library FTXUI was not found.") + set(BUILD_NETPROF OFF CACHE BOOL "" FORCE) + endif() + if(NOT NLOHMANN_JSON_FOUND) + message(WARNING "Option BUILD_NETPROF is enabled but required library NLOHMANN_JSON was not found.") + set(BUILD_NETPROF OFF CACHE BOOL "" FORCE) + endif() +endif() + if(BUILD_FUN_UTILS) add_subdirectory(other/fun) endif() diff --git a/cmake/Dependencies.cmake b/cmake/Dependencies.cmake index fb30fdcefa..cd05d56775 100644 --- a/cmake/Dependencies.cmake +++ b/cmake/Dependencies.cmake @@ -53,3 +53,34 @@ endif() # For tox-bootstrapd. pkg_search_module(LIBCONFIG libconfig IMPORTED_TARGET) + +# For netprof. +if(BUILD_NETPROF) + pkg_search_module(FTXUI ftxui IMPORTED_TARGET) + if(FTXUI_FOUND) + string(REGEX MATCH "^([0-9]+)" FTXUI_VERSION_MAJOR "${FTXUI_VERSION}") + else() + pkg_search_module(FTXUI_SCREEN ftxui-screen IMPORTED_TARGET) + pkg_search_module(FTXUI_DOM ftxui-dom IMPORTED_TARGET) + pkg_search_module(FTXUI_COMPONENT ftxui-component IMPORTED_TARGET) + if(FTXUI_SCREEN_FOUND AND FTXUI_DOM_FOUND AND FTXUI_COMPONENT_FOUND) + set(FTXUI_FOUND TRUE) + string(REGEX MATCH "^([0-9]+)" FTXUI_VERSION_MAJOR "${FTXUI_SCREEN_VERSION}") + endif() + endif() + + if(NOT FTXUI_FOUND) + find_package(ftxui QUIET) + if(TARGET ftxui::screen AND TARGET ftxui::dom AND TARGET ftxui::component) + set(FTXUI_FOUND TRUE) + endif() + endif() + + pkg_search_module(NLOHMANN_JSON nlohmann_json IMPORTED_TARGET) + if(NOT NLOHMANN_JSON_FOUND) + find_package(nlohmann_json QUIET) + if(TARGET nlohmann_json::nlohmann_json) + set(NLOHMANN_JSON_FOUND TRUE) + endif() + endif() +endif() diff --git a/other/analysis/gen-file.sh b/other/analysis/gen-file.sh index 36e811935b..967bcb999d 100644 --- a/other/analysis/gen-file.sh +++ b/other/analysis/gen-file.sh @@ -10,6 +10,8 @@ CPPFLAGS+=("-Iother") CPPFLAGS+=("-Iother/bootstrap_daemon/src") CPPFLAGS+=("-Iother/fun") CPPFLAGS+=("-Itesting") +CPPFLAGS+=("-Itesting/netprof") +CPPFLAGS+=("-Itesting/netprof/views") CPPFLAGS+=("-Itesting/fuzzing") CPPFLAGS+=("-Itesting/support") CPPFLAGS+=("-Itesting/support/doubles") @@ -61,6 +63,7 @@ COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './_build/*'" COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './other/docker/*'" COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './super_donators/*'" COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './testing/fuzzing/*'" +COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './testing/netprof/*'" COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './third_party/cmp/examples/*'" COMMON_EXCLUDES="$COMMON_EXCLUDES -and -not -wholename './third_party/cmp/test/*'" diff --git a/other/event_tooling/generate_event_c.cpp b/other/event_tooling/generate_event_c.cpp index 9be359093d..42c23f3f9f 100644 --- a/other/event_tooling/generate_event_c.cpp +++ b/other/event_tooling/generate_event_c.cpp @@ -721,6 +721,7 @@ int main(int argc, char** argv) { { "Dht_Nodes_Response", { + EventTypeByteArray{"responder_public_key", "TOX_PUBLIC_KEY_SIZE"}, EventTypeByteArray{"public_key", "TOX_PUBLIC_KEY_SIZE"}, EventTypeByteRange{"ip", "ip_length", "ip_length", "char", "uint32_t", true}, EventTypeTrivial{"uint16_t", "port"}, diff --git a/testing/CMakeLists.txt b/testing/CMakeLists.txt index cf465b040a..4fc502422c 100644 --- a/testing/CMakeLists.txt +++ b/testing/CMakeLists.txt @@ -39,3 +39,7 @@ endif() if(IS_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}/support") add_subdirectory(support) endif() + +if(BUILD_NETPROF) + add_subdirectory(netprof) +endif() diff --git a/testing/netprof/BUILD.bazel b/testing/netprof/BUILD.bazel new file mode 100644 index 0000000000..d1851f1b36 --- /dev/null +++ b/testing/netprof/BUILD.bazel @@ -0,0 +1,211 @@ +load("@rules_cc//cc:defs.bzl", "cc_binary", "cc_library", "cc_test") + +cc_library( + name = "constants", + hdrs = ["constants.hh"], + visibility = ["//c-toxcore/testing/netprof:__subpackages__"], +) + +cc_library( + name = "layout_engine", + srcs = ["layout_engine.cc"], + hdrs = ["layout_engine.hh"], + deps = [":constants"], +) + +cc_library( + name = "model", + hdrs = ["model.hh"], + visibility = ["//c-toxcore/testing/netprof:__subpackages__"], + deps = [ + ":constants", + "//c-toxcore/toxcore:tox", + ], +) + +cc_library( + name = "model_utils", + srcs = ["model_utils.cc"], + hdrs = ["model_utils.hh"], + visibility = ["//c-toxcore/testing/netprof:__subpackages__"], + deps = [ + ":model", + "//c-toxcore/toxcore:tox", + ], +) + +cc_library( + name = "packet_utils", + srcs = ["packet_utils.cc"], + hdrs = ["packet_utils.hh"], + visibility = ["//c-toxcore/testing/netprof:__subpackages__"], + deps = [ + "//c-toxcore/toxcore:tox", + ], +) + +cc_library( + name = "node_wrapper", + srcs = ["node_wrapper.cc"], + hdrs = ["node_wrapper.hh"], + deps = [ + ":constants", + ":model", + "//c-toxcore/testing/support", + "//c-toxcore/toxcore:tox", + "//c-toxcore/toxcore:tox_events", + "//c-toxcore/toxcore:tox_options", + ], +) + +cc_library( + name = "simulation_manager", + srcs = ["simulation_manager.cc"], + hdrs = ["simulation_manager.hh"], + deps = [ + ":constants", + ":model_utils", + ":node_wrapper", + "//c-toxcore/testing/support", + "//c-toxcore/toxcore:tox", + "//c-toxcore/toxcore:tox_events", + "//c-toxcore/toxcore:tox_options", + "@json", + ], +) + +cc_library( + name = "ui", + srcs = [ + "command_registry.cc", + "ui.cc", + ], + hdrs = [ + "command_registry.hh", + "ui.hh", + ], + deps = [ + ":constants", + ":layout_engine", + ":model", + ":model_utils", + "//c-toxcore/testing/netprof/views", + "//c-toxcore/toxcore:tox", + "//c-toxcore/toxcore:tox_events", + "@ftxui//:component", + "@ftxui//:dom", + "@ftxui//:screen", + ], +) + +cc_library( + name = "ui_test_support", + testonly = True, + srcs = ["ui_test_support.cc"], + hdrs = ["ui_test_support.hh"], + visibility = ["//c-toxcore/testing/netprof:__subpackages__"], + deps = [ + ":ui", + "@com_google_googletest//:gtest", + "@ftxui//:dom", + "@ftxui//:screen", + ], +) + +cc_library( + name = "app", + srcs = ["app.cc"], + hdrs = ["app.hh"], + deps = [ + ":constants", + ":model", + ":model_utils", + ":packet_utils", + ":simulation_manager", + ":ui", + "//c-toxcore/toxcore:tox", + ], +) + +cc_binary( + name = "netprof", + srcs = [ + "main.cc", + ], + deps = [ + ":app", + ":packet_utils", + ":simulation_manager", + ":ui", + "//c-toxcore/testing/support", + "//c-toxcore/toxcore:tox", + "//c-toxcore/toxcore:tox_events", + "@ftxui//:component", + "@ftxui//:dom", + "@ftxui//:screen", + "@json", + ], +) + +cc_test( + name = "app_test", + srcs = ["app_test.cc"], + deps = [ + ":app", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "ui_test", + srcs = ["ui_test.cc"], + deps = [ + ":ui", + ":ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "model_utils_test", + srcs = ["model_utils_test.cc"], + deps = [ + ":model_utils", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "simulation_manager_test", + srcs = ["simulation_manager_test.cc"], + deps = [ + ":simulation_manager", + "//c-toxcore/testing/support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "node_wrapper_test", + srcs = ["node_wrapper_test.cc"], + deps = [ + ":node_wrapper", + "//c-toxcore/testing/support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "layout_engine_test", + srcs = ["layout_engine_test.cc"], + deps = [ + ":layout_engine", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) diff --git a/testing/netprof/CMakeLists.txt b/testing/netprof/CMakeLists.txt new file mode 100644 index 0000000000..d2a104b28f --- /dev/null +++ b/testing/netprof/CMakeLists.txt @@ -0,0 +1,85 @@ +set(netprof_lib_SOURCES + app.cc + command_registry.cc + layout_engine.cc + model_utils.cc + node_wrapper.cc + packet_utils.cc + simulation_manager.cc + ui.cc + views/bottom_bar.cc + views/command_log.cc + views/command_palette.cc + views/dht_filter.cc + views/dht_topology.cc + views/event_log.cc + views/focusable.cc + views/hud.cc + views/inspector.cc + views/topology.cc +) + +add_library(netprof_core STATIC ${netprof_lib_SOURCES}) + +target_include_directories(netprof_core PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + +target_link_libraries(netprof_core PUBLIC support) + +if(TARGET nlohmann_json::nlohmann_json) + target_link_libraries(netprof_core PUBLIC nlohmann_json::nlohmann_json) +else() + target_link_libraries(netprof_core PUBLIC PkgConfig::NLOHMANN_JSON) +endif() + +if(TARGET ftxui::component) + target_link_libraries(netprof_core PUBLIC ftxui::component ftxui::dom ftxui::screen) +elseif(TARGET PkgConfig::FTXUI) + target_link_libraries(netprof_core PUBLIC PkgConfig::FTXUI) +else() + target_link_libraries(netprof_core PUBLIC PkgConfig::FTXUI_COMPONENT PkgConfig::FTXUI_DOM PkgConfig::FTXUI_SCREEN) +endif() + +if(TARGET pthreads4w::pthreads4w) + target_link_libraries(netprof_core PUBLIC pthreads4w::pthreads4w) +elseif(TARGET PThreads4W::PThreads4W) + target_link_libraries(netprof_core PUBLIC PThreads4W::PThreads4W) +elseif(TARGET Threads::Threads) + target_link_libraries(netprof_core PUBLIC Threads::Threads) +endif() + +if(TARGET toxcore_static) + target_link_libraries(netprof_core PRIVATE toxcore_static) +else() + target_link_libraries(netprof_core PRIVATE toxcore_shared) +endif() + +if(FTXUI_VERSION_MAJOR) + target_compile_definitions(netprof_core PUBLIC FTXUI_VERSION_MAJOR=${FTXUI_VERSION_MAJOR}) +endif() + +add_executable(netprof main.cc) +target_link_libraries(netprof PRIVATE netprof_core) + +# Tests +if(UNITTEST AND TARGET GTest::gtest_main) + add_library(netprof_test_support STATIC ui_test_support.cc) + target_link_libraries(netprof_test_support PUBLIC netprof_core GTest::gtest GTest::gmock) + + function(netprof_test target source) + add_executable(${target} ${source}) + target_link_libraries(${target} PRIVATE netprof_test_support netprof_core GTest::gtest_main GTest::gmock) + if(TARGET toxcore_static) + target_link_libraries(${target} PRIVATE toxcore_static) + else() + target_link_libraries(${target} PRIVATE toxcore_shared) + endif() + add_test(NAME ${target} COMMAND ${target}) + endfunction() + + netprof_test(netprof_layout_engine_test layout_engine_test.cc) + netprof_test(netprof_node_wrapper_test node_wrapper_test.cc) + netprof_test(netprof_simulation_manager_test simulation_manager_test.cc) + netprof_test(netprof_ui_test ui_test.cc) + netprof_test(netprof_model_utils_test model_utils_test.cc) + netprof_test(netprof_app_test app_test.cc) +endif() diff --git a/testing/netprof/app.cc b/testing/netprof/app.cc new file mode 100644 index 0000000000..96914af759 --- /dev/null +++ b/testing/netprof/app.cc @@ -0,0 +1,508 @@ +#include "app.hh" + +#include + +#include "../../toxcore/tox_private.h" +#include "model_utils.hh" +#include "packet_utils.hh" + +namespace tox::netprof { + +NetProfApp::NetProfApp(uint64_t seed, bool verbose) + : manager_(seed, verbose) + , ui_([this](UICommand cmd) { this->handle_command(std::move(cmd)); }) + , last_sync_virtual_time_(manager_.get_virtual_time_ms()) +{ + // Initial setup + auto alice = manager_.add_node("Alice", 20.0f, 50.0f); + auto bob = manager_.add_node("Bob", 80.0f, 50.0f); + manager_.connect_nodes(alice->id(), bob->id()); + + // Notify UI of initial state + ui_.emit(MsgNodeAdded{alice->id(), alice->name(), alice->x(), alice->y(), alice->get_dht_id()}); + ui_.emit(MsgNodeAdded{bob->id(), bob->name(), bob->x(), bob->y(), bob->get_dht_id()}); + ui_.emit(MsgLinkUpdated{alice->id(), bob->id(), true, 5, 0.0}); +} + +NetProfApp::~NetProfApp() +{ + running_ = false; + pause_cv_.notify_all(); + if (sim_thread_.joinable()) + sim_thread_.join(); +} + +void NetProfApp::run(bool headless, const std::string &load_path) +{ + if (!load_path.empty()) { + load_snapshot(load_path); + } + + if (headless) { + run_headless(); + return; + } + + // Start simulation thread. + sim_thread_ = std::thread([this] { simulation_loop(); }); + + // Start UI (blocking). + ui_.run(); +} + +void NetProfApp::handle_command(UICommand cmd) +{ + switch (cmd.type) { + case CmdType::Quit: + running_ = false; + pause_cv_.notify_all(); + break; + case CmdType::TogglePause: + auto_play_ = !auto_play_; + pause_cv_.notify_all(); + sync_stats(); + ui_.emit( + MsgLog{auto_play_ ? "Simulation RESUMED" : "Simulation PAUSED", LogLevel::Command}); + break; + case CmdType::Step: + manager_.step(kDefaultTickMs); + sync_stats(); + ui_.emit(MsgLog{"Simulation STEPPED", LogLevel::Command}); + break; + case CmdType::SetSpeed: + if (!cmd.args.empty()) { + double speed; + if (safe_stod(cmd.args[0], speed)) { + simulation_speed_ = speed; + pause_cv_.notify_all(); + sync_stats(); + ui_.emit(MsgLog{"Simulation speed set to " + cmd.args[0] + "x", LogLevel::Command}); + } + } + break; + case CmdType::AddNode: + cmd_add_node(cmd.args); + break; + case CmdType::MoveNode: + cmd_move_node(cmd.args); + break; + case CmdType::RemoveNode: + cmd_remove_node(cmd.args); + break; + case CmdType::ConnectNodes: + cmd_connect_nodes(cmd.args); + break; + case CmdType::DisconnectNodes: + cmd_disconnect_nodes(cmd.args); + break; + case CmdType::ToggleOffline: + cmd_toggle_offline(cmd.args); + break; + case CmdType::TogglePin: + cmd_toggle_pin(cmd.args); + break; + case CmdType::SaveSnapshot: + manager_.save_to_file("netprof_save.json"); + ui_.emit(MsgLog{"Saved to netprof_save.json", LogLevel::Command}); + break; + case CmdType::LoadSnapshot: + manager_.load_from_file("netprof_save.json"); + resync_ui(); + ui_.emit(MsgLog{"Loaded snapshot and resynced UI", LogLevel::Command}); + break; + } +} + +void NetProfApp::cmd_add_node(const std::vector &args) +{ + std::string name; + bool tcp_only = false; + + // Try to find an unused "nice" name. + for (const char *candidate : kNiceNames) { + bool taken = false; + manager_.for_each_node([&](const NodeWrapper &n) { + if (n.name() == candidate) { + taken = true; + } + }); + if (!taken) { + name = candidate; + break; + } + } + + if (name.empty()) { + name = "Node " + std::to_string(manager_.node_count() + 1); + } + + if (!args.empty() && args.back() == "tcp") { + tcp_only = true; + } + + auto n = manager_.add_node(name, -1.0f, -1.0f, tcp_only); + ui_.emit(MsgNodeAdded{n->id(), name, -1.0f, -1.0f, n->get_dht_id()}); + ui_.emit(MsgLog{ + "Added node: " + name + " (ID: " + std::to_string(n->id()) + ")", LogLevel::Command}); +} + +void NetProfApp::cmd_move_node(const std::vector &args) +{ + if (args.size() >= 3) { + uint32_t id; + std::shared_ptr n; + if (safe_stoul(args[0], id)) { + n = manager_.get_node(id); + } else { + n = manager_.get_node_by_name(args[0]); + if (n) + id = n->id(); + } + + float x, y; + if (n && safe_stof(args[1], x) && safe_stof(args[2], y)) { + n->set_pos(x, y); + n->set_pinned(true); + ui_.emit(MsgNodeMoved{id, x, y}); + ui_.emit(MsgLog{"Moved and PINNED node " + std::to_string(id) + " to (" + args[1] + ", " + + args[2] + ")", + LogLevel::Command}); + } + } +} + +void NetProfApp::cmd_remove_node(const std::vector &args) +{ + if (!args.empty()) { + uint32_t id; + if (safe_stoul(args[0], id)) { + manager_.remove_node(id); + ui_.emit(MsgNodeRemoved{id}); + ui_.emit(MsgLog{"Removed node " + std::to_string(id), LogLevel::Command}); + } else if (auto n = manager_.get_node_by_name(args[0])) { + id = n->id(); + manager_.remove_node(id); + ui_.emit(MsgNodeRemoved{id}); + ui_.emit(MsgLog{ + "Removed node " + std::to_string(id) + " (" + args[0] + ")", LogLevel::Command}); + } + } +} + +void NetProfApp::cmd_connect_nodes(const std::vector &args) +{ + if (args.size() >= 2) { + std::shared_ptr n1, n2; + uint32_t id1, id2; + + if (safe_stoul(args[0], id1)) { + n1 = manager_.get_node(id1); + } else { + n1 = manager_.get_node_by_name(args[0]); + } + + if (safe_stoul(args[1], id2)) { + n2 = manager_.get_node(id2); + } else { + n2 = manager_.get_node_by_name(args[1]); + } + + if (n1 && n2) { + id1 = n1->id(); + id2 = n2->id(); + if (manager_.connect_nodes(id1, id2)) { + ui_.emit(MsgLinkUpdated{id1, id2, true, 20, 0.0}); + ui_.emit( + MsgLog{"Connected node " + std::to_string(id1) + " and " + std::to_string(id2), + LogLevel::Command}); + } + } + } +} + +void NetProfApp::cmd_disconnect_nodes(const std::vector &args) +{ + if (args.size() >= 2) { + std::shared_ptr n1, n2; + uint32_t id1, id2; + + if (safe_stoul(args[0], id1)) { + n1 = manager_.get_node(id1); + } else { + n1 = manager_.get_node_by_name(args[0]); + } + + if (safe_stoul(args[1], id2)) { + n2 = manager_.get_node(id2); + } else { + n2 = manager_.get_node_by_name(args[1]); + } + + if (n1 && n2) { + id1 = n1->id(); + id2 = n2->id(); + if (manager_.disconnect_nodes(id1, id2)) { + ui_.emit(MsgLinkUpdated{id1, id2, false, 0, 0.0}); + ui_.emit(MsgLog{ + "Disconnected node " + std::to_string(id1) + " and " + std::to_string(id2), + LogLevel::Command}); + } + } + } +} + +void NetProfApp::cmd_toggle_offline(const std::vector &args) +{ + if (!args.empty()) { + uint32_t id; + std::shared_ptr n; + if (safe_stoul(args[0], id)) { + n = manager_.get_node(id); + } else { + n = manager_.get_node_by_name(args[0]); + if (n) + id = n->id(); + } + + if (n) { + bool new_state = !n->is_online(); + n->set_online(new_state); + ui_.emit(MsgLog{ + "Node " + std::to_string(id) + " is now " + (new_state ? "online" : "offline"), + LogLevel::Command}); + } + } +} + +void NetProfApp::cmd_toggle_pin(const std::vector &args) +{ + if (!args.empty()) { + uint32_t id; + std::shared_ptr n; + if (safe_stoul(args[0], id)) { + n = manager_.get_node(id); + } else { + n = manager_.get_node_by_name(args[0]); + if (n) + id = n->id(); + } + + if (n) { + bool new_state = !n->is_pinned(); + n->set_pinned(new_state); + ui_.emit(MsgLog{ + "Node " + std::to_string(id) + " is now " + (new_state ? "PINNED" : "UNPINNED"), + LogLevel::Command}); + } + } +} + +void NetProfApp::load_snapshot(const std::string &filename) +{ + manager_.load_from_file(filename); + resync_ui(); + ui_.emit(MsgLog{"Loaded snapshot: " + filename, LogLevel::Command}); +} + +void NetProfApp::resync_ui() +{ + ui_.emit(MsgReset{}); + manager_.for_each_node([this](const NodeWrapper &n) { + ui_.emit(MsgNodeAdded{n.id(), n.name(), n.x(), n.y(), n.get_dht_id()}); + }); + + manager_.for_each_connection([this](const SimulationManager::ConnectionIntent &c) { + ui_.emit(MsgLinkUpdated{c.node_a, c.node_b, true, 20, 0.0}); + }); +} + +void NetProfApp::sync_stats() +{ + std::lock_guard lock(stats_mutex_); + + std::vector batch_msgs; + auto emit = [&](UIMessage msg) { batch_msgs.push_back(std::move(msg)); }; + + uint64_t virtual_time = manager_.get_virtual_time_ms(); + uint64_t delta_ms = virtual_time - last_sync_virtual_time_; + uint32_t num_ticks = static_cast(delta_ms / kDefaultTickMs); + if (num_ticks > 0) { + last_sync_virtual_time_ += num_ticks * kDefaultTickMs; + } + + std::map global_per_packet_bytes; + std::vector> node_stats_snapshots; + manager_.for_each_node([&](const NodeWrapper &node) { + auto stats = const_cast(node).get_stats(); + node_stats_snapshots.push_back({node.id(), stats}); + + auto add_stats = [&](const std::map &packet_stats, + Tox_Netprof_Packet_Type protocol) { + for (const auto &kv : packet_stats) { + if (kv.second.sent == 0 && kv.second.recv == 0) { + continue; + } + NodeInfo::ProtocolKey key{static_cast(protocol), kv.first}; + global_per_packet_bytes[key].sent += kv.second.sent; + global_per_packet_bytes[key].recv += kv.second.recv; + } + }; + + add_stats(stats.udp_packet_stats, TOX_NETPROF_PACKET_TYPE_UDP); + add_stats(stats.tcp_packet_stats, TOX_NETPROF_PACKET_TYPE_TCP); + }); + + // Collect statistics from simulation and emit to UI. + GlobalStats gstats; + gstats.virtual_time_ms = virtual_time; + gstats.real_time_factor = simulation_speed_; + gstats.total_packets_sent = manager_.total_packets_sent(); + gstats.total_bytes_sent = manager_.total_bytes_sent(); + gstats.paused = !auto_play_; + for (const auto &kv : global_per_packet_bytes) { + gstats.protocol_breakdown[kv.first] = {kv.second.sent, kv.second.recv}; + } + emit(MsgTick{gstats}); + + for (const auto &node_entry : node_stats_snapshots) { + uint32_t id = node_entry.first; + const auto &stats = node_entry.second; + auto node_ptr = manager_.get_node(id); + + int bw_in = 0; + int bw_out = 0; + + if (last_node_stats_.count(id) && delta_ms > 0) { + const auto &prev = last_node_stats_[id]; + uint64_t total_recv = stats.total_udp.bytes_recv + stats.total_tcp.bytes_recv; + uint64_t total_sent = stats.total_udp.bytes_sent + stats.total_tcp.bytes_sent; + uint64_t prev_recv = prev.total_udp.bytes_recv + prev.total_tcp.bytes_recv; + uint64_t prev_sent = prev.total_udp.bytes_sent + prev.total_tcp.bytes_sent; + + bw_in = static_cast((total_recv - prev_recv) * 1000 / delta_ms); + bw_out = static_cast((total_sent - prev_sent) * 1000 / delta_ms); + } + + if (num_ticks > 0) { + last_node_stats_[id] = stats; + } + + std::map protocol_breakdown; + auto add_node_stats = [&](const std::map &packet_stats, + Tox_Netprof_Packet_Type protocol) { + for (const auto &kv : packet_stats) { + if (kv.second.sent == 0 && kv.second.recv == 0) { + continue; + } + NodeInfo::ProtocolKey key{static_cast(protocol), kv.first}; + protocol_breakdown[key].sent += kv.second.sent; + protocol_breakdown[key].recv += kv.second.recv; + } + }; + + add_node_stats(stats.udp_packet_stats, TOX_NETPROF_PACKET_TYPE_UDP); + add_node_stats(stats.tcp_packet_stats, TOX_NETPROF_PACKET_TYPE_TCP); + + emit(MsgNodeStats{ + id, + bw_in, + bw_out, + stats.dht.num_closelist, + stats.dht.num_friends, + stats.dht.num_friends_udp, + stats.dht.num_friends_tcp, + stats.dht.connection_status, + node_ptr ? node_ptr->is_online() : false, + node_ptr ? node_ptr->is_pinned() : false, + num_ticks, + protocol_breakdown, + }); + // Poll events. + if (node_ptr) { + auto event_batches = node_ptr->poll_events(); + for (const auto &batch : event_batches) { + uint32_t num_events = tox_events_get_size(batch.get()); + for (uint32_t i = 0; i < num_events; ++i) { + const Tox_Event *ev = tox_events_get(batch.get(), i); + Tox_Event_Type type = tox_event_get_type(ev); + LogLevel level = LogLevel::Info; + + if (type == TOX_EVENT_DHT_NODES_RESPONSE) { + level = LogLevel::DHT; + const Tox_Event_Dht_Nodes_Response *res + = tox_event_get_dht_nodes_response(ev); + const uint8_t *responder_pk + = tox_event_dht_nodes_response_get_responder_public_key(res); + auto responder = manager_.get_node_by_dht_id(responder_pk); + uint32_t responder_id = responder ? responder->id() : 0; + + const uint8_t *discovered_pk + = tox_event_dht_nodes_response_get_public_key(res); + auto discovered = manager_.get_node_by_dht_id(discovered_pk); + uint32_t discovered_id = discovered ? discovered->id() : 0; + + emit(MsgDHTResponse{id, responder_id, discovered_id}); + } else if (type == TOX_EVENT_FRIEND_CONNECTION_STATUS) { + level = LogLevel::Conn; + } + + emit(MsgLog{ + "Node " + std::to_string(id) + " event: " + tox_event_type_to_string(type), + level, + }); + } + } + } + } + + if (!batch_msgs.empty()) { + ui_.emit_batch(std::move(batch_msgs)); + } +} + +void NetProfApp::simulation_loop() +{ + auto last_sync_real_time = std::chrono::steady_clock::now(); + while (running_) { + if (auto_play_) { + auto start_step = std::chrono::steady_clock::now(); + manager_.step(kDefaultTickMs); + + auto now = std::chrono::steady_clock::now(); + if (now - last_sync_real_time >= std::chrono::milliseconds(kUIRefreshIntervalMs)) { + sync_stats(); + last_sync_real_time = now; + } + + if (simulation_speed_ > 0.0) { + auto target_duration = std::chrono::microseconds( + static_cast(kDefaultTickMs * 1000.0 / simulation_speed_)); + auto end_step = std::chrono::steady_clock::now(); + auto elapsed = end_step - start_step; + + if (target_duration > elapsed) { + std::unique_lock lock(pause_mutex_); + pause_cv_.wait_for(lock, target_duration - elapsed, + [this] { return !running_ || !auto_play_; }); + } + } + } else { + sync_stats(); + std::unique_lock lock(pause_mutex_); + pause_cv_.wait_for( + lock, std::chrono::milliseconds(100), [this] { return !running_ || auto_play_; }); + } + } +} + +void NetProfApp::run_headless() +{ + std::cout << "[Headless] Starting..." << std::endl; + for (int i = 0; i < 100; ++i) { + manager_.step(kDefaultTickMs); + if (i % 20 == 0) + std::cout << "Tick " << i << std::endl; + } +} + +} // namespace tox::netprof diff --git a/testing/netprof/app.hh b/testing/netprof/app.hh new file mode 100644 index 0000000000..0a92fbdaa6 --- /dev/null +++ b/testing/netprof/app.hh @@ -0,0 +1,62 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_APP_H +#define C_TOXCORE_TESTING_NETPROF_APP_H + +#include +#include +#include +#include +#include +#include + +#include "simulation_manager.hh" +#include "ui.hh" + +namespace tox::netprof { + +class NetProfApp { +public: + explicit NetProfApp(uint64_t seed, bool verbose); + ~NetProfApp(); + + void run(bool headless, const std::string &load_path = ""); + + // Internal command handlers exposed for testing + void handle_command(UICommand cmd); + + void load_snapshot(const std::string &filename); + + const SimulationManager &manager() const { return manager_; } + NetProfUI &ui() { return ui_; } + +private: + SimulationManager manager_; + NetProfUI ui_; + + std::atomic running_{true}; + std::atomic auto_play_{false}; + std::atomic simulation_speed_{1.0}; + std::atomic last_sync_virtual_time_{0}; + std::mutex stats_mutex_; + std::mutex pause_mutex_; + std::condition_variable pause_cv_; + std::map last_node_stats_; + std::thread sim_thread_; + + void simulation_loop(); + void sync_stats(); + void resync_ui(); + + void cmd_add_node(const std::vector &args); + void cmd_move_node(const std::vector &args); + void cmd_remove_node(const std::vector &args); + void cmd_connect_nodes(const std::vector &args); + void cmd_disconnect_nodes(const std::vector &args); + void cmd_toggle_offline(const std::vector &args); + void cmd_toggle_pin(const std::vector &args); + + void run_headless(); +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_APP_H diff --git a/testing/netprof/app_test.cc b/testing/netprof/app_test.cc new file mode 100644 index 0000000000..2167254bfe --- /dev/null +++ b/testing/netprof/app_test.cc @@ -0,0 +1,184 @@ +#include "app.hh" + +#include + +namespace tox::netprof { + +class AppTest : public ::testing::Test { +protected: + ~AppTest() override; + NetProfApp app_{12345, false}; +}; + +AppTest::~AppTest() = default; + +TEST_F(AppTest, ConnectByName) +{ + // NetProfApp starts with Alice (1) and Bob (2) connected. + // Let's add Charlie and Dave. + app_.handle_command({CmdType::AddNode, {}}); // Adds Charlie (3) + app_.handle_command({CmdType::AddNode, {}}); // Adds Dave (4) + + // Verify they are not connected initially. + EXPECT_EQ(app_.manager().get_node_by_name("Charlie")->id(), 3u); + EXPECT_EQ(app_.manager().get_node_by_name("Dave")->id(), 4u); + + // Connect them by name. + app_.handle_command({CmdType::ConnectNodes, {"Charlie", "Dave"}}); + app_.ui().process_messages(); + + // Verify connection intent exists. + bool found = false; + app_.manager().for_each_connection([&](const SimulationManager::ConnectionIntent &c) { + if ((c.node_a == 3 && c.node_b == 4) || (c.node_a == 4 && c.node_b == 3)) { + found = true; + } + }); + EXPECT_TRUE(found); + + // Verify UI model has the link. + const auto &links = app_.ui().get_model().links; + bool ui_found = false; + for (const auto &l : links) { + if ((l.from == 3 && l.to == 4) || (l.from == 4 && l.to == 3)) { + ui_found = true; + break; + } + } + EXPECT_TRUE(ui_found); +} + +TEST_F(AppTest, ConnectInitialAndAddedNode) +{ + // Alice (1) exists. Add Charlie (3). + app_.handle_command({CmdType::AddNode, {}}); + app_.ui().process_messages(); + + // Connect Alice and Charlie. + app_.handle_command({CmdType::ConnectNodes, {"Alice", "Charlie"}}); + app_.ui().process_messages(); + + // Verify UI model has the link. + const auto &links = app_.ui().get_model().links; + bool ui_found = false; + for (const auto &l : links) { + if ((l.from == 1 && l.to == 3) || (l.from == 3 && l.to == 1)) { + ui_found = true; + break; + } + } + EXPECT_TRUE(ui_found); +} + +TEST_F(AppTest, ConnectCaseInsensitive) +{ + // Add Charlie. + app_.handle_command({CmdType::AddNode, {}}); + app_.ui().process_messages(); + + // Try connecting with lowercase names. + app_.handle_command({CmdType::ConnectNodes, {"alice", "charlie"}}); + app_.ui().process_messages(); + + const auto &links = app_.ui().get_model().links; + bool ui_found = false; + for (const auto &l : links) { + if ((l.from == 1 && l.to == 3) || (l.from == 3 && l.to == 1)) { + ui_found = true; + break; + } + } + EXPECT_TRUE(ui_found) << "Connection by lowercase name should work"; +} + +TEST_F(AppTest, ConnectMixedCase) +{ + // Add Charlie. + app_.handle_command({CmdType::AddNode, {}}); + app_.ui().process_messages(); + + // Try connecting with mixed case names. + app_.handle_command({CmdType::ConnectNodes, {"aLiCe", "CHarLIE"}}); + app_.ui().process_messages(); + + const auto &links = app_.ui().get_model().links; + bool ui_found = false; + for (const auto &l : links) { + if ((l.from == 1 && l.to == 3) || (l.from == 3 && l.to == 1)) { + ui_found = true; + break; + } + } + EXPECT_TRUE(ui_found) << "Connection by mixed-case name should work"; + + // Verify log message. + const auto &logs = app_.ui().get_model().logs; + bool log_found = false; + for (const auto &entry : logs) { + if (entry.message.find("Connected node") != std::string::npos) { + log_found = true; + break; + } + } + EXPECT_TRUE(log_found); +} + +TEST_F(AppTest, RemoveByName) +{ + EXPECT_NE(app_.manager().get_node_by_name("Alice"), nullptr); + app_.handle_command({CmdType::RemoveNode, {"Alice"}}); + EXPECT_EQ(app_.manager().get_node_by_name("Alice"), nullptr); +} + +TEST_F(AppTest, MoveByName) +{ + auto bob = app_.manager().get_node_by_name("Bob"); + EXPECT_FALSE(bob->is_pinned()); + app_.handle_command({CmdType::MoveNode, {"Bob", "10.0", "20.0"}}); + EXPECT_FLOAT_EQ(bob->x(), 10.0f); + EXPECT_FLOAT_EQ(bob->y(), 20.0f); + // Manual move should automatically pin the node. + EXPECT_TRUE(bob->is_pinned()); +} + +TEST_F(AppTest, TogglePin) +{ + auto alice = app_.manager().get_node_by_name("Alice"); + EXPECT_FALSE(alice->is_pinned()); + + // Pin it. + app_.handle_command({CmdType::TogglePin, {"Alice"}}); + EXPECT_TRUE(alice->is_pinned()); + + // Unpin it. + app_.handle_command({CmdType::TogglePin, {"Alice"}}); + EXPECT_FALSE(alice->is_pinned()); +} + +TEST_F(AppTest, InvalidNumericInputDoesNotCrash) +{ + // This would have crashed before if it used std::stod directly. + // "not_a_number" will fail safe_stod/safe_stoul and then fail name lookup. + app_.handle_command({CmdType::SetSpeed, {"not_a_number"}}); + app_.handle_command({CmdType::ConnectNodes, {"NonExistent1", "NonExistent2"}}); +} + +TEST_F(AppTest, CommandEmitsCorrectLogLevel) +{ + app_.handle_command({CmdType::TogglePause, {}}); + app_.ui().process_messages(); + + const auto &logs = app_.ui().get_model().logs; + bool found = false; + for (const auto &log : logs) { + if (log.level == LogLevel::Command + && (log.message.find("PAUSED") != std::string::npos + || log.message.find("RESUMED") != std::string::npos)) { + found = true; + break; + } + } + EXPECT_TRUE(found); +} + +} // namespace tox::netprof diff --git a/testing/netprof/command_registry.cc b/testing/netprof/command_registry.cc new file mode 100644 index 0000000000..f3942af30a --- /dev/null +++ b/testing/netprof/command_registry.cc @@ -0,0 +1,66 @@ +#include "command_registry.hh" + +#include +#include +#include +#include + +namespace tox::netprof { + +void CommandRegistry::register_command(std::string name, std::string description, Handler handler) +{ + std::transform( + name.begin(), name.end(), name.begin(), [](unsigned char c) { return std::tolower(c); }); + handlers_[name] = std::move(handler); + descriptions_[name] = std::move(description); +} + +bool CommandRegistry::execute(const std::string &cmd_line) +{ + std::stringstream ss(cmd_line); + std::vector tokens; + std::string token; + while (ss >> token) { + tokens.push_back(token); + } + + if (tokens.empty()) { + return false; + } + + // Find the longest matching command name greedily. + std::string best_match; + size_t tokens_consumed = 0; + std::string current_prefix; + + auto to_lower = [](std::string s) { + std::transform( + s.begin(), s.end(), s.begin(), [](unsigned char c) { return std::tolower(c); }); + return s; + }; + + for (size_t i = 0; i < tokens.size(); ++i) { + if (i > 0) { + current_prefix += " "; + } + current_prefix += to_lower(tokens[i]); + + if (handlers_.count(current_prefix)) { + best_match = current_prefix; + tokens_consumed = i + 1; + } + } + + if (!best_match.empty()) { + std::vector args; + for (size_t i = tokens_consumed; i < tokens.size(); ++i) { + args.push_back(tokens[i]); + } + handlers_[best_match](args); + return true; + } + + return false; +} + +} // namespace tox::netprof diff --git a/testing/netprof/command_registry.hh b/testing/netprof/command_registry.hh new file mode 100644 index 0000000000..264792340f --- /dev/null +++ b/testing/netprof/command_registry.hh @@ -0,0 +1,46 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_COMMAND_REGISTRY_H +#define C_TOXCORE_TESTING_NETPROF_COMMAND_REGISTRY_H + +#include +#include +#include +#include + +namespace tox::netprof { + +/** + * @brief Represents a parsed command with its arguments. + */ +struct CommandContext { + std::string name; + std::vector args; +}; + +/** + * @brief A registry for UI commands to avoid long if-else chains. + */ +class CommandRegistry { +public: + using Handler = std::function &)>; + + void register_command(std::string name, std::string description, Handler handler); + + /** + * @brief Parses and executes a command string. + * @return true if command was found and executed. + */ + bool execute(const std::string &cmd_line); + + /** + * @brief Gets a list of all registered command names and descriptions. + */ + std::map get_commands() const { return descriptions_; } + +private: + std::map handlers_; + std::map descriptions_; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_COMMAND_REGISTRY_H diff --git a/testing/netprof/constants.hh b/testing/netprof/constants.hh new file mode 100644 index 0000000000..1959dcb6b9 --- /dev/null +++ b/testing/netprof/constants.hh @@ -0,0 +1,42 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_CONSTANTS_H +#define C_TOXCORE_TESTING_NETPROF_CONSTANTS_H + +#include + +namespace tox::netprof { + +// Simulation constants. +static constexpr uint64_t kDefaultTickMs = 50; +static constexpr uint16_t kBasePort = 33445; +static constexpr int kMaxBootstrapNodes = 4; + +// Layout hyperparameters. +static constexpr float kDefaultRepulsion = 50.0f; +static constexpr float kDefaultAttraction = 0.1f; +static constexpr float kDefaultIdealLength = 30.0f; +static constexpr float kDefaultFriction = 0.9f; +static constexpr float kDefaultStabilizationThreshold = 0.0001f; + +// Visualization constants. +static constexpr size_t kHistoryBufferSize = 200; +/** @brief Maximum number of ticks to push to history in a single UI update to preserve visual flow. + */ +static constexpr uint32_t kMaxTicksToPushPerUpdate = 40; +static constexpr int kUIRefreshIntervalMs = 100; +static constexpr int kUIFastRefreshIntervalMs = 500; +static constexpr uint64_t kDHTInteractionLifetimeMs = 1000; +static constexpr float kDHTRingRadius = 42.0f; +static constexpr double kEMAAlpha = 0.3; + +// UI Layout constants. +static constexpr int kLogHeight = 6; + +// Nice names for nodes. +static constexpr const char *kNiceNames[] = {"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", + "Grace", "Heidi", "Ivan", "Judy", "Kevin", "Linda", "Mike", "Nancy", "Oscar", "Peggy", + "Quentin", "Rose", "Steve", "Trent", "Ursula", "Victor", "Wendy", "Xavier", "Yvonne", "Zelda"}; +static constexpr size_t kNumNiceNames = sizeof(kNiceNames) / sizeof(kNiceNames[0]); + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_CONSTANTS_H diff --git a/testing/netprof/examples/friends22.json b/testing/netprof/examples/friends22.json new file mode 100644 index 0000000000..a8fc351013 --- /dev/null +++ b/testing/netprof/examples/friends22.json @@ -0,0 +1,272 @@ +{ + "connections": [ + { + "from": 1, + "tcp": false, + "to": 2 + }, + { + "from": 1, + "tcp": false, + "to": 3 + }, + { + "from": 3, + "tcp": false, + "to": 19 + }, + { + "from": 2, + "tcp": false, + "to": 10 + }, + { + "from": 10, + "tcp": false, + "to": 15 + }, + { + "from": 10, + "tcp": false, + "to": 22 + }, + { + "from": 10, + "tcp": false, + "to": 12 + }, + { + "from": 10, + "tcp": false, + "to": 1 + }, + { + "from": 10, + "tcp": false, + "to": 19 + }, + { + "from": 1, + "tcp": false, + "to": 5 + }, + { + "from": 1, + "tcp": false, + "to": 5 + }, + { + "from": 13, + "tcp": false, + "to": 5 + }, + { + "from": 13, + "tcp": false, + "to": 14 + }, + { + "from": 13, + "tcp": false, + "to": 6 + }, + { + "from": 6, + "tcp": false, + "to": 17 + }, + { + "from": 9, + "tcp": false, + "to": 17 + }, + { + "from": 11, + "tcp": false, + "to": 4 + }, + { + "from": 11, + "tcp": false, + "to": 21 + } + ], + "nodes": [ + { + "id": 1, + "name": "Alice", + "pos": [ + 20.0, + 50.0 + ] + }, + { + "id": 2, + "name": "Bob", + "pos": [ + 80.0, + 50.0 + ] + }, + { + "id": 3, + "name": "Charlie", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 4, + "name": "Dave", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 5, + "name": "Eve", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 6, + "name": "Frank", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 7, + "name": "Grace", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 8, + "name": "Heidi", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 9, + "name": "Ivan", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 10, + "name": "Judy", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 11, + "name": "Kevin", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 12, + "name": "Linda", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 13, + "name": "Mike", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 14, + "name": "Nancy", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 15, + "name": "Oscar", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 16, + "name": "Peggy", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 17, + "name": "Quentin", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 18, + "name": "Rose", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 19, + "name": "Steve", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 20, + "name": "Trent", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 21, + "name": "Ursula", + "pos": [ + -1.0, + -1.0 + ] + }, + { + "id": 22, + "name": "Victor", + "pos": [ + -1.0, + -1.0 + ] + } + ] +} diff --git a/testing/netprof/layout_engine.cc b/testing/netprof/layout_engine.cc new file mode 100644 index 0000000000..8bea9d969e --- /dev/null +++ b/testing/netprof/layout_engine.cc @@ -0,0 +1,164 @@ +#include "layout_engine.hh" + +#include +#include + +#ifdef force +#undef force +#endif + +namespace tox::netprof { + +LayoutEngine::LayoutEngine(float width, float height) + : width_(width) + , height_(height) +{ +} + +void LayoutEngine::add_node(uint32_t id, float x, float y, bool fixed) +{ + std::uniform_real_distribution dist_w(0, width_); + std::uniform_real_distribution dist_h(0, height_); + if (x < 0) + x = dist_w(rng_); + if (y < 0) + y = dist_h(rng_); + nodes_[id] = {id, x, y, 0, 0, fixed}; + stabilized_ = false; +} + +void LayoutEngine::remove_node(uint32_t id) +{ + nodes_.erase(id); + links_.erase(std::remove_if(links_.begin(), links_.end(), + [id](const LayoutLink &l) { return l.from == id || l.to == id; }), + links_.end()); + stabilized_ = false; +} + +void LayoutEngine::update_node(uint32_t id, float x, float y, bool fixed) +{ + if (nodes_.count(id)) { + nodes_[id].x = x; + nodes_[id].y = y; + nodes_[id].fixed = fixed; + nodes_[id].vx = 0; + nodes_[id].vy = 0; + stabilized_ = false; + } +} + +void LayoutEngine::add_link(uint32_t from, uint32_t to) +{ + links_.push_back({from, to}); + stabilized_ = false; +} + +void LayoutEngine::remove_link(uint32_t from, uint32_t to) +{ + links_.erase(std::remove_if(links_.begin(), links_.end(), + [from, to](const LayoutLink &l) { + return (l.from == from && l.to == to) || (l.from == to && l.to == from); + }), + links_.end()); + stabilized_ = false; +} + +void LayoutEngine::step(float dt) +{ + if (stabilized_) + return; + + // 1. Repulsion (between all nodes) + for (auto &it1 : nodes_) { + for (const auto &it2 : nodes_) { + if (it1.first == it2.first) + continue; + + float dx = it1.second.x - it2.second.x; + float dy = it1.second.y - it2.second.y; + float dist_sq = dx * dx + dy * dy + 0.01f; + float dist = std::sqrt(dist_sq); + + float force = repulsion_constant_ / dist_sq; + it1.second.vx += (dx / dist) * force * dt; + it1.second.vy += (dy / dist) * force * dt; + } + } + + // 2. Attraction (along links) + for (const auto &link : links_) { + if (nodes_.count(link.from) == 0 || nodes_.count(link.to) == 0) + continue; + + auto &n1 = nodes_[link.from]; + auto &n2 = nodes_[link.to]; + + float dx = n2.x - n1.x; + float dy = n2.y - n1.y; + float dist = std::sqrt(dx * dx + dy * dy + 0.01f); + + float force = attraction_constant_ * (dist - ideal_length_); + n1.vx += (dx / dist) * force * dt; + n1.vy += (dy / dist) * force * dt; + n2.vx -= (dx / dist) * force * dt; + n2.vy -= (dy / dist) * force * dt; + } + + // 3. Central Gravity (pull towards middle) + float cx = width_ / 2.0f; + float cy = height_ / 2.0f; + for (auto &it : nodes_) { + float dx = cx - it.second.x; + float dy = cy - it.second.y; + it.second.vx += dx * 0.01f * dt; + it.second.vy += dy * 0.01f * dt; + } + + // 4. Integration + float total_ke = 0.0f; + std::uniform_real_distribution jitter_dist(-0.005f, 0.005f); + for (auto &it : nodes_) { + if (it.second.fixed) { + it.second.vx = 0; + it.second.vy = 0; + continue; + } + + // Apply a small amount of random jitter to prevent collinearity issues. + it.second.vx += jitter_dist(rng_); + it.second.vy += jitter_dist(rng_); + + it.second.x += it.second.vx * dt; + it.second.y += it.second.vy * dt; + + // Apply friction. + it.second.vx *= friction_; + it.second.vy *= friction_; + + total_ke += it.second.vx * it.second.vx + it.second.vy * it.second.vy; + + // Boundary constraints. + if (it.second.x < 5.0f) { + it.second.x = 5.0f; + it.second.vx = 0; + } else if (it.second.x > width_ - 5.0f) { + it.second.x = width_ - 5.0f; + it.second.vx = 0; + } + + if (it.second.y < 5.0f) { + it.second.y = 5.0f; + it.second.vy = 0; + } else if (it.second.y > height_ - 5.0f) { + it.second.y = height_ - 5.0f; + it.second.vy = 0; + } + } + + if (total_ke < stabilization_threshold_) { + stabilized_ = true; + } +} + +} // namespace tox::netprof diff --git a/testing/netprof/layout_engine.hh b/testing/netprof/layout_engine.hh new file mode 100644 index 0000000000..9894aef12e --- /dev/null +++ b/testing/netprof/layout_engine.hh @@ -0,0 +1,65 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_LAYOUT_ENGINE_H +#define C_TOXCORE_TESTING_NETPROF_LAYOUT_ENGINE_H + +#include +#include +#include +#include + +#include "constants.hh" + +namespace tox::netprof { + +struct LayoutNode { + uint32_t id; + float x, y; + float vx, vy; + bool fixed; +}; + +struct LayoutLink { + uint32_t from; + uint32_t to; +}; + +/** + * @brief Continuous Force-Directed Graph Layout Engine. + */ +class LayoutEngine { +public: + explicit LayoutEngine(float width = 100.0f, float height = 100.0f); + + void add_node(uint32_t id, float x, float y, bool fixed = false); + void remove_node(uint32_t id); + void update_node(uint32_t id, float x, float y, bool fixed); + + void add_link(uint32_t from, uint32_t to); + void remove_link(uint32_t from, uint32_t to); + + /** + * @brief Advance the layout simulation by one tick. + */ + void step(float dt = 0.1f); + + const std::map &nodes() const { return nodes_; } + bool is_stabilized() const { return stabilized_; } + +private: + float width_, height_; + std::map nodes_; + std::vector links_; + + std::minstd_rand rng_{42}; + bool stabilized_ = false; + + // Hyperparameters + float repulsion_constant_ = kDefaultRepulsion; + float attraction_constant_ = kDefaultAttraction; + float ideal_length_ = kDefaultIdealLength; + float friction_ = kDefaultFriction; + float stabilization_threshold_ = kDefaultStabilizationThreshold; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_LAYOUT_ENGINE_H diff --git a/testing/netprof/layout_engine_test.cc b/testing/netprof/layout_engine_test.cc new file mode 100644 index 0000000000..1c13dd3fed --- /dev/null +++ b/testing/netprof/layout_engine_test.cc @@ -0,0 +1,217 @@ +#include "layout_engine.hh" + +#include + +#include + +namespace tox::netprof { + +TEST(LayoutEngineTest, AddAndRemoveNode) +{ + LayoutEngine engine(100, 100); + engine.add_node(1, 50, 50); + EXPECT_EQ(engine.nodes().size(), 1u); + EXPECT_FLOAT_EQ(engine.nodes().at(1).x, 50.0f); + EXPECT_FLOAT_EQ(engine.nodes().at(1).y, 50.0f); + + engine.remove_node(1); + EXPECT_EQ(engine.nodes().size(), 0u); +} + +TEST(LayoutEngineTest, RepulsionForce) +{ + LayoutEngine engine(100, 100); + // Place two nodes close to each other in the center + engine.add_node(1, 50, 50); + engine.add_node(2, 51, 50); + + float initial_dist = 1.0f; + + // Step the simulation + engine.step(0.1f); + + auto &n1 = engine.nodes().at(1); + auto &n2 = engine.nodes().at(2); + + float dx = n2.x - n1.x; + float dy = n2.y - n1.y; + float final_dist = std::sqrt(dx * dx + dy * dy); + + // Nodes must move apart due to repulsion. + EXPECT_GT(final_dist, initial_dist); +} + +TEST(LayoutEngineTest, AttractionForce) +{ + LayoutEngine engine(100, 100); + // Place two nodes far apart and connect them + engine.add_node(1, 10, 10); + engine.add_node(2, 90, 90); + engine.add_link(1, 2); + + float dx_init = 80.0f; + float dy_init = 80.0f; + float initial_dist = std::sqrt(dx_init * dx_init + dy_init * dy_init); + + // Step the simulation multiple times to see the pull + for (int i = 0; i < 10; ++i) + engine.step(0.1f); + + auto &n1 = engine.nodes().at(1); + auto &n2 = engine.nodes().at(2); + + float dx = n2.x - n1.x; + float dy = n2.y - n1.y; + float final_dist = std::sqrt(dx * dx + dy * dy); + + // Nodes must move closer due to attraction. + EXPECT_LT(final_dist, initial_dist); +} + +TEST(LayoutEngineTest, PinningNodes) +{ + LayoutEngine engine(100, 100); + // Add a fixed node and a free node + engine.add_node(1, 50, 50, true); // fixed + engine.add_node(2, 51, 50, false); // free + + engine.step(0.1f); + + auto &n1 = engine.nodes().at(1); + auto &n2 = engine.nodes().at(2); + + // Verify node 1 remains fixed. + EXPECT_FLOAT_EQ(n1.x, 50.0f); + EXPECT_FLOAT_EQ(n1.y, 50.0f); + + // Verify node 2 has moved. + EXPECT_NE(n2.x, 51.0f); +} + +TEST(LayoutEngineTest, Boundaries) +{ + LayoutEngine engine(100, 100); + // Place node near the edge with velocity towards the edge + engine.add_node(1, 2, 50); + engine.update_node(1, 2, 50, false); // Not fixed + + // Step multiple times with a lot of repulsion (simulated by adding another node) + engine.add_node(2, 10, 50); // Will push node 1 to the left + + for (int i = 0; i < 100; ++i) + engine.step(0.5f); + + auto &n1 = engine.nodes().at(1); + // Verify clamping to boundary (min 5.0 as per implementation). + EXPECT_GE(n1.x, 5.0f); +} + +TEST(LayoutEngineTest, TriangleNonCollinear) +{ + LayoutEngine engine(100, 100); + // Create a triangle: 1-2, 2-3, 3-1 + // Start them perfectly collinear + engine.add_node(1, 40, 40); + engine.add_node(2, 60, 40); + engine.add_node(3, 50, 40); // Exactly on the line 1-2 + + engine.add_link(1, 2); + engine.add_link(2, 3); + engine.add_link(3, 1); + + // Run for many steps to let it stabilize + for (int i = 0; i < 500; ++i) { + engine.step(0.1f); + } + + auto &n1 = engine.nodes().at(1); + auto &n2 = engine.nodes().at(2); + auto &n3 = engine.nodes().at(3); + + // Calculate area of triangle (should be non-zero) + float area + = 0.5f * std::abs(n1.x * (n2.y - n3.y) + n2.x * (n3.y - n1.y) + n3.x * (n1.y - n2.y)); + + // Ensure non-zero area, confirming collinearity was broken. + EXPECT_GT(area, 10.0f); +} + +TEST(LayoutEngineTest, ChainNonCollinear) +{ + LayoutEngine engine(100, 100); + // Create a chain: 1-2, 2-3 + engine.add_node(1, 40, 40); + engine.add_node(2, 50, 40); + engine.add_node(3, 60, 40); + + engine.add_link(1, 2); + engine.add_link(2, 3); + + // Run for steps + for (int i = 0; i < 500; ++i) { + engine.step(0.1f); + } + + auto &n1 = engine.nodes().at(1); + auto &n2 = engine.nodes().at(2); + auto &n3 = engine.nodes().at(3); + + float area + = 0.5f * std::abs(n1.x * (n2.y - n3.y) + n2.x * (n3.y - n1.y) + n3.x * (n1.y - n2.y)); + + // Chain might remain nearly collinear, but jitter should prevent exactly zero area. + EXPECT_GT(area, 0.01f); +} + +TEST(LayoutEngineTest, ManyNodesStayInBounds) +{ + const float width = 100.0f; + const float height = 100.0f; + LayoutEngine engine(width, height); + + // Add 100 nodes in the same spot to create massive repulsion + for (uint32_t i = 0; i < 100; ++i) { + engine.add_node(i, 50, 50); + } + + // Run for many steps + for (int i = 0; i < 200; ++i) { + engine.step(0.5f); + } + + for (const auto &kv : engine.nodes()) { + EXPECT_GE(kv.second.x, 0.0f); + EXPECT_LE(kv.second.x, width); + EXPECT_GE(kv.second.y, 0.0f); + EXPECT_LE(kv.second.y, height); + } +} + +TEST(LayoutEngineTest, Stabilization) +{ + LayoutEngine engine(100, 100); + engine.add_node(1, 40, 40); + engine.add_node(2, 60, 60); + engine.add_link(1, 2); + + EXPECT_FALSE(engine.is_stabilized()); + + // Run until it stabilizes or we reach a timeout (many steps) + bool stabilized = false; + for (int i = 0; i < 2000; ++i) { + engine.step(0.1f); + if (engine.is_stabilized()) { + stabilized = true; + break; + } + } + + EXPECT_TRUE(stabilized); + EXPECT_TRUE(engine.is_stabilized()); + + // Verify that adding a node de-stabilizes the layout. + engine.add_node(3, 10, 10); + EXPECT_FALSE(engine.is_stabilized()); +} + +} // namespace tox::netprof diff --git a/testing/netprof/main.cc b/testing/netprof/main.cc new file mode 100644 index 0000000000..81c0d7a5a4 --- /dev/null +++ b/testing/netprof/main.cc @@ -0,0 +1,32 @@ +#include +#include + +#include "app.hh" + +using namespace tox::netprof; + +int main(int argc, char **argv) +{ + bool verbose = false; + bool headless = false; + uint64_t seed = 12345; + std::string load_path; + + for (int i = 1; i < argc; ++i) { + std::string arg = argv[i]; + if (arg == "--headless") { + headless = true; + } else if (arg == "--verbose") { + verbose = true; + } else if (arg == "--seed" && i + 1 < argc) { + seed = std::stoull(argv[++i]); + } else if (arg == "--load" && i + 1 < argc) { + load_path = argv[++i]; + } + } + + NetProfApp app(seed, verbose); + app.run(headless, load_path); + + return 0; +} diff --git a/testing/netprof/model.hh b/testing/netprof/model.hh new file mode 100644 index 0000000000..f51d1227ab --- /dev/null +++ b/testing/netprof/model.hh @@ -0,0 +1,264 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_MODEL_H +#define C_TOXCORE_TESTING_NETPROF_MODEL_H + +#include +#include +#include +#include +#include + +#include "../../toxcore/tox.h" +#include "constants.hh" + +namespace tox::netprof { + +enum class LogLevel { Info, Warn, Error, DHT, Crypto, Conn, Command }; + +/** + * @brief Snapshot of traffic statistics for a single frame. + */ +struct NetProfStats { + struct PacketStats { + uint64_t count_sent = 0; + uint64_t count_recv = 0; + uint64_t bytes_sent = 0; + uint64_t bytes_recv = 0; + }; + + PacketStats total_udp; + PacketStats total_tcp; + + struct DHTStats { + uint16_t num_closelist = 0; + uint16_t num_friends = 0; + uint16_t num_friends_udp = 0; + uint16_t num_friends_tcp = 0; + Tox_Connection connection_status = TOX_CONNECTION_NONE; + } dht; + + struct PerPacket { + uint64_t sent = 0; + uint64_t recv = 0; + }; + std::map udp_packet_stats; + std::map tcp_packet_stats; +}; + +struct NodeInfo { + uint32_t id; + std::string name; + bool is_online = true; + std::vector dht_id; + + // Statistics history (buffers). + std::vector dht_neighbors_history = std::vector(kHistoryBufferSize, 0); + std::vector dht_response_history = std::vector(kHistoryBufferSize, 0); + std::vector bw_in_history = std::vector(kHistoryBufferSize, 0); + std::vector bw_out_history = std::vector(kHistoryBufferSize, 0); + + struct Traffic { + uint64_t sent = 0; + uint64_t recv = 0; + }; + + struct ProtocolKey { + uint8_t protocol; // Tox_Netprof_Packet_Type + uint8_t id; + bool operator<(const ProtocolKey &other) const + { + if (protocol != other.protocol) + return protocol < other.protocol; + return id < other.id; + } + }; + std::map protocol_breakdown; + + struct DHTInfo { + uint16_t num_closelist = 0; + uint16_t num_friends = 0; + uint16_t num_friends_udp = 0; + uint16_t num_friends_tcp = 0; + Tox_Connection connection_status = TOX_CONNECTION_NONE; + } dht; + + // Visual position (synchronized from LayoutEngine). + float x = 0.0f; + float y = 0.0f; + + // Smoothed metrics. + double ema_bw_in = 0.0; + double ema_bw_out = 0.0; + + uint32_t dht_responses_received_this_tick = 0; + + bool is_pinned = false; +}; + +struct LinkInfo { + uint32_t from; + uint32_t to; + bool connected; + int latency_ms; + double packet_loss; + float congestion = 0.0f; +}; + +struct GlobalStats { + uint64_t virtual_time_ms = 0; + double real_time_factor = 0.0; + uint64_t total_packets_sent = 0; + uint64_t total_bytes_sent = 0; + bool paused = true; + std::map protocol_breakdown; +}; + +enum class LayerMode { Normal, TrafficType }; + +struct UIModel { + GlobalStats stats; + std::map nodes; + std::vector links; + + struct InteractionKey { + uint32_t id1; + uint32_t id2; + bool is_discovery; + bool operator<(const InteractionKey &other) const + { + if (id1 != other.id1) + return id1 < other.id1; + if (id2 != other.id2) + return id2 < other.id2; + return is_discovery < other.is_discovery; + } + }; + std::map dht_interactions; + + struct LogEntry { + std::string message; + LogLevel level; + }; + std::vector logs; + std::string log_filter; + + uint32_t selected_node_id = 0; + uint32_t marked_node_id = 0; // Selected node for connection operations. + int cursor_x = 50; + int cursor_y = 50; + bool cursor_mode = false; + bool grab_mode = false; + LayerMode layer_mode = LayerMode::Normal; + + int screen_width = 0; + int screen_height = 0; + bool manual_screen_size = false; + bool fast_mode = false; + + bool show_dht_interactions_physical = false; + bool show_dht_responder_lines = true; + bool show_dht_discovery_lines = true; + bool show_command_palette = false; + std::string command_input; + int command_selected_index = 0; + struct Suggestion { + std::string name; + std::string description; + }; + std::vector command_suggestions; + int command_name_max_width = 15; + int command_description_max_width = 0; +}; + +// UI messages. + +struct MsgTick { + GlobalStats stats; +}; +struct MsgNodeAdded { + uint32_t id; + std::string name; + float x = -1.0f; + float y = -1.0f; + std::vector dht_id; +}; +struct MsgNodeRemoved { + uint32_t id; +}; +struct MsgNodeMoved { + uint32_t id; + float x; + float y; +}; +struct MsgNodePinned { + uint32_t id; + bool pinned; +}; +struct MsgLinkUpdated { + uint32_t from; + uint32_t to; + bool connected; + int latency; + double loss; + float congestion = 0.0f; +}; +struct MsgNodeStats { + uint32_t id; + int bw_in; + int bw_out; + uint16_t dht_nodes; + uint16_t dht_friends; + uint16_t dht_friends_udp; + uint16_t dht_friends_tcp; + Tox_Connection connection_status; + bool is_online; + bool is_pinned; + uint32_t num_ticks; + std::map protocol_breakdown; +}; +struct MsgLog { + std::string message; + LogLevel level = LogLevel::Info; +}; + +struct MsgDHTResponse { + uint32_t receiver_id; + uint32_t responder_id; // 0 if responder is unknown or external. + uint32_t discovered_id; // 0 if discovered node is unknown or external. +}; + +struct MsgReset { }; + +struct MsgResize { + int width; + int height; +}; + +using UIMessage = std::variant; + +// UI commands. + +enum class CmdType { + Quit, + TogglePause, + Step, + AddNode, + MoveNode, + RemoveNode, + ConnectNodes, + DisconnectNodes, + ToggleOffline, + TogglePin, + SaveSnapshot, + LoadSnapshot, + SetSpeed +}; + +struct UICommand { + CmdType type; + std::vector args; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_MODEL_H diff --git a/testing/netprof/model_utils.cc b/testing/netprof/model_utils.cc new file mode 100644 index 0000000000..bc9b95e5c1 --- /dev/null +++ b/testing/netprof/model_utils.cc @@ -0,0 +1,172 @@ +#include "model_utils.hh" + +#include + +#include "../../toxcore/tox_private.h" + +namespace tox::netprof { +namespace { + + void classify_packet(const NodeInfo::ProtocolKey &pk, uint64_t total, uint64_t &dht, + uint64_t &data, uint64_t &onion) + { + Tox_Netprof_Packet_Id id = static_cast(pk.id); + + if (pk.protocol == TOX_NETPROF_PACKET_TYPE_UDP) { + switch (id) { + case TOX_NETPROF_PACKET_ID_ZERO: // Ping Req + case TOX_NETPROF_PACKET_ID_ONE: // Ping Resp + case TOX_NETPROF_PACKET_ID_TWO: // Nodes Req + case TOX_NETPROF_PACKET_ID_FOUR: // Nodes Resp + case TOX_NETPROF_PACKET_ID_ANNOUNCE_REQUEST_OLD: + case TOX_NETPROF_PACKET_ID_ANNOUNCE_RESPONSE_OLD: + case TOX_NETPROF_PACKET_ID_ANNOUNCE_REQUEST: + case TOX_NETPROF_PACKET_ID_ANNOUNCE_RESPONSE: + dht += total; + break; + case TOX_NETPROF_PACKET_ID_CRYPTO_HS: + case TOX_NETPROF_PACKET_ID_CRYPTO_DATA: + case TOX_NETPROF_PACKET_ID_CRYPTO: + data += total; + break; + case TOX_NETPROF_PACKET_ID_ONION_SEND_INITIAL: + case TOX_NETPROF_PACKET_ID_ONION_SEND_1: + case TOX_NETPROF_PACKET_ID_ONION_SEND_2: + case TOX_NETPROF_PACKET_ID_ONION_DATA_REQUEST: + case TOX_NETPROF_PACKET_ID_ONION_DATA_RESPONSE: + case TOX_NETPROF_PACKET_ID_ONION_RECV_3: + case TOX_NETPROF_PACKET_ID_ONION_RECV_2: + case TOX_NETPROF_PACKET_ID_ONION_RECV_1: + onion += total; + break; + case TOX_NETPROF_PACKET_ID_TCP_DISCONNECT: + case TOX_NETPROF_PACKET_ID_TCP_PONG: + case TOX_NETPROF_PACKET_ID_TCP_OOB_SEND: + case TOX_NETPROF_PACKET_ID_TCP_OOB_RECV: + case TOX_NETPROF_PACKET_ID_TCP_ONION_REQUEST: + case TOX_NETPROF_PACKET_ID_TCP_ONION_RESPONSE: + case TOX_NETPROF_PACKET_ID_TCP_FORWARD_REQUEST: + case TOX_NETPROF_PACKET_ID_TCP_FORWARDING: + case TOX_NETPROF_PACKET_ID_TCP_DATA: + case TOX_NETPROF_PACKET_ID_COOKIE_REQUEST: + case TOX_NETPROF_PACKET_ID_COOKIE_RESPONSE: + case TOX_NETPROF_PACKET_ID_LAN_DISCOVERY: + case TOX_NETPROF_PACKET_ID_GC_HANDSHAKE: + case TOX_NETPROF_PACKET_ID_GC_LOSSLESS: + case TOX_NETPROF_PACKET_ID_GC_LOSSY: + case TOX_NETPROF_PACKET_ID_FORWARD_REQUEST: + case TOX_NETPROF_PACKET_ID_FORWARDING: + case TOX_NETPROF_PACKET_ID_FORWARD_REPLY: + case TOX_NETPROF_PACKET_ID_DATA_SEARCH_REQUEST: + case TOX_NETPROF_PACKET_ID_DATA_SEARCH_RESPONSE: + case TOX_NETPROF_PACKET_ID_DATA_RETRIEVE_REQUEST: + case TOX_NETPROF_PACKET_ID_DATA_RETRIEVE_RESPONSE: + case TOX_NETPROF_PACKET_ID_STORE_ANNOUNCE_REQUEST: + case TOX_NETPROF_PACKET_ID_STORE_ANNOUNCE_RESPONSE: + case TOX_NETPROF_PACKET_ID_BOOTSTRAP_INFO: + break; + } + } else { + // TCP (don't need to handle all the items, so cast to int). + switch (static_cast(id)) { + case TOX_NETPROF_PACKET_ID_TCP_ONION_REQUEST: + case TOX_NETPROF_PACKET_ID_TCP_ONION_RESPONSE: + onion += total; + break; + case TOX_NETPROF_PACKET_ID_TCP_DATA: + data += total; + break; + default: + break; + } + } + } + +} // namespace + +TrafficCategory get_dominant_traffic_category(const NodeInfo &node) +{ + uint64_t dht = 0, data = 0, onion = 0; + for (const auto &pk : node.protocol_breakdown) { + uint64_t total = pk.second.sent + pk.second.recv; + classify_packet(pk.first, total, dht, data, onion); + } + + if (dht == 0 && data == 0 && onion == 0) { + return TrafficCategory::None; + } + + if (dht > data && dht > onion) { + return TrafficCategory::DHT; + } + + if (data > dht && data > onion) { + return TrafficCategory::Data; + } + + if (onion > dht && onion > data) { + return TrafficCategory::Onion; + } + + return TrafficCategory::None; +} + +float project_dht_id_to_theta(const std::vector &dht_id) +{ + if (dht_id.size() < 4) { + return 0.0f; + } + // Use first 4 bytes for better entropy than 2 bytes + uint32_t val = (static_cast(dht_id[0]) << 24) + | (static_cast(dht_id[1]) << 16) | (static_cast(dht_id[2]) << 8) + | static_cast(dht_id[3]); + + return static_cast(val) / 4294967296.0f * 2.0f * 3.14159265f; +} + +bool safe_stod(const std::string &s, double &out) +{ + if (s.empty()) + return false; + char *end; + out = std::strtod(s.c_str(), &end); + return *end == '\0'; +} + +bool safe_stof(const std::string &s, float &out) +{ + if (s.empty()) + return false; + char *end; + out = std::strtof(s.c_str(), &end); + return *end == '\0'; +} + +bool safe_stoul(const std::string &s, uint32_t &out) +{ + if (s.empty()) + return false; + if (s[0] == '-' || s[0] == '+') + return false; + char *end; + unsigned long val = std::strtoul(s.c_str(), &end, 10); + if (*end != '\0') + return false; + out = static_cast(val); + return true; +} + +bool case_insensitive_equal(const std::string &a, const std::string &b) +{ + if (a.length() != b.length()) + return false; + for (size_t i = 0; i < a.length(); ++i) { + if (std::tolower(static_cast(a[i])) + != std::tolower(static_cast(b[i]))) { + return false; + } + } + return true; +} + +} // namespace tox::netprof diff --git a/testing/netprof/model_utils.hh b/testing/netprof/model_utils.hh new file mode 100644 index 0000000000..5fa737fe60 --- /dev/null +++ b/testing/netprof/model_utils.hh @@ -0,0 +1,35 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_MODEL_UTILS_H +#define C_TOXCORE_TESTING_NETPROF_MODEL_UTILS_H + +#include "model.hh" + +namespace tox::netprof { + +/** + * @brief Categorizes node traffic for visualization. + */ +enum class TrafficCategory { DHT, Data, Onion, None }; + +/** + * @brief Analyzes node traffic and returns the dominant category. + */ +TrafficCategory get_dominant_traffic_category(const NodeInfo &node); + +/** + * @brief Projects a 32-byte DHT ID into a 0..2*PI theta angle. + */ +float project_dht_id_to_theta(const std::vector &dht_id); + +/** + * @brief Safe numeric parsing functions that don't throw exceptions. + * Returns true if parsing was successful. + */ +bool safe_stod(const std::string &s, double &out); +bool safe_stof(const std::string &s, float &out); +bool safe_stoul(const std::string &s, uint32_t &out); + +bool case_insensitive_equal(const std::string &a, const std::string &b); + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_MODEL_UTILS_H diff --git a/testing/netprof/model_utils_test.cc b/testing/netprof/model_utils_test.cc new file mode 100644 index 0000000000..9799f9699b --- /dev/null +++ b/testing/netprof/model_utils_test.cc @@ -0,0 +1,45 @@ +#include "model_utils.hh" + +#include + +namespace tox::netprof { + +TEST(ModelUtilsTest, SafeSTOD) +{ + double val; + EXPECT_TRUE(safe_stod("123.456", val)); + EXPECT_DOUBLE_EQ(val, 123.456); + + EXPECT_TRUE(safe_stod("-0.5", val)); + EXPECT_DOUBLE_EQ(val, -0.5); + + EXPECT_FALSE(safe_stod("abc", val)); + EXPECT_FALSE(safe_stod("123a", val)); + EXPECT_FALSE(safe_stod("", val)); +} + +TEST(ModelUtilsTest, SafeSTOF) +{ + float val; + EXPECT_TRUE(safe_stof("12.5", val)); + EXPECT_FLOAT_EQ(val, 12.5f); + + EXPECT_FALSE(safe_stof("xyz", val)); +} + +TEST(ModelUtilsTest, SafeSTOUL) +{ + uint32_t val; + EXPECT_TRUE(safe_stoul("42", val)); + EXPECT_EQ(val, 42u); + + EXPECT_TRUE(safe_stoul("0", val)); + EXPECT_EQ(val, 0u); + + // Verify that negative numbers are rejected. + EXPECT_FALSE(safe_stoul("-1", val)); + + EXPECT_FALSE(safe_stoul("abc", val)); +} + +} // namespace tox::netprof diff --git a/testing/netprof/node_wrapper.cc b/testing/netprof/node_wrapper.cc new file mode 100644 index 0000000000..83c300d781 --- /dev/null +++ b/testing/netprof/node_wrapper.cc @@ -0,0 +1,140 @@ +#include "node_wrapper.hh" + +#include + +#include "../../toxcore/tox_options.h" +#include "../../toxcore/tox_private.h" +#include "constants.hh" + +namespace tox::netprof { + +static void log_cb(Tox *tox, Tox_Log_Level level, const char *file, uint32_t line, const char *func, + const char *message, void *user_data) +{ + std::cerr << "[Tox Log] " << file << ":" << line << " (" << func << "): " << message + << std::endl; +} + +NodeWrapper::NodeWrapper(tox::test::Simulation &sim, uint32_t id, std::string name, bool verbose, + float x, float y, bool tcp_only) + : id_(id) + , name_(std::move(name)) + , x_(x) + , y_(y) + , node_(std::make_unique(sim, id)) +{ + // Create Tox with default options + event logging enabled + Tox_Options *options = tox_options_new(nullptr); + tox_options_set_ipv6_enabled(options, false); + tox_options_set_udp_enabled(options, !tcp_only); + + // Set port range + tox_options_set_start_port(options, kBasePort); + tox_options_set_end_port(options, 55555); + + if (verbose) { + tox_options_set_log_callback(options, log_cb); + } + + runner_ = std::make_unique(*node_, options); + tox_options_free(options); + + dht_id_ = runner_->invoke([](Tox *tox) { + std::vector dht_id(TOX_PUBLIC_KEY_SIZE); + tox_self_get_dht_id(tox, dht_id.data()); + return dht_id; + }); +} + +NodeWrapper::~NodeWrapper() = default; + +std::vector> +NodeWrapper::poll_events() +{ + return runner_->poll_events(); +} + +NetProfStats NodeWrapper::get_stats() +{ + return runner_->invoke([](Tox *tox) { + NetProfStats stats; + + // UDP + stats.total_udp.count_sent = tox_netprof_get_packet_total_count( + tox, TOX_NETPROF_PACKET_TYPE_UDP, TOX_NETPROF_DIRECTION_SENT); + stats.total_udp.count_recv = tox_netprof_get_packet_total_count( + tox, TOX_NETPROF_PACKET_TYPE_UDP, TOX_NETPROF_DIRECTION_RECV); + stats.total_udp.bytes_sent = tox_netprof_get_packet_total_bytes( + tox, TOX_NETPROF_PACKET_TYPE_UDP, TOX_NETPROF_DIRECTION_SENT); + stats.total_udp.bytes_recv = tox_netprof_get_packet_total_bytes( + tox, TOX_NETPROF_PACKET_TYPE_UDP, TOX_NETPROF_DIRECTION_RECV); + + // TCP (Aggregated) + stats.total_tcp.count_sent = tox_netprof_get_packet_total_count( + tox, TOX_NETPROF_PACKET_TYPE_TCP, TOX_NETPROF_DIRECTION_SENT); + stats.total_tcp.count_recv = tox_netprof_get_packet_total_count( + tox, TOX_NETPROF_PACKET_TYPE_TCP, TOX_NETPROF_DIRECTION_RECV); + stats.total_tcp.bytes_sent = tox_netprof_get_packet_total_bytes( + tox, TOX_NETPROF_PACKET_TYPE_TCP, TOX_NETPROF_DIRECTION_SENT); + stats.total_tcp.bytes_recv = tox_netprof_get_packet_total_bytes( + tox, TOX_NETPROF_PACKET_TYPE_TCP, TOX_NETPROF_DIRECTION_RECV); + + // DHT + stats.dht.num_closelist = tox_dht_get_num_closelist(tox); + uint32_t num_friends = tox_self_get_friend_list_size(tox); + stats.dht.num_friends = static_cast(num_friends); + stats.dht.connection_status = tox_self_get_connection_status(tox); + + for (uint32_t i = 0; i < num_friends; ++i) { + Tox_Connection status = tox_friend_get_connection_status(tox, i, nullptr); + if (status == TOX_CONNECTION_UDP) { + stats.dht.num_friends_udp++; + } else if (status == TOX_CONNECTION_TCP) { + stats.dht.num_friends_tcp++; + } + } + + for (uint16_t id = 0; id < 256; ++id) { + uint8_t id8 = static_cast(id); + + uint64_t bytes_sent_udp = tox_netprof_get_packet_id_bytes( + tox, TOX_NETPROF_PACKET_TYPE_UDP, id8, TOX_NETPROF_DIRECTION_SENT); + uint64_t bytes_recv_udp = tox_netprof_get_packet_id_bytes( + tox, TOX_NETPROF_PACKET_TYPE_UDP, id8, TOX_NETPROF_DIRECTION_RECV); + if (bytes_sent_udp > 0 || bytes_recv_udp > 0) { + stats.udp_packet_stats[id8] = {bytes_sent_udp, bytes_recv_udp}; + } + + uint64_t bytes_sent_tcp = tox_netprof_get_packet_id_bytes( + tox, TOX_NETPROF_PACKET_TYPE_TCP, id8, TOX_NETPROF_DIRECTION_SENT); + uint64_t bytes_recv_tcp = tox_netprof_get_packet_id_bytes( + tox, TOX_NETPROF_PACKET_TYPE_TCP, id8, TOX_NETPROF_DIRECTION_RECV); + if (bytes_sent_tcp > 0 || bytes_recv_tcp > 0) { + stats.tcp_packet_stats[id8] = {bytes_sent_tcp, bytes_recv_tcp}; + } + } + + return stats; + }); +} + +void NodeWrapper::send_message(uint32_t friend_number, const std::string &msg) +{ + runner_->execute([friend_number, msg](Tox *tox) { + tox_friend_send_message(tox, friend_number, TOX_MESSAGE_TYPE_NORMAL, + reinterpret_cast(msg.data()), msg.size(), nullptr); + }); +} + +void NodeWrapper::set_online(bool online) +{ + if (online) { + runner_->resume(); + } else { + runner_->pause(); + } +} + +bool NodeWrapper::is_online() const { return runner_->is_active(); } + +} // namespace tox::netprof diff --git a/testing/netprof/node_wrapper.hh b/testing/netprof/node_wrapper.hh new file mode 100644 index 0000000000..84023fb9f4 --- /dev/null +++ b/testing/netprof/node_wrapper.hh @@ -0,0 +1,74 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_NODE_WRAPPER_H +#define C_TOXCORE_TESTING_NETPROF_NODE_WRAPPER_H + +#include +#include +#include +#include + +#include "../../toxcore/tox.h" +#include "../support/public/simulation.hh" +#include "../support/public/tox_runner.hh" +#include "model.hh" + +namespace tox::netprof { + +/** + * @brief Wraps a SimulatedNode and its ToxRunner for the UI. + * Buffers events and provides thread-safe access to stats. + */ +class NodeWrapper { +public: + NodeWrapper(tox::test::Simulation &sim, uint32_t id, std::string name, bool verbose, + float x = -1.0f, float y = -1.0f, bool tcp_only = false); + ~NodeWrapper(); + + uint32_t id() const { return id_; } + const std::string &name() const { return name_; } + + float x() const { return x_; } + float y() const { return y_; } + void set_pos(float x, float y) + { + x_ = x; + y_ = y; + } + + bool is_pinned() const { return pinned_; } + void set_pinned(bool pinned) { pinned_ = pinned; } + + // Thread-safe access to Tox events + std::vector> poll_events(); + + // Snapshot current statistics + NetProfStats get_stats(); + + // Get DHT public key + const std::vector &get_dht_id() const { return dht_id_; } + + // Basic Tox Actions + void send_message(uint32_t friend_number, const std::string &msg); + void set_online(bool online); + bool is_online() const; + + // Accessors + tox::test::SimulatedNode &node() { return *node_; } + tox::test::ToxRunner &runner() { return *runner_; } + Tox *unsafe_tox() { return runner_->unsafe_tox(); } // Use with care! + +private: + uint32_t id_; + std::string name_; + float x_ = -1.0f; + float y_ = -1.0f; + bool pinned_ = false; + + std::vector dht_id_; + + std::unique_ptr node_; + std::unique_ptr runner_; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_NODE_WRAPPER_H diff --git a/testing/netprof/node_wrapper_test.cc b/testing/netprof/node_wrapper_test.cc new file mode 100644 index 0000000000..bb4e31c2cc --- /dev/null +++ b/testing/netprof/node_wrapper_test.cc @@ -0,0 +1,66 @@ +#include "node_wrapper.hh" + +#include + +#include "../support/public/simulation.hh" + +namespace tox::netprof { + +class NodeWrapperTest : public ::testing::Test { +protected: + ~NodeWrapperTest() override; + tox::test::Simulation sim_{12345}; +}; + +NodeWrapperTest::~NodeWrapperTest() = default; + +TEST_F(NodeWrapperTest, Identity) +{ + NodeWrapper alice(sim_, 1, "Alice", false, 10.0f, 20.0f); + EXPECT_EQ(alice.id(), 1u); + EXPECT_EQ(alice.name(), "Alice"); + EXPECT_FLOAT_EQ(alice.x(), 10.0f); + EXPECT_FLOAT_EQ(alice.y(), 20.0f); +} + +TEST_F(NodeWrapperTest, Movement) +{ + NodeWrapper alice(sim_, 1, "Alice", false); + alice.set_pos(30.0f, 40.0f); + EXPECT_FLOAT_EQ(alice.x(), 30.0f); + EXPECT_FLOAT_EQ(alice.y(), 40.0f); +} + +TEST_F(NodeWrapperTest, OnlineStatus) +{ + NodeWrapper alice(sim_, 1, "Alice", false); + EXPECT_TRUE(alice.is_online()); + + alice.set_online(false); + EXPECT_FALSE(alice.is_online()); + + alice.set_online(true); + EXPECT_TRUE(alice.is_online()); +} + +TEST_F(NodeWrapperTest, InitialStats) +{ + NodeWrapper alice(sim_, 1, "Alice", false); + auto stats = alice.get_stats(); + + EXPECT_EQ(stats.total_udp.count_sent, 0u); + EXPECT_EQ(stats.total_udp.count_recv, 0u); + EXPECT_EQ(stats.total_tcp.count_sent, 0u); + EXPECT_EQ(stats.total_tcp.count_recv, 0u); + EXPECT_EQ(stats.dht.num_closelist, 0u); + EXPECT_EQ(stats.dht.connection_status, TOX_CONNECTION_NONE); +} + +TEST_F(NodeWrapperTest, DHTId) +{ + NodeWrapper alice(sim_, 1, "Alice", false); + auto dht_id = alice.get_dht_id(); + EXPECT_EQ(dht_id.size(), TOX_PUBLIC_KEY_SIZE); +} + +} // namespace tox::netprof diff --git a/testing/netprof/packet_utils.cc b/testing/netprof/packet_utils.cc new file mode 100644 index 0000000000..2ca73c231e --- /dev/null +++ b/testing/netprof/packet_utils.cc @@ -0,0 +1,119 @@ +#include "packet_utils.hh" + +#include + +#include "../../toxcore/tox_private.h" + +namespace tox::netprof { + +std::string get_packet_name(Tox_Netprof_Packet_Type protocol, uint8_t id) +{ + switch (static_cast(id)) { + case TOX_NETPROF_PACKET_ID_ZERO: + return (protocol == TOX_NETPROF_PACKET_TYPE_UDP) ? "Ping Req" : "Routing Req"; + case TOX_NETPROF_PACKET_ID_ONE: + return (protocol == TOX_NETPROF_PACKET_TYPE_UDP) ? "Ping Resp" : "Routing Resp"; + case TOX_NETPROF_PACKET_ID_TWO: + return (protocol == TOX_NETPROF_PACKET_TYPE_UDP) ? "Nodes Req" : "Conn Notification"; + case TOX_NETPROF_PACKET_ID_TCP_DISCONNECT: + return "TCP Disconnect"; + case TOX_NETPROF_PACKET_ID_FOUR: + return (protocol == TOX_NETPROF_PACKET_TYPE_UDP) ? "Nodes Resp" : "Ping (TCP)"; + case TOX_NETPROF_PACKET_ID_TCP_PONG: + return "TCP Pong"; + case TOX_NETPROF_PACKET_ID_TCP_OOB_SEND: + return "TCP OOB Send"; + case TOX_NETPROF_PACKET_ID_TCP_OOB_RECV: + return "TCP OOB Recv"; + case TOX_NETPROF_PACKET_ID_TCP_ONION_REQUEST: + return "TCP Onion Req"; + case TOX_NETPROF_PACKET_ID_TCP_ONION_RESPONSE: + return "TCP Onion Resp"; + case TOX_NETPROF_PACKET_ID_TCP_FORWARD_REQUEST: + return "TCP Forward Req"; + case TOX_NETPROF_PACKET_ID_TCP_FORWARDING: + return "TCP Forwarding"; + case TOX_NETPROF_PACKET_ID_TCP_DATA: + return (protocol == TOX_NETPROF_PACKET_TYPE_TCP) ? "TCP Data (Conn 0)" : "UDP Range 16-255"; + case TOX_NETPROF_PACKET_ID_COOKIE_REQUEST: + return "Cookie Req"; + case TOX_NETPROF_PACKET_ID_COOKIE_RESPONSE: + return "Cookie Resp"; + case TOX_NETPROF_PACKET_ID_CRYPTO_HS: + return "Crypto HS"; + case TOX_NETPROF_PACKET_ID_CRYPTO_DATA: + return "Crypto Data"; + case TOX_NETPROF_PACKET_ID_CRYPTO: + return "Encrypted Data"; + case TOX_NETPROF_PACKET_ID_LAN_DISCOVERY: + return "LAN Discovery"; + case TOX_NETPROF_PACKET_ID_GC_HANDSHAKE: + return "GC Handshake"; + case TOX_NETPROF_PACKET_ID_GC_LOSSLESS: + return "GC Lossless"; + case TOX_NETPROF_PACKET_ID_GC_LOSSY: + return "GC Lossy"; + case TOX_NETPROF_PACKET_ID_ONION_SEND_INITIAL: + return "Onion Send Init"; + case TOX_NETPROF_PACKET_ID_ONION_SEND_1: + return "Onion Send 1"; + case TOX_NETPROF_PACKET_ID_ONION_SEND_2: + return "Onion Send 2"; + case TOX_NETPROF_PACKET_ID_ANNOUNCE_REQUEST_OLD: + return "Announce Req (Old)"; + case TOX_NETPROF_PACKET_ID_ANNOUNCE_RESPONSE_OLD: + return "Announce Resp (Old)"; + case TOX_NETPROF_PACKET_ID_ONION_DATA_REQUEST: + return "Onion Data Req"; + case TOX_NETPROF_PACKET_ID_ONION_DATA_RESPONSE: + return "Onion Data Resp"; + case TOX_NETPROF_PACKET_ID_ANNOUNCE_REQUEST: + return "Announce Req"; + case TOX_NETPROF_PACKET_ID_ANNOUNCE_RESPONSE: + return "Announce Resp"; + case TOX_NETPROF_PACKET_ID_ONION_RECV_3: + return "Onion Recv 3"; + case TOX_NETPROF_PACKET_ID_ONION_RECV_2: + return "Onion Recv 2"; + case TOX_NETPROF_PACKET_ID_ONION_RECV_1: + return "Onion Recv 1"; + case TOX_NETPROF_PACKET_ID_FORWARD_REQUEST: + return "Forward Req"; + case TOX_NETPROF_PACKET_ID_FORWARDING: + return "Forwarding"; + case TOX_NETPROF_PACKET_ID_FORWARD_REPLY: + return "Forward Reply"; + case TOX_NETPROF_PACKET_ID_DATA_SEARCH_REQUEST: + return "Data Search Req"; + case TOX_NETPROF_PACKET_ID_DATA_SEARCH_RESPONSE: + return "Data Search Resp"; + case TOX_NETPROF_PACKET_ID_DATA_RETRIEVE_REQUEST: + return "Data Retrieve Req"; + case TOX_NETPROF_PACKET_ID_DATA_RETRIEVE_RESPONSE: + return "Data Retrieve Resp"; + case TOX_NETPROF_PACKET_ID_STORE_ANNOUNCE_REQUEST: + return "Store Announce Req"; + case TOX_NETPROF_PACKET_ID_STORE_ANNOUNCE_RESPONSE: + return "Store Announce Resp"; + case TOX_NETPROF_PACKET_ID_BOOTSTRAP_INFO: + return "Bootstrap Info"; + } + + if (protocol == TOX_NETPROF_PACKET_TYPE_TCP && id >= 16) { + return "TCP Conn " + std::to_string(id - 16); + } + + const char *name_ptr = tox_netprof_packet_id_to_string(static_cast(id)); + if (!name_ptr || std::string(name_ptr).find(" + +#include "../../toxcore/tox_private.h" + +namespace tox::netprof { + +/** + * @brief Translates a low-level Tox packet ID into a human-readable string. + * + * @param protocol The protocol type (UDP, TCP, etc.) + * @param id The 1-byte packet ID. + * @return A descriptive name for the packet type. + */ +std::string get_packet_name(Tox_Netprof_Packet_Type protocol, uint8_t id); + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_PACKET_UTILS_H diff --git a/testing/netprof/simulation_manager.cc b/testing/netprof/simulation_manager.cc new file mode 100644 index 0000000000..50f6ec9be8 --- /dev/null +++ b/testing/netprof/simulation_manager.cc @@ -0,0 +1,349 @@ +#include "simulation_manager.hh" + +#include +#include +#include +#include + +#include "../../toxcore/tox_options.h" +#include "../../toxcore/tox_private.h" +#include "constants.hh" +#include "model_utils.hh" + +namespace tox::netprof { + +// --- SimulationManager Implementation --- + +SimulationManager::SimulationManager(uint64_t seed, bool verbose) + : seed_(seed) + , sim_(seed) + , verbose_(verbose) +{ + if (verbose_) { + sim_.net().set_verbose(true); + } + + sim_.net().add_observer([this](const tox::test::Packet &p) { + total_packets_sent_++; + total_bytes_sent_ += p.data.size(); + }); +} +SimulationManager::~SimulationManager() = default; + +void SimulationManager::start() +{ + // TODO(iphydf): Implement background thread runner if required. + // The UI loop currently drives the 'step' function. + running_ = true; +} + +void SimulationManager::stop() { running_ = false; } + +void SimulationManager::step(uint64_t ms) +{ + sim_.run_until([] { return false; }, ms); +} + +uint64_t SimulationManager::get_virtual_time_ms() const { return sim_.clock().current_time_ms(); } + +bool SimulationManager::is_running() const { return running_; } + +std::shared_ptr SimulationManager::add_node( + std::string name, float x, float y, bool tcp_only) +{ + std::lock_guard lock(nodes_mutex_); + + // Find unique ID (starting at 1) + uint32_t id = 1; + while (std::any_of( + nodes_.begin(), nodes_.end(), [id](const auto &node) { return node->id() == id; })) { + id++; + } + + auto wrapper + = std::make_shared(sim_, id, std::move(name), verbose_, x, y, tcp_only); + auto ptr = wrapper; + + // Bootstrap against up to 4 other nodes + std::vector> others = nodes_; + std::mt19937 g(seed_ + nodes_.size()); + std::shuffle(others.begin(), others.end(), g); + + int count = 0; + for (const auto &other : others) { + if (count >= kMaxBootstrapNodes) + break; + + auto dht_id = other->runner().invoke([](Tox *t) { + std::vector res(TOX_PUBLIC_KEY_SIZE); + tox_self_get_dht_id(t, res.data()); + return res; + }); + + auto *socket = other->node().get_primary_socket(); + if (socket) { + Ip_Ntoa ip_str; + net_ip_ntoa(&other->node().ip, &ip_str); + std::string ip(ip_str.buf); + uint16_t port = socket->local_port(); + + ptr->runner().execute([dht_id, port, ip](Tox *t) { + tox_bootstrap(t, ip.c_str(), port, dht_id.data(), nullptr); + tox_add_tcp_relay(t, ip.c_str(), port, dht_id.data(), nullptr); + }); + count++; + } + } + + dht_id_to_node_[ptr->get_dht_id()] = ptr; + nodes_.push_back(std::move(wrapper)); + + return ptr; +} + +void SimulationManager::remove_node(uint32_t id) +{ + std::lock_guard lock(nodes_mutex_); + for (auto it = nodes_.begin(); it != nodes_.end(); ++it) { + if ((*it)->id() == id) { + dht_id_to_node_.erase((*it)->get_dht_id()); + nodes_.erase(it); + + // Remove connections involving this node + connections_.erase( + std::remove_if(connections_.begin(), connections_.end(), + [id](const ConnectionIntent &c) { return c.node_a == id || c.node_b == id; }), + connections_.end()); + return; + } + } +} + +std::shared_ptr SimulationManager::get_node(uint32_t id) +{ + std::lock_guard lock(nodes_mutex_); + for (const auto &node : nodes_) { + if (node->id() == id) { + return node; + } + } + return nullptr; +} + +std::shared_ptr SimulationManager::get_node_by_name(const std::string &name) const +{ + std::lock_guard lock(nodes_mutex_); + for (const auto &node : nodes_) { + if (case_insensitive_equal(node->name(), name)) { + return node; + } + } + return nullptr; +} + +std::shared_ptr SimulationManager::get_node_by_dht_id(const uint8_t *dht_id) +{ + std::lock_guard lock(nodes_mutex_); + std::vector key(dht_id, dht_id + TOX_PUBLIC_KEY_SIZE); + auto it = dht_id_to_node_.find(key); + if (it != dht_id_to_node_.end()) { + return it->second; + } + return nullptr; +} + +std::vector> SimulationManager::get_nodes() const +{ + std::lock_guard lock(nodes_mutex_); + return nodes_; +} + +bool SimulationManager::connect_nodes(uint32_t id_a, uint32_t id_b, bool tcp_only) +{ + std::lock_guard lock(nodes_mutex_); + std::shared_ptr node_a; + std::shared_ptr node_b; + + for (const auto &node : nodes_) { + if (node->id() == id_a) + node_a = node; + if (node->id() == id_b) + node_b = node; + } + + if (!node_a || !node_b) + return false; + + // Record intent + connections_.push_back({id_a, id_b, tcp_only}); + + // Execute connection logic + // 1. Get Address and DHT ID from Node B + auto [address_b, dht_id_b] = node_b->runner().invoke([](Tox *t) { + std::pair, std::vector> res; + res.first.resize(TOX_ADDRESS_SIZE); + tox_self_get_address(t, res.first.data()); + + res.second.resize(TOX_PUBLIC_KEY_SIZE); + tox_self_get_dht_id(t, res.second.data()); + return res; + }); + + // 2. Get Address from Node A + auto address_a = node_a->runner().invoke([](Tox *t) { + std::vector addr(TOX_ADDRESS_SIZE); + tox_self_get_address(t, addr.data()); + return addr; + }); + + // 3. Exchange Friend Requests + node_a->runner().execute( + [address_b](Tox *t) { tox_friend_add_norequest(t, address_b.data(), nullptr); }); + + node_b->runner().execute( + [address_a](Tox *t) { tox_friend_add_norequest(t, address_a.data(), nullptr); }); + + // 4. Bootstrap A to B + auto *socket = node_b->node().get_primary_socket(); + if (socket) { + uint16_t port = socket->local_port(); + Ip_Ntoa ip_str; + net_ip_ntoa(&node_b->node().ip, &ip_str); + std::string ip(ip_str.buf); + + node_a->runner().execute([dht_id_b, port, ip](Tox *t) { + tox_bootstrap(t, ip.c_str(), port, dht_id_b.data(), nullptr); + tox_add_tcp_relay(t, ip.c_str(), port, dht_id_b.data(), nullptr); + }); + } + + return true; +} + +bool SimulationManager::disconnect_nodes(uint32_t id_a, uint32_t id_b) +{ + std::lock_guard lock(nodes_mutex_); + std::shared_ptr node_a; + std::shared_ptr node_b; + + for (const auto &node : nodes_) { + if (node->id() == id_a) + node_a = node; + if (node->id() == id_b) + node_b = node; + } + + if (!node_a || !node_b) + return false; + + // 1. Get Public Keys + auto pk_a = node_a->runner().invoke([](Tox *t) { + std::vector pk(TOX_PUBLIC_KEY_SIZE); + tox_self_get_public_key(t, pk.data()); + return pk; + }); + + auto pk_b = node_b->runner().invoke([](Tox *t) { + std::vector pk(TOX_PUBLIC_KEY_SIZE); + tox_self_get_public_key(t, pk.data()); + return pk; + }); + + // 2. Remove friends from both sides + node_a->runner().execute([pk_b](Tox *t) { + uint32_t fn = tox_friend_by_public_key(t, pk_b.data(), nullptr); + if (fn != UINT32_MAX) { + tox_friend_delete(t, fn, nullptr); + } + }); + + node_b->runner().execute([pk_a](Tox *t) { + uint32_t fn = tox_friend_by_public_key(t, pk_a.data(), nullptr); + if (fn != UINT32_MAX) { + tox_friend_delete(t, fn, nullptr); + } + }); + + // 3. Update intent + connections_.erase(std::remove_if(connections_.begin(), connections_.end(), + [id_a, id_b](const ConnectionIntent &c) { + return (c.node_a == id_a && c.node_b == id_b) + || (c.node_a == id_b && c.node_b == id_a); + }), + connections_.end()); + + return true; +} + +nlohmann::json SimulationManager::to_json() const +{ + nlohmann::json j; + j["nodes"] = nlohmann::json::array(); + j["connections"] = nlohmann::json::array(); + + std::lock_guard lock(nodes_mutex_); + for (const auto &nw : nodes_) { + j["nodes"].push_back({ + {"id", nw->id()}, + {"name", nw->name()}, + {"pos", {nw->x(), nw->y()}}, + {"pinned", nw->is_pinned()}, + }); + } + + for (const auto &conn : connections_) { + j["connections"].push_back({ + {"from", conn.node_a}, + {"to", conn.node_b}, + {"tcp", conn.tcp_only}, + }); + } + + return j; +} + +void SimulationManager::from_json(const nlohmann::json &j) +{ + std::lock_guard lock(nodes_mutex_); + // Clear existing. Node destruction handles unregistration. + nodes_.clear(); + dht_id_to_node_.clear(); + connections_.clear(); + + if (j.contains("nodes")) { + for (const auto &item : j["nodes"]) { + float x = -1.0f; + float y = -1.0f; + if (item.contains("pos") && item["pos"].is_array() && item["pos"].size() == 2) { + x = item["pos"][0].get(); + y = item["pos"][1].get(); + } + auto n = add_node(item.value("name", "Unnamed"), x, y); + n->set_pinned(item.value("pinned", false)); + } + } + + if (j.contains("connections")) { + for (const auto &item : j["connections"]) { + connect_nodes(item.value("from", 0), item.value("to", 0), item.value("tcp", false)); + } + } +} + +void SimulationManager::save_to_file(const std::string &filename) const +{ + std::ofstream o(filename); + o << std::setw(4) << to_json() << std::endl; +} + +void SimulationManager::load_from_file(const std::string &filename) +{ + std::ifstream i(filename); + if (i.is_open()) { + nlohmann::json j; + i >> j; + from_json(j); + } +} + +} // namespace tox::netprof diff --git a/testing/netprof/simulation_manager.hh b/testing/netprof/simulation_manager.hh new file mode 100644 index 0000000000..b8b23b4757 --- /dev/null +++ b/testing/netprof/simulation_manager.hh @@ -0,0 +1,119 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_SIMULATION_MANAGER_H +#define C_TOXCORE_TESTING_NETPROF_SIMULATION_MANAGER_H + +#include +#include +#include +#include +#include + +#include "../support/public/simulation.hh" +#include "../support/public/tox_network.hh" +#include "../support/public/tox_runner.hh" +#include "constants.hh" +#include "node_wrapper.hh" + +namespace tox::netprof { + +/** + * @brief Manages the lifecycle of the simulation and its nodes. + * Handles persistence (Load/Save) and global simulation control. + */ +class SimulationManager { +public: + explicit SimulationManager(uint64_t seed, bool verbose = false); + ~SimulationManager(); + + // Simulation Control + void start(); + void stop(); + void step(uint64_t ms = kDefaultTickMs); + bool is_running() const; + + // Node Management + std::shared_ptr add_node( + std::string name, float x = -1.0f, float y = -1.0f, bool tcp_only = false); + void remove_node(uint32_t id); + std::shared_ptr get_node(uint32_t id); + std::shared_ptr get_node_by_name(const std::string &name) const; + std::shared_ptr get_node_by_dht_id(const uint8_t *dht_id); + std::vector> get_nodes() const; + + size_t node_count() const + { + std::lock_guard lock(nodes_mutex_); + return nodes_.size(); + } + + template + void for_each_node(F &&f) const + { + std::vector> nodes_copy; + { + std::lock_guard lock(nodes_mutex_); + nodes_copy = nodes_; + } + for (const auto &node : nodes_copy) { + f(*node); + } + } + + // Connection Management + // Returns true if connection was successfully initiated + bool connect_nodes(uint32_t id_a, uint32_t id_b, bool tcp_only = false); + bool disconnect_nodes(uint32_t id_a, uint32_t id_b); + + // Persistence + nlohmann::json to_json() const; + void from_json(const nlohmann::json &j); + + void save_to_file(const std::string &filename) const; + void load_from_file(const std::string &filename); + + // Global Stats + uint64_t get_virtual_time_ms() const; + uint64_t total_packets_sent() const { return total_packets_sent_; } + uint64_t total_bytes_sent() const { return total_bytes_sent_; } + const std::map &global_protocol_breakdown() const + { + return global_protocol_breakdown_; + } + + // Track intended topology for persistence + struct ConnectionIntent { + uint32_t node_a; + uint32_t node_b; + bool tcp_only; + }; + + template + void for_each_connection(F &&f) const + { + std::lock_guard lock(nodes_mutex_); + for (const auto &conn : connections_) { + f(conn); + } + } + + // Access to underlying simulation + tox::test::Simulation &simulation() { return sim_; } + +private: + const uint64_t seed_; + tox::test::Simulation sim_; + std::vector> nodes_; + std::vector connections_; + std::map, std::shared_ptr> dht_id_to_node_; + + mutable std::recursive_mutex nodes_mutex_; + std::atomic running_{false}; + const bool verbose_; + + std::atomic total_packets_sent_{0}; + std::atomic total_bytes_sent_{0}; + std::map global_protocol_breakdown_; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_SIMULATION_MANAGER_H diff --git a/testing/netprof/simulation_manager_test.cc b/testing/netprof/simulation_manager_test.cc new file mode 100644 index 0000000000..a6db0cb358 --- /dev/null +++ b/testing/netprof/simulation_manager_test.cc @@ -0,0 +1,230 @@ +#include "simulation_manager.hh" + +#include + +#include + +namespace tox::netprof { + +class SimulationManagerTest : public ::testing::Test { +protected: + ~SimulationManagerTest() override; + SimulationManager manager_{12345, true}; +}; + +SimulationManagerTest::~SimulationManagerTest() = default; + +TEST_F(SimulationManagerTest, AddAndRemoveNode) +{ + auto alice = manager_.add_node("Alice"); + ASSERT_NE(alice, nullptr); + EXPECT_EQ(alice->name(), "Alice"); + EXPECT_EQ(manager_.get_nodes().size(), 1u); + + uint32_t id = alice->id(); + manager_.remove_node(id); + EXPECT_EQ(manager_.get_nodes().size(), 0u); + EXPECT_EQ(manager_.get_node(id), nullptr); +} + +TEST_F(SimulationManagerTest, GetNodeByName) +{ + manager_.add_node("Alice"); + manager_.add_node("Bob"); + + auto alice = manager_.get_node_by_name("Alice"); + ASSERT_NE(alice, nullptr); + EXPECT_EQ(alice->name(), "Alice"); + + auto bob = manager_.get_node_by_name("Bob"); + ASSERT_NE(bob, nullptr); + EXPECT_EQ(bob->name(), "Bob"); + + EXPECT_EQ(manager_.get_node_by_name("Charlie"), nullptr); +} + +TEST_F(SimulationManagerTest, ConnectNodes) +{ + auto alice = manager_.add_node("Alice"); + auto bob = manager_.add_node("Bob"); + + bool connected = manager_.connect_nodes(alice->id(), bob->id()); + EXPECT_TRUE(connected); +} + +TEST_F(SimulationManagerTest, DisconnectNodes) +{ + auto alice = manager_.add_node("Alice"); + auto bob = manager_.add_node("Bob"); + + manager_.connect_nodes(alice->id(), bob->id()); + manager_.step(100); + + bool disconnected = manager_.disconnect_nodes(alice->id(), bob->id()); + EXPECT_TRUE(disconnected); + + // Verify friend relationships are removed after disconnection. + // Sufficient time is needed for friend deletion to be reflected in statistics. + manager_.step(100); + auto stats_alice = alice->get_stats(); + EXPECT_EQ(stats_alice.dht.num_friends, 0u); +} + +TEST_F(SimulationManagerTest, GlobalStatsTracking) +{ + auto alice = manager_.add_node("Alice"); + auto bob = manager_.add_node("Bob"); + manager_.connect_nodes(alice->id(), bob->id()); + + uint64_t initial_time = manager_.get_virtual_time_ms(); + manager_.step(100); + // Virtual time starts at 1000ms. + // Verify that stepping 100ms advances the virtual clock appropriately. + // The underlying simulation engine steps in minimum increments. + EXPECT_GE(manager_.get_virtual_time_ms(), initial_time + 100); +} + +TEST_F(SimulationManagerTest, Serialization) +{ + manager_.add_node("Alice"); + manager_.add_node("Bob"); + + nlohmann::json j = manager_.to_json(); + EXPECT_TRUE(j.contains("nodes")); + EXPECT_EQ(j["nodes"].size(), 2u); + + SimulationManager manager2{12345}; + manager2.from_json(j); + EXPECT_EQ(manager2.get_nodes().size(), 2u); + EXPECT_EQ(manager2.get_nodes()[0]->name(), "Alice"); + EXPECT_EQ(manager2.get_nodes()[1]->name(), "Bob"); +} + +TEST_F(SimulationManagerTest, AddNodeWithPosition) +{ + auto alice = manager_.add_node("Alice", 10.5f, 20.7f); + ASSERT_NE(alice, nullptr); + EXPECT_FLOAT_EQ(alice->x(), 10.5f); + EXPECT_FLOAT_EQ(alice->y(), 20.7f); +} + +TEST_F(SimulationManagerTest, ProtocolBreakdown) +{ + auto alice = manager_.add_node("Alice"); + manager_.step(100); + + auto stats = alice->get_stats(); + // Verify that packet statistics are initially empty. + EXPECT_TRUE(stats.udp_packet_stats.empty()); + EXPECT_TRUE(stats.tcp_packet_stats.empty()); +} + +TEST_F(SimulationManagerTest, ProtocolBreakdownWithTraffic) +{ + auto alice = manager_.add_node("Alice"); + auto bob = manager_.add_node("Bob"); + manager_.connect_nodes(alice->id(), bob->id()); + + // Allow time for node exchange and DHT pings. + manager_.step(1000); + + auto stats = alice->get_stats(); + // Statistics maps should contain DHT/Ping traffic. + EXPECT_FALSE(stats.udp_packet_stats.empty()); +} + +TEST_F(SimulationManagerTest, SerializationWithPosition) +{ + manager_.add_node("Alice", 1.0f, 2.0f); + + nlohmann::json j = manager_.to_json(); + ASSERT_TRUE(j["nodes"][0].contains("pos")); + EXPECT_FLOAT_EQ(j["nodes"][0]["pos"][0].get(), 1.0f); + EXPECT_FLOAT_EQ(j["nodes"][0]["pos"][1].get(), 2.0f); + + SimulationManager manager2{12345}; + manager2.from_json(j); + ASSERT_EQ(manager2.get_nodes().size(), 1u); + EXPECT_FLOAT_EQ(manager2.get_nodes()[0]->x(), 1.0f); + EXPECT_FLOAT_EQ(manager2.get_nodes()[0]->y(), 2.0f); +} + +TEST_F(SimulationManagerTest, SaveLoadFile) +{ + manager_.add_node("Alice", 10.0f, 20.0f); + manager_.add_node("Bob", 30.0f, 40.0f); + manager_.connect_nodes(1, 2); + + const std::string filename = "test_save.json"; + manager_.save_to_file(filename); + + SimulationManager manager2{12345}; + manager2.load_from_file(filename); + + ASSERT_EQ(manager2.get_nodes().size(), 2u); + EXPECT_EQ(manager2.get_nodes()[0]->name(), "Alice"); + EXPECT_FLOAT_EQ(manager2.get_nodes()[0]->x(), 10.0f); + EXPECT_EQ(manager2.get_nodes()[1]->name(), "Bob"); + EXPECT_FLOAT_EQ(manager2.get_nodes()[1]->y(), 40.0f); + + // Clean up + std::remove(filename.c_str()); +} + +TEST_F(SimulationManagerTest, AutoBootstrapping) +{ + auto alice = manager_.add_node("Alice"); + manager_.step(100); + + auto bob = manager_.add_node("Bob"); + // Verify automatic bootstrapping of Node B against existing Node A. + + // Allow sufficient time for node exchange and DHT activity. + manager_.step(5000); + + auto stats_bob = bob->get_stats(); + // Node Bob should discover Node Alice in its DHT close list. + EXPECT_GT(stats_bob.dht.num_closelist, 0u); +} + +TEST_F(SimulationManagerTest, LoadSnapshotDoesNotCorruptRunnerCount) +{ + // 1. Add some nodes. + manager_.add_node("Alice"); + manager_.add_node("Bob"); + + // 2. Load a snapshot (this triggers from_json). + nlohmann::json j = manager_.to_json(); + manager_.from_json(j); + + // 3. Load it again to be sure. + manager_.from_json(j); + + // 4. Try to step the simulation. If the runner count is corrupted, this will freeze. + // We use a small timeout to ensure the test fails instead of hanging indefinitely if broken. + manager_.step(100); +} + +TEST_F(SimulationManagerTest, PinnedStateSerialization) +{ + auto alice = manager_.add_node("Alice"); + alice->set_pinned(true); + + auto bob = manager_.add_node("Bob"); + bob->set_pinned(false); + + nlohmann::json j = manager_.to_json(); + + SimulationManager manager2{12345}; + manager2.from_json(j); + + auto alice2 = manager2.get_node_by_name("Alice"); + ASSERT_NE(alice2, nullptr); + EXPECT_TRUE(alice2->is_pinned()); + + auto bob2 = manager2.get_node_by_name("Bob"); + ASSERT_NE(bob2, nullptr); + EXPECT_FALSE(bob2->is_pinned()); +} + +} // namespace tox::netprof diff --git a/testing/netprof/ui.cc b/testing/netprof/ui.cc new file mode 100644 index 0000000000..9dafa10e09 --- /dev/null +++ b/testing/netprof/ui.cc @@ -0,0 +1,984 @@ +#include "ui.hh" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "constants.hh" + +namespace tox::netprof { + +using namespace ftxui; + +NetProfUI::NetProfUI(CommandCallback on_command) + : on_command_(std::move(on_command)) + , screen_(ScreenInteractive::Fullscreen()) +{ + register_commands(); + + command_palette_ = views::command_palette( + model_, + [this] { + if (!model_.command_suggestions.empty() && model_.command_selected_index >= 0 + && model_.command_selected_index + < static_cast(model_.command_suggestions.size())) { + model_.command_input + = model_.command_suggestions[model_.command_selected_index].name; + } + this->execute_command(model_.command_input); + model_.show_command_palette = false; + main_stack_index_ = 0; + model_.command_input = ""; + }, + [this] { this->update_command_suggestions(); }); + + hud_comp_ = views::hud(model_); + topology_comp_ = views::topology(model_); + dht_topology_comp_ = views::dht_topology(model_); + inspector_comp_ = views::inspector(model_); + event_log_comp_ = views::event_log(model_); + command_log_comp_ = views::command_log(model_); + bottom_bar_comp_ = views::bottom_bar(model_); + dht_filter_controls_ = views::dht_filter(model_); + + col1_comp_ = Container::Vertical({topology_comp_, command_log_comp_}); + col2_comp_ = Container::Vertical({dht_topology_comp_, dht_filter_controls_}); + col3_comp_ = Container::Vertical({inspector_comp_, event_log_comp_}); + + // Container for focus-managed interactive components. + interactive_container_ = Container::Horizontal({ + col1_comp_, + col2_comp_, + col3_comp_, + }); + + // Main application view renderer. + auto main_renderer = Renderer(interactive_container_, [this]() -> Element { + // Process any messages queued before UI activation. + this->process_messages(); + + { + std::lock_guard lock(ui_mutex_); + ui_active_ = true; + } + + if (!model_.manual_screen_size) { + model_.screen_width = ftxui::Terminal::Size().dimx; + model_.screen_height = ftxui::Terminal::Size().dimy; + } + + auto topo_header = text(" ๐Ÿ—๏ธ PHYSICAL TOPOLOGY ") | bold | hcenter; + if (topology_comp_->Focused()) + topo_header |= bgcolor(Color::Blue); + + auto cmd_log_header = text(" ๐Ÿ“‹ COMMAND LOG ") | bold | hcenter; + if (command_log_comp_->Focused()) + cmd_log_header |= bgcolor(Color::Blue); + + auto dht_topo_header = text(" ๐Ÿ•ธ๏ธ DHT TOPOLOGY (Kademlia Ring) ") | bold | hcenter; + if (dht_topology_comp_->Focused()) + dht_topo_header |= bgcolor(Color::Blue); + + auto dht_header = text(" ๐Ÿ” DHT RING FILTERS ") | bold | hcenter; + if (dht_filter_controls_->Focused()) + dht_header |= bgcolor(Color::Blue); + + auto inspector_header = text(" ๐Ÿ”Ž NODE INSPECTOR ") | bold | hcenter; + if (inspector_comp_->Focused()) + inspector_header |= bgcolor(Color::Blue); + + auto event_log_header = text(" ๐Ÿ“ EVENT LOG ") | bold | hcenter; + if (event_log_comp_->Focused()) + event_log_header |= bgcolor(Color::Blue); + + auto col1_render = vbox({ + topo_header, + topology_comp_->Render() | flex, + separator(), + cmd_log_header, + command_log_comp_->Render() | size(HEIGHT, EQUAL, kLogHeight), + }) + | flex; + + auto col2_render = vbox({ + dht_topo_header, + dht_topology_comp_->Render() | flex, + separator(), + dht_header, + dht_filter_controls_->Render() | size(HEIGHT, EQUAL, kLogHeight), + }) + | flex; + + auto col3_render = vbox({ + inspector_header, + inspector_comp_->Render() | flex, + separator(), + event_log_header, + event_log_comp_->Render() | size(HEIGHT, EQUAL, kLogHeight), + }) + | flex; + + if (model_.fast_mode) { + return vbox({ + hud_comp_->Render(), + separator(), + vbox({ + filler(), + text(" FAST RENDERING MODE ENABLED ") | bold | hcenter + | color(Color::Yellow), + text(" All data is still being recorded. Press 'F' to restore full " + "view. ") + | hcenter | color(Color::GrayLight), + filler(), + }) | flex, + bottom_bar_comp_->Render() | size(HEIGHT, EQUAL, 1), + }) + | border | flex; + } + + return vbox({ + vbox({ + hud_comp_->Render(), + separator(), + hbox({ + col1_render, + separator(), + col2_render, + separator(), + col3_render, + }) | flex, + }) | border + | flex, + bottom_bar_comp_->Render() | size(HEIGHT, EQUAL, 1), + }); + }); + + // Stack comprising the main view and the modal command palette. + main_stack_ = Container::Tab( + { + main_renderer, + command_palette_, + }, + &main_stack_index_); + + main_container_ = Renderer(main_stack_, [this, main_renderer] { + if (model_.show_command_palette) { + return dbox({ + main_renderer->Render(), + command_palette_->Render() | center, + }); + } + return main_renderer->Render(); + }); + + // Global input event handling. + main_container_ |= CatchEvent([this](Event event) { return this->handle_event(event); }); +} + +bool NetProfUI::handle_command_palette_event(Event event) +{ + if (event == Event::Escape) { + model_.show_command_palette = false; + main_stack_index_ = 0; + return true; + } + + if (event == Event::ArrowUp) { + if (!model_.command_suggestions.empty()) { + model_.command_selected_index + = (model_.command_selected_index - 1 + model_.command_suggestions.size()) + % model_.command_suggestions.size(); + } + return true; + } + if (event == Event::ArrowDown) { + if (!model_.command_suggestions.empty()) { + model_.command_selected_index + = (model_.command_selected_index + 1) % model_.command_suggestions.size(); + } + return true; + } + + if (event == Event::Tab) { + if (!model_.command_suggestions.empty() && model_.command_selected_index >= 0 + && model_.command_selected_index + < static_cast(model_.command_suggestions.size())) { + model_.command_input = model_.command_suggestions[model_.command_selected_index].name; + update_command_suggestions(); + command_palette_->OnEvent(Event::End); + } + return true; + } + + return false; // Allow the input component to process the event. +} + +bool NetProfUI::handle_tab_navigation(Event event) const +{ + if (event != Event::Tab && event != Event::TabReverse) { + return false; + } + + std::vector components = { + topology_comp_, + command_log_comp_, + dht_topology_comp_, + dht_filter_controls_, + inspector_comp_, + event_log_comp_, + }; + + int current = -1; + for (int i = 0; i < static_cast(components.size()); ++i) { + if (components[i]->Focused()) { + current = i; + break; + } + } + + if (current != -1) { + int dir = (event == Event::Tab) ? 1 : -1; + int count = static_cast(components.size()); + int next = (current + dir + count) % count; + components[next]->TakeFocus(); + return true; + } + + return false; +} + +bool NetProfUI::handle_global_hotkeys(Event event) +{ + if (!event.is_character()) { + return false; + } + + char c = event.character()[0]; + switch (c) { + case 'q': + on_command_({CmdType::Quit}); + screen_.ExitLoopClosure()(); + return true; + case ' ': + on_command_({CmdType::TogglePause}); + return true; + case 's': + on_command_({CmdType::Step}); + return true; + case ':': + model_.show_command_palette = true; + main_stack_index_ = 1; + model_.command_input = ""; + model_.command_selected_index = 0; + update_command_suggestions(); + return true; + case 'S': + on_command_({CmdType::SaveSnapshot}); + return true; + case 'L': + on_command_({CmdType::LoadSnapshot}); + return true; + case '+': + if (model_.stats.real_time_factor > 0.0 && model_.stats.real_time_factor < 10.0) { + on_command_({CmdType::SetSpeed, {std::to_string(model_.stats.real_time_factor + 0.5)}}); + } else if (model_.stats.real_time_factor >= 10.0) { + on_command_({CmdType::SetSpeed, {"0.0"}}); // Max speed + } + return true; + case '=': + on_command_({CmdType::SetSpeed, {"1.0"}}); + return true; + case '-': + if (model_.stats.real_time_factor <= 0.0) { + on_command_({CmdType::SetSpeed, {"10.0"}}); + } else if (model_.stats.real_time_factor > 0.5) { + on_command_({CmdType::SetSpeed, {std::to_string(model_.stats.real_time_factor - 0.5)}}); + } else if (model_.stats.real_time_factor > 0.15) { + on_command_({CmdType::SetSpeed, {std::to_string(model_.stats.real_time_factor - 0.1)}}); + } + return true; + case 'F': + model_.fast_mode = !model_.fast_mode; + return true; + } + return false; +} + +bool NetProfUI::handle_node_operations(char c) +{ + switch (c) { + case 'v': + execute_command("dht"); + return true; + case 'a': + on_command_({CmdType::AddNode, {}}); + return true; + case 'A': + on_command_({CmdType::AddNode, {"tcp"}}); + return true; + case 'm': + if (model_.selected_node_id != 0) { + on_command_({CmdType::MoveNode, + {std::to_string(model_.selected_node_id), std::to_string(model_.cursor_x), + std::to_string(model_.cursor_y)}}); + } + return true; + case 'd': + if (model_.selected_node_id != 0) { + on_command_({CmdType::RemoveNode, {std::to_string(model_.selected_node_id)}}); + } + return true; + case 'f': + if (model_.selected_node_id != 0) { + if (model_.marked_node_id == 0) { + model_.marked_node_id = model_.selected_node_id; + } else if (model_.marked_node_id != model_.selected_node_id) { + on_command_({CmdType::ConnectNodes, + {std::to_string(model_.marked_node_id), + std::to_string(model_.selected_node_id)}}); + } + } + return true; + case 'u': + if (model_.selected_node_id != 0 && model_.marked_node_id != 0 + && model_.marked_node_id != model_.selected_node_id) { + on_command_({CmdType::DisconnectNodes, + {std::to_string(model_.marked_node_id), std::to_string(model_.selected_node_id)}}); + } + return true; + case 'c': + model_.cursor_mode = !model_.cursor_mode; + if (!model_.cursor_mode) + model_.grab_mode = false; + return true; + case 'g': + if (model_.cursor_mode && model_.selected_node_id != 0) { + model_.grab_mode = !model_.grab_mode; + } + return true; + case 'l': + switch (model_.layer_mode) { + case LayerMode::Normal: + model_.layer_mode = LayerMode::TrafficType; + break; + case LayerMode::TrafficType: + model_.layer_mode = LayerMode::Normal; + break; + } + return true; + case 'o': + if (model_.selected_node_id != 0) { + on_command_({CmdType::ToggleOffline, {std::to_string(model_.selected_node_id)}}); + } + return true; + case 'p': + if (model_.selected_node_id != 0) { + on_command_({CmdType::TogglePin, {std::to_string(model_.selected_node_id)}}); + } + return true; + } + return false; +} + +bool NetProfUI::handle_cursor_movement(Event event) +{ + bool moved = false; + if (event == Event::ArrowUp) { + model_.cursor_y = std::max(0, model_.cursor_y - 2); + moved = true; + } + if (event == Event::ArrowDown) { + model_.cursor_y = std::min(100, model_.cursor_y + 2); + moved = true; + } + if (event == Event::ArrowLeft) { + model_.cursor_x = std::max(0, model_.cursor_x - 2); + moved = true; + } + if (event == Event::ArrowRight) { + model_.cursor_x = std::min(100, model_.cursor_x + 2); + moved = true; + } + + if (moved) { + if (model_.grab_mode && model_.selected_node_id != 0) { + on_command_({CmdType::MoveNode, + {std::to_string(model_.selected_node_id), std::to_string(model_.cursor_x), + std::to_string(model_.cursor_y)}}); + } else { + // Snap to and select the nearest node within proximity. + for (const auto &kv : model_.nodes) { + float dx = kv.second.x - static_cast(model_.cursor_x); + float dy = kv.second.y - static_cast(model_.cursor_y); + if (std::sqrt(dx * dx + dy * dy) < 5.0f) { + model_.selected_node_id = kv.first; + break; + } + } + } + return true; + } + return false; +} + +bool NetProfUI::handle_topology_event(Event event) +{ + if (event == Event::Special("\033[3~")) { // Delete key + if (model_.selected_node_id != 0) { + on_command_({CmdType::RemoveNode, {std::to_string(model_.selected_node_id)}}); + } + return true; + } + + if (event == Event::Escape) { + model_.marked_node_id = 0; + return true; + } + + // Handle movement navigation. + if (model_.cursor_mode) { + return handle_cursor_movement(event); + } + + if (event == Event::ArrowUp) { + select_node_in_direction(0, -1); + return true; + } + if (event == Event::ArrowDown) { + select_node_in_direction(0, 1); + return true; + } + if (event == Event::ArrowLeft) { + select_node_in_direction(-1, 0); + return true; + } + if (event == Event::ArrowRight) { + select_node_in_direction(1, 0); + return true; + } + + return false; +} + +bool NetProfUI::handle_event(Event event) +{ + if (model_.show_command_palette) { + return handle_command_palette_event(event); + } + + if (model_.fast_mode) { + handle_global_hotkeys(event); + return true; + } + + if (handle_tab_navigation(event)) { + return true; + } + + bool topo_focused = topology_comp_->Focused(); + + if (handle_global_hotkeys(event)) { + return true; + } + + if (event.is_character() && topo_focused) { + if (handle_node_operations(event.character()[0])) { + return true; + } + } + + if (topo_focused && handle_topology_event(event)) { + return true; + } + + if (event == Event::Special({16})) { // Ctrl+P + model_.show_command_palette = true; + main_stack_index_ = 1; + command_palette_->TakeFocus(); + model_.command_input = ""; + model_.command_selected_index = 0; + update_command_suggestions(); + return true; + } + + return false; +} + +void NetProfUI::run() +{ + auto event_processor = CatchEvent(main_container_, [this](Event event) { + if (event == Event::Custom) { + this->process_messages(); + return true; // Wakeup event consumed. + } + return false; + }); + + screen_.Loop(event_processor); + + { + std::lock_guard lock(ui_mutex_); + ui_active_ = false; + } +} + +void NetProfUI::emit(UIMessage msg) { emit_batch({std::move(msg)}); } + +void NetProfUI::emit_batch(std::vector batch) +{ + { + std::lock_guard lock(queue_mutex_); + message_queue_.push(std::move(batch)); + } + + std::lock_guard lock(ui_mutex_); + if (ui_active_) { + auto now = std::chrono::steady_clock::now(); + const int interval = model_.fast_mode ? kUIFastRefreshIntervalMs : kUIRefreshIntervalMs; + if (std::chrono::duration_cast(now - last_refresh_time_).count() + >= static_cast(interval)) { + screen_.PostEvent(Event::Custom); + last_refresh_time_ = now; + } + } +} + +void NetProfUI::process_messages() +{ + std::lock_guard lock(queue_mutex_); + while (!message_queue_.empty()) { + for (const auto &msg : message_queue_.front()) { + apply(msg); + } + message_queue_.pop(); + } +} + +template +struct overloaded : Ts... { + using Ts::operator()...; +}; +template +overloaded(Ts...) -> overloaded; + +void NetProfUI::apply(const UIMessage &msg) +{ + std::visit( + overloaded{ + [&](const MsgTick &m) { + model_.stats = m.stats; + + // Advance layout simulation. + layout_.step(0.5f); + for (auto &kv : model_.nodes) { + if (layout_.nodes().count(kv.first)) { + kv.second.x = layout_.nodes().at(kv.first).x; + kv.second.y = layout_.nodes().at(kv.first).y; + } + } + + // Remove expired DHT interactions based on virtual time. + uint64_t current_time = model_.stats.virtual_time_ms; + for (auto it = model_.dht_interactions.begin(); + it != model_.dht_interactions.end();) { + if (current_time > it->second + kDHTInteractionLifetimeMs) { + it = model_.dht_interactions.erase(it); + } else { + ++it; + } + } + }, + [&](const MsgNodeAdded &m) { + model_.nodes[m.id] = NodeInfo{m.id, m.name, true, m.dht_id}; + auto &node = model_.nodes[m.id]; + + // Register node in the layout engine. + layout_.add_node(m.id, m.x, m.y, false); + node.x = layout_.nodes().at(m.id).x; + node.y = layout_.nodes().at(m.id).y; + + // Automatically select the first node added. + if (model_.nodes.size() == 1) + model_.selected_node_id = m.id; + }, + [&](const MsgNodeRemoved &m) { + if (model_.marked_node_id == m.id) { + model_.marked_node_id = 0; + } + + float old_x = 50.0f, old_y = 50.0f; + bool was_selected = (model_.selected_node_id == m.id); + + auto it = model_.nodes.find(m.id); + if (it != model_.nodes.end()) { + old_x = it->second.x; + old_y = it->second.y; + } + + model_.nodes.erase(m.id); + layout_.remove_node(m.id); + + // Remove all links associated with this node. + model_.links.erase( + std::remove_if(model_.links.begin(), model_.links.end(), + [&](const auto &link) { return link.from == m.id || link.to == m.id; }), + model_.links.end()); + + if (was_selected) { + model_.selected_node_id = 0; + if (!model_.nodes.empty()) { + float min_dist = 1e10f; + uint32_t best_id = 0; + for (const auto &kv : model_.nodes) { + float dx = kv.second.x - old_x; + float dy = kv.second.y - old_y; + float d = dx * dx + dy * dy; + if (d < min_dist) { + min_dist = d; + best_id = kv.first; + } + } + model_.selected_node_id = best_id; + } + } + }, + [&](const MsgNodeMoved &m) { + if (model_.nodes.count(m.id)) { + model_.nodes[m.id].x = m.x; + model_.nodes[m.id].y = m.y; + model_.nodes[m.id].is_pinned = true; + layout_.update_node(m.id, m.x, m.y, true); // Pin moved nodes + } + }, + [&](const MsgNodePinned &m) { + if (model_.nodes.count(m.id)) { + model_.nodes[m.id].is_pinned = m.pinned; + layout_.update_node(m.id, model_.nodes[m.id].x, model_.nodes[m.id].y, m.pinned); + } + }, + [&](const MsgLinkUpdated &m) { + // Update existing link or add a new one. + auto it + = std::find_if(model_.links.begin(), model_.links.end(), [&](const auto &link) { + return (link.from == m.from && link.to == m.to) + || (link.from == m.to && link.to == m.from); + }); + + if (m.connected) { + if (it != model_.links.end()) { + it->connected = m.connected; + it->latency_ms = m.latency; + it->packet_loss = m.loss; + it->congestion = m.congestion; + } else { + model_.links.push_back( + {m.from, m.to, m.connected, m.latency, m.loss, m.congestion}); + layout_.add_link(m.from, m.to); + } + } else { + if (it != model_.links.end()) { + model_.links.erase(it); + layout_.remove_link(m.from, m.to); + } + } + }, + [&](const MsgNodeStats &m) { + if (model_.nodes.count(m.id)) { + auto &n = model_.nodes[m.id]; + // Update history buffers only if virtual time has progressed. + if (m.num_ticks > 0) { + const uint32_t ticks_to_push + = std::min(m.num_ticks, kMaxTicksToPushPerUpdate); + + for (uint32_t j = 0; j < ticks_to_push; ++j) { + // Map virtual ticks to history samples using Bresenham-like + // distribution. + const uint32_t v_start = (j * m.num_ticks) / ticks_to_push; + const uint32_t v_end = ((j + 1) * m.num_ticks) / ticks_to_push; + const uint32_t v_count = v_end - v_start; + + const uint32_t r_start + = (j * n.dht_responses_received_this_tick) / ticks_to_push; + const uint32_t r_end + = ((j + 1) * n.dht_responses_received_this_tick) / ticks_to_push; + const int r_count = static_cast(r_end - r_start); + + if (n.dht_neighbors_history.size() >= kHistoryBufferSize) + n.dht_neighbors_history.erase(n.dht_neighbors_history.begin()); + n.dht_neighbors_history.push_back(m.dht_nodes); + + if (n.dht_response_history.size() >= kHistoryBufferSize) + n.dht_response_history.erase(n.dht_response_history.begin()); + n.dht_response_history.push_back(r_count); + + // Apply scaled Exponential Moving Average (EMA). + // alpha_v = 1 - (1 - alpha)^v + const double alpha_v + = 1.0 - std::pow(1.0 - kEMAAlpha, static_cast(v_count)); + n.ema_bw_in = (alpha_v * m.bw_in) + ((1.0 - alpha_v) * n.ema_bw_in); + n.ema_bw_out = (alpha_v * m.bw_out) + ((1.0 - alpha_v) * n.ema_bw_out); + + if (n.bw_in_history.size() >= kHistoryBufferSize) + n.bw_in_history.erase(n.bw_in_history.begin()); + n.bw_in_history.push_back(static_cast(std::round(n.ema_bw_in))); + + if (n.bw_out_history.size() >= kHistoryBufferSize) + n.bw_out_history.erase(n.bw_out_history.begin()); + n.bw_out_history.push_back(static_cast(std::round(n.ema_bw_out))); + } + n.dht_responses_received_this_tick = 0; + } + + n.dht.num_closelist = m.dht_nodes; + n.dht.num_friends = m.dht_friends; + n.dht.num_friends_udp = m.dht_friends_udp; + n.dht.num_friends_tcp = m.dht_friends_tcp; + n.dht.connection_status = m.connection_status; + n.is_online = m.is_online; + n.is_pinned = m.is_pinned; + n.protocol_breakdown = m.protocol_breakdown; + + layout_.update_node(m.id, n.x, n.y, m.is_pinned); + } + }, + [&](const MsgLog &m) { + model_.logs.push_back({m.message, m.level}); + if (model_.logs.size() > 100) + model_.logs.erase(model_.logs.begin()); + }, + [&](const MsgDHTResponse &m) { + if (model_.nodes.count(m.receiver_id)) { + model_.nodes[m.receiver_id].dht_responses_received_this_tick++; + + auto record_interaction = [&](uint32_t from, uint32_t to, bool discovery) { + if (from == 0 || to == 0 || !model_.nodes.count(from) + || !model_.nodes.count(to)) { + return; + } + + // Normalize IDs for bidirectional deduplication. + uint32_t id1 = std::min(from, to); + uint32_t id2 = std::max(from, to); + + UIModel::InteractionKey key{id1, id2, discovery}; + model_.dht_interactions[key] = model_.stats.virtual_time_ms; + }; + + // Interaction from responder to receiver. + record_interaction(m.responder_id, m.receiver_id, false); + // Interaction from receiver to discovered node. + record_interaction(m.receiver_id, m.discovered_id, true); + } + }, + [&](const MsgResize &m) { + model_.screen_width = m.width; + model_.screen_height = m.height; + model_.manual_screen_size = true; + }, + [&](const MsgReset &) { + model_.nodes.clear(); + model_.links.clear(); + model_.logs.clear(); + model_.dht_interactions.clear(); + model_.selected_node_id = 0; + model_.marked_node_id = 0; + model_.manual_screen_size = false; + layout_ = LayoutEngine(100.0f, 100.0f); + }, + }, + msg); +} + +void NetProfUI::select_node_in_direction(int dx, int dy) +{ + if (model_.nodes.empty()) + return; + + // If no node is selected, default to the one closest to center. + if (model_.nodes.count(model_.selected_node_id) == 0) { + float min_dist = 1e10f; + uint32_t best_id = 0; + for (const auto &kv : model_.nodes) { + float d + = std::sqrt(std::pow(kv.second.x - 50.0f, 2) + std::pow(kv.second.y - 50.0f, 2)); + if (d < min_dist) { + min_dist = d; + best_id = kv.first; + } + } + if (best_id != 0 || model_.nodes.count(0)) { + // Note: Node IDs start at 1. + // best_id will be non-zero if a suitable node is found. + model_.selected_node_id = best_id; + } + return; + } + + const auto ¤t = model_.nodes.at(model_.selected_node_id); + uint32_t best_id = 0; + float min_score = 1e10f; + + for (const auto &kv : model_.nodes) { + if (kv.first == model_.selected_node_id) + continue; + + float d_x = kv.second.x - current.x; + float d_y = kv.second.y - current.y; + + // Verify the node is in the target direction. + bool in_direction = false; + if (dx > 0) + in_direction = d_x > 0.1f; + if (dx < 0) + in_direction = d_x < -0.1f; + if (dy > 0) + in_direction = d_y > 0.1f; + if (dy < 0) + in_direction = d_y < -0.1f; + + if (!in_direction) + continue; + + // Calculate distance score. + // Prefer nodes that align more closely with the movement axis. + float score; + if (dx != 0) { + score = std::sqrt(d_x * d_x + (2.0f * d_y) * (2.0f * d_y)); + } else { + score = std::sqrt((2.0f * d_x) * (2.0f * d_x) + d_y * d_y); + } + + if (score < min_score) { + min_score = score; + best_id = kv.first; + } + } + + if (best_id != 0) { + model_.selected_node_id = best_id; + } +} + +void NetProfUI::update_command_suggestions() +{ + model_.command_suggestions.clear(); + std::string input = model_.command_input; + std::transform( + input.begin(), input.end(), input.begin(), [](unsigned char c) { return std::tolower(c); }); + + auto all_commands = command_registry_.get_commands(); + for (const auto &kv : all_commands) { + if (input.empty() || kv.first.find(input) != std::string::npos) { + model_.command_suggestions.push_back({kv.first, kv.second}); + } + } + std::sort(model_.command_suggestions.begin(), model_.command_suggestions.end(), + [](const auto &a, const auto &b) { return a.name < b.name; }); + + model_.command_selected_index = 0; +} + +void NetProfUI::register_commands() +{ + command_registry_.register_command("quit", "Exit the application", [this](auto) { + { + std::lock_guard lock(ui_mutex_); + ui_active_ = false; + } + on_command_({CmdType::Quit}); + screen_.ExitLoopClosure()(); + }); + command_registry_.register_command("exit", "Exit the application", [this](auto) { + { + std::lock_guard lock(ui_mutex_); + ui_active_ = false; + } + on_command_({CmdType::Quit}); + screen_.ExitLoopClosure()(); + }); + command_registry_.register_command( + "pause", "Toggle simulation pause", [this](auto) { on_command_({CmdType::TogglePause}); }); + command_registry_.register_command( + "resume", "Resume simulation", [this](auto) { on_command_({CmdType::TogglePause}); }); + command_registry_.register_command( + "play", "Resume simulation", [this](auto) { on_command_({CmdType::TogglePause}); }); + command_registry_.register_command( + "step", "Step simulation by 50ms", [this](auto) { on_command_({CmdType::Step}); }); + command_registry_.register_command( + "add node", "Add a new node at cursor or random position", [this](auto args) { + on_command_({CmdType::AddNode, args}); + }); + command_registry_.register_command( + "dht", "Toggle physical DHT interaction overlay", [this](auto) { + model_.show_dht_interactions_physical = !model_.show_dht_interactions_physical; + emit(MsgLog{std::string("Physical DHT overlay: ") + + (model_.show_dht_interactions_physical ? "ENABLED" : "DISABLED"), + LogLevel::Command}); + }); + command_registry_.register_command("save", "Save simulation state to netprof_save.json", + [this](auto) { on_command_({CmdType::SaveSnapshot}); }); + command_registry_.register_command("load", "Load simulation state from netprof_save.json", + [this](auto) { on_command_({CmdType::LoadSnapshot}); }); + command_registry_.register_command( + "speed", "Set simulation speed (0 for max)", [this](auto args) { + on_command_({CmdType::SetSpeed, args}); + }); + command_registry_.register_command( + "connect", "Connect two nodes: connect ", [this](auto args) { + if (args.size() >= 2) { + on_command_({CmdType::ConnectNodes, args}); + } else { + emit(MsgLog{"Usage: connect ", LogLevel::Warn}); + } + }); + command_registry_.register_command("filter", "Set or clear log filter", [this](auto args) { + if (args.empty()) { + model_.log_filter = ""; + emit(MsgLog{"Filter cleared", LogLevel::Command}); + } else { + model_.log_filter = args[0]; + emit(MsgLog{"Filter set to: " + args[0], LogLevel::Command}); + } + }); + command_registry_.register_command( + "layer normal", "Switch to normal topology layer", [this](auto) { + model_.layer_mode = LayerMode::Normal; + emit(MsgLog{"Layer set to NORMAL", LogLevel::Command}); + }); + command_registry_.register_command( + "layer traffic", "Switch to traffic type heatmap layer", [this](auto) { + model_.layer_mode = LayerMode::TrafficType; + emit(MsgLog{"Layer set to TRAFFIC", LogLevel::Command}); + }); + + // Calculate maximum widths for UI layout. + for (const auto &kv : command_registry_.get_commands()) { + model_.command_name_max_width + = std::max(model_.command_name_max_width, static_cast(kv.first.length())); + model_.command_description_max_width + = std::max(model_.command_description_max_width, static_cast(kv.second.length())); + } +} + +void NetProfUI::execute_command(const std::string &cmd_str) +{ + // Trim leading and trailing whitespace. + std::string cmd = cmd_str; + cmd.erase(0, cmd.find_first_not_of(" \t\n\r")); + cmd.erase(cmd.find_last_not_of(" \t\n\r") + 1); + + if (cmd.empty()) + return; + + if (!command_registry_.execute(cmd)) { + emit(MsgLog{"Unknown command: " + cmd, LogLevel::Warn}); + } +} + +} // namespace tox::netprof diff --git a/testing/netprof/ui.hh b/testing/netprof/ui.hh new file mode 100644 index 0000000000..0de1cbd86e --- /dev/null +++ b/testing/netprof/ui.hh @@ -0,0 +1,113 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_UI_H +#define C_TOXCORE_TESTING_NETPROF_UI_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include "../../toxcore/tox.h" +#include "command_registry.hh" +#include "layout_engine.hh" +#include "model.hh" +#include "views/bottom_bar.hh" +#include "views/command_log.hh" +#include "views/command_palette.hh" +#include "views/dht_filter.hh" +#include "views/dht_topology.hh" +#include "views/event_log.hh" +#include "views/hud.hh" +#include "views/inspector.hh" +#include "views/topology.hh" + +namespace tox::netprof { + +// --- The View Controller --- + +class NetProfUI { +public: + using CommandCallback = std::function; + + explicit NetProfUI(CommandCallback on_command); + ~NetProfUI() = default; + + // Main Entry Point (Blocking) + void run(); + + // Thread-safe input channel + void emit(UIMessage msg); + void emit_batch(std::vector batch); + + const UIModel &get_model() const { return model_; } + ftxui::Component get_main_container() const { return main_container_; } + ftxui::Component get_interactive_container() const { return interactive_container_; } + ftxui::Component get_topology_comp() const { return topology_comp_; } + ftxui::Component get_dht_filter_controls() const { return dht_filter_controls_; } + + // MVU Logic + void process_messages(); + void apply(const UIMessage &msg); + void execute_command(const std::string &cmd_str); + + // Event Handling + bool handle_event(ftxui::Event event); + + // Navigation + void select_node_in_direction(int dx, int dy); + + void register_commands(); + +private: + bool handle_command_palette_event(ftxui::Event event); + bool handle_tab_navigation(ftxui::Event event) const; + bool handle_global_hotkeys(ftxui::Event event); + bool handle_topology_event(ftxui::Event event); + bool handle_cursor_movement(ftxui::Event event); + bool handle_node_operations(char c); + + void update_command_suggestions(); + + // Interaction + CommandCallback on_command_; + ftxui::ScreenInteractive screen_; + + // State + UIModel model_; + LayoutEngine layout_; + CommandRegistry command_registry_; + std::mutex queue_mutex_; + std::queue> message_queue_; + + // Components + ftxui::Component main_container_; + ftxui::Component main_stack_; + ftxui::Component interactive_container_; + ftxui::Component command_palette_; + ftxui::Component dht_filter_controls_; + + ftxui::Component hud_comp_; + ftxui::Component topology_comp_; + ftxui::Component dht_topology_comp_; + ftxui::Component inspector_comp_; + ftxui::Component event_log_comp_; + ftxui::Component command_log_comp_; + ftxui::Component bottom_bar_comp_; + + ftxui::Component col1_comp_; + ftxui::Component col2_comp_; + ftxui::Component col3_comp_; + + int main_stack_index_ = 0; + + mutable std::mutex ui_mutex_; + bool ui_active_{false}; + std::chrono::steady_clock::time_point last_refresh_time_; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_UI_H diff --git a/testing/netprof/ui_test.cc b/testing/netprof/ui_test.cc new file mode 100644 index 0000000000..6508042940 --- /dev/null +++ b/testing/netprof/ui_test.cc @@ -0,0 +1,153 @@ +#include "ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, AddNodeUpdatesModel) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.process_messages(); + + const auto &model = ui_.get_model(); + ASSERT_EQ(model.nodes.size(), 1u); + EXPECT_EQ(model.nodes.at(1).name, "Alice"); + EXPECT_EQ(model.selected_node_id, 1u); +} + +TEST_F(NetProfUITest, UpdateStatsAddsToHistory) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.emit(MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, 1}); + ui_.process_messages(); + + const auto &model = ui_.get_model(); + const auto &node = model.nodes.at(1); + + ASSERT_EQ(node.dht_neighbors_history.size(), kHistoryBufferSize); + EXPECT_EQ(node.dht_neighbors_history.back(), 10); + EXPECT_GT(node.bw_in_history.back(), 0); + EXPECT_EQ(node.bw_in_history.back(), 30); +} + +TEST_F(NetProfUITest, NodeMovedUpdatesModel) +{ + ui_.emit(MsgNodeAdded{1, "Alice", 10.0f, 10.0f}); + ui_.emit(MsgNodeMoved{1, 20.0f, 30.0f}); + ui_.process_messages(); + + const auto &model = ui_.get_model(); + EXPECT_FLOAT_EQ(model.nodes.at(1).x, 20.0f); + EXPECT_FLOAT_EQ(model.nodes.at(1).y, 30.0f); +} + +TEST_F(NetProfUITest, EmitDuringInitializationRace) +{ + std::thread ui_thread([&]() { ui_.run(); }); + + for (int i = 0; i < 1000; ++i) { + ui_.emit(MsgLog{"Race test"}); + if (i == 500) { + ui_.execute_command("quit"); + } + std::this_thread::yield(); + } + + ui_thread.join(); +} + +TEST_F(NetProfUITest, TabPaneFocusTest) +{ + auto is_topo_focused = [&]() { return ui_.get_topology_comp()->Focused(); }; + auto is_dht_filters_focused = [&]() { return ui_.get_dht_filter_controls()->Focused(); }; + + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(150), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, ui_.get_main_container()->Render()); + + EXPECT_TRUE(is_topo_focused()); + EXPECT_FALSE(is_dht_filters_focused()); + + EXPECT_TRUE(ui_.get_main_container()->OnEvent(ftxui::Event::Tab)); // Command Log + EXPECT_FALSE(is_topo_focused()); + + EXPECT_TRUE(ui_.get_main_container()->OnEvent(ftxui::Event::Tab)); // DHT Topology + EXPECT_TRUE(ui_.get_main_container()->OnEvent(ftxui::Event::Tab)); // DHT Filters + + EXPECT_TRUE(is_dht_filters_focused()); +} + +TEST_F(NetProfUITest, SimulationSpeedConsistency) +{ + NetProfUI ui1([](auto) {}); + ui1.emit(MsgNodeAdded{1, "Alice"}); + for (int i = 0; i < 4; ++i) { + ui1.emit(MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, 1}); + } + ui1.process_messages(); + + NetProfUI ui4([](auto) {}); + ui4.emit(MsgNodeAdded{1, "Alice"}); + ui4.emit(MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, 4}); + ui4.process_messages(); + + NetProfUI ui05([](auto) {}); + ui05.emit(MsgNodeAdded{1, "Alice"}); + for (int i = 0; i < 4; ++i) { + ui05.emit(MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, 0}); + ui05.emit(MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, 1}); + } + ui05.process_messages(); + + EXPECT_EQ(ui1.get_model().nodes.at(1).bw_in_history, ui4.get_model().nodes.at(1).bw_in_history); + EXPECT_EQ( + ui1.get_model().nodes.at(1).bw_in_history, ui05.get_model().nodes.at(1).bw_in_history); + EXPECT_EQ(ui1.get_model().nodes.at(1).dht_neighbors_history, + ui4.get_model().nodes.at(1).dht_neighbors_history); + EXPECT_EQ(ui1.get_model().nodes.at(1).dht_neighbors_history, + ui05.get_model().nodes.at(1).dht_neighbors_history); + EXPECT_EQ(ui1.get_model().nodes.at(1).dht_response_history, + ui4.get_model().nodes.at(1).dht_response_history); + EXPECT_EQ(ui1.get_model().nodes.at(1).dht_response_history, + ui05.get_model().nodes.at(1).dht_response_history); +} + +TEST_F(NetProfUITest, SimulationSpeedConsistencyLarge) +{ + const uint32_t kLargeTicks = 500; + + // ui1: 500 messages, each with 1 response and 1 tick. + NetProfUI ui1([](auto) {}); + ui1.emit(MsgNodeAdded{1, "Alice"}); + for (uint32_t i = 0; i < kLargeTicks; ++i) { + ui1.emit(MsgDHTResponse{1, 2, 3}); + ui1.emit(MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, 1}); + } + ui1.process_messages(); + + // uiLarge: 1 message with 500 ticks, after 500 responses. + NetProfUI uiLarge([](auto) {}); + uiLarge.emit(MsgNodeAdded{1, "Alice"}); + for (uint32_t i = 0; i < kLargeTicks; ++i) { + uiLarge.emit(MsgDHTResponse{1, 2, 3}); + } + uiLarge.emit( + MsgNodeStats{1, 100, 200, 10, 5, 3, 2, TOX_CONNECTION_UDP, true, false, kLargeTicks}); + uiLarge.process_messages(); + + // Both should reach the target bandwidth. + EXPECT_EQ(ui1.get_model().nodes.at(1).bw_in_history.back(), 100); + EXPECT_EQ(uiLarge.get_model().nodes.at(1).bw_in_history.back(), 100); + + // Verify all 500 responses are preserved in the compressed history. + const auto &h = uiLarge.get_model().nodes.at(1).dht_response_history; + int total_responses = 0; + for (int r : h) { + total_responses += r; + } + EXPECT_EQ(total_responses, 500); + + // The compressed history should only have the last 40 samples modified. + // (Actually 200 samples total, but the first 160 are 0s). + EXPECT_EQ(h[159], 0); + EXPECT_GT(h[160], 0); +} + +} // namespace tox::netprof diff --git a/testing/netprof/ui_test_support.cc b/testing/netprof/ui_test_support.cc new file mode 100644 index 0000000000..2571d6ecaf --- /dev/null +++ b/testing/netprof/ui_test_support.cc @@ -0,0 +1,18 @@ +#include "ui_test_support.hh" + +#include + +namespace ftxui { +void PrintTo(const Screen &screen, std::ostream *os) { *os << "\n" << screen.ToString(); } +} // namespace ftxui + +namespace tox::netprof { + +NetProfUITest::NetProfUITest() + : ui_([this](UICommand cmd) { last_command_ = cmd; }) +{ +} + +NetProfUITest::~NetProfUITest() = default; + +} // namespace tox::netprof diff --git a/testing/netprof/ui_test_support.hh b/testing/netprof/ui_test_support.hh new file mode 100644 index 0000000000..766e1dd7cd --- /dev/null +++ b/testing/netprof/ui_test_support.hh @@ -0,0 +1,59 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_UI_TEST_SUPPORT_H +#define C_TOXCORE_TESTING_NETPROF_UI_TEST_SUPPORT_H + +#include +#include + +#include +#include + +#include "ui.hh" + +namespace ftxui { +void PrintTo(const Screen &screen, std::ostream *os); +} // namespace ftxui + +namespace tox::netprof { + +MATCHER_P5(MatchesRect, x, y, w, h, expected_lines, + "matches rectangle at (" + std::to_string(x) + "," + std::to_string(y) + ") with size " + + std::to_string(w) + "x" + std::to_string(h)) +{ + const ftxui::Screen &screen = arg; + + std::string expected; + for (size_t i = 0; i < static_cast(expected_lines.size()); ++i) { + expected += expected_lines[i]; + if (i < static_cast(expected_lines.size()) - 1) + expected += "\n"; + } + + std::string actual; + for (int j = y; j < y + h; ++j) { + for (int i = x; i < x + w; ++i) { + std::string cell = screen.at(i, j); + actual += cell.empty() ? " " : cell; + } + if (j < y + h - 1) + actual += "\n"; + } + + if (actual == expected) + return true; + + *result_listener << "\nActual area:\n[" << actual << "]\nExpected area:\n[" << expected << "]"; + return false; +} + +class NetProfUITest : public ::testing::Test { +protected: + NetProfUITest(); + ~NetProfUITest() override; + + NetProfUI ui_; + UICommand last_command_; +}; + +} // namespace tox::netprof + +#endif // C_TOXCORE_TESTING_NETPROF_UI_TEST_SUPPORT_H diff --git a/testing/netprof/views/BUILD.bazel b/testing/netprof/views/BUILD.bazel new file mode 100644 index 0000000000..91a3cedff5 --- /dev/null +++ b/testing/netprof/views/BUILD.bazel @@ -0,0 +1,242 @@ +load("@rules_cc//cc:defs.bzl", "cc_library", "cc_test") + +package(default_visibility = ["//c-toxcore/testing/netprof:__subpackages__"]) + +cc_library( + name = "focusable", + srcs = ["focusable.cc"], + hdrs = ["focusable.hh"], + deps = ["@ftxui//:component"], +) + +cc_library( + name = "hud", + srcs = ["hud.cc"], + hdrs = ["hud.hh"], + deps = [ + "//c-toxcore/testing/netprof:model", + "@ftxui//:component", + "@ftxui//:dom", + ], +) + +cc_library( + name = "topology", + srcs = ["topology.cc"], + hdrs = ["topology.hh"], + deps = [ + ":focusable", + "//c-toxcore/testing/netprof:model", + "//c-toxcore/testing/netprof:model_utils", + "@ftxui//:component", + "@ftxui//:dom", + "@ftxui//:screen", + ], +) + +cc_library( + name = "dht_topology", + srcs = ["dht_topology.cc"], + hdrs = ["dht_topology.hh"], + deps = [ + ":focusable", + "//c-toxcore/testing/netprof:constants", + "//c-toxcore/testing/netprof:model", + "//c-toxcore/testing/netprof:model_utils", + "@ftxui//:component", + "@ftxui//:dom", + "@ftxui//:screen", + ], +) + +cc_library( + name = "inspector", + srcs = ["inspector.cc"], + hdrs = ["inspector.hh"], + deps = [ + ":focusable", + "//c-toxcore/testing/netprof:model", + "//c-toxcore/testing/netprof:model_utils", + "//c-toxcore/testing/netprof:packet_utils", + "//c-toxcore/toxcore:tox", + "@ftxui//:component", + "@ftxui//:dom", + "@ftxui//:screen", + ], +) + +cc_library( + name = "event_log", + srcs = ["event_log.cc"], + hdrs = ["event_log.hh"], + deps = [ + ":focusable", + "//c-toxcore/testing/netprof:constants", + "//c-toxcore/testing/netprof:model", + "@ftxui//:component", + "@ftxui//:dom", + ], +) + +cc_library( + name = "command_log", + srcs = ["command_log.cc"], + hdrs = ["command_log.hh"], + deps = [ + ":focusable", + "//c-toxcore/testing/netprof:constants", + "//c-toxcore/testing/netprof:model", + "@ftxui//:component", + "@ftxui//:dom", + ], +) + +cc_library( + name = "bottom_bar", + srcs = ["bottom_bar.cc"], + hdrs = ["bottom_bar.hh"], + deps = [ + "//c-toxcore/testing/netprof:model", + "@ftxui//:component", + "@ftxui//:dom", + ], +) + +cc_library( + name = "dht_filter", + srcs = ["dht_filter.cc"], + hdrs = ["dht_filter.hh"], + deps = [ + "//c-toxcore/testing/netprof:model", + "@ftxui//:component", + "@ftxui//:dom", + "@ftxui//:screen", + ], +) + +cc_library( + name = "command_palette", + srcs = ["command_palette.cc"], + hdrs = ["command_palette.hh"], + deps = [ + "//c-toxcore/testing/netprof:model", + "@ftxui//:component", + "@ftxui//:dom", + ], +) + +cc_library( + name = "views", + hdrs = [ + "bottom_bar.hh", + "command_log.hh", + "command_palette.hh", + "dht_filter.hh", + "dht_topology.hh", + "event_log.hh", + "hud.hh", + "inspector.hh", + "topology.hh", + ], + deps = [ + ":bottom_bar", + ":command_log", + ":command_palette", + ":dht_filter", + ":dht_topology", + ":event_log", + ":hud", + ":inspector", + ":topology", + ], +) + +cc_test( + name = "inspector_test", + srcs = ["inspector_test.cc"], + deps = [ + ":inspector", + "//c-toxcore/testing/netprof:ui_test_support", + "//c-toxcore/toxcore:tox", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "topology_test", + srcs = ["topology_test.cc"], + deps = [ + ":topology", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "hud_test", + srcs = ["hud_test.cc"], + deps = [ + ":hud", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "command_palette_test", + srcs = ["command_palette_test.cc"], + deps = [ + ":command_palette", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "event_log_test", + srcs = ["event_log_test.cc"], + deps = [ + ":event_log", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "command_log_test", + srcs = ["command_log_test.cc"], + deps = [ + ":command_log", + ":event_log", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "dht_filter_test", + srcs = ["dht_filter_test.cc"], + deps = [ + ":dht_filter", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) + +cc_test( + name = "dht_topology_test", + srcs = ["dht_topology_test.cc"], + deps = [ + ":dht_topology", + "//c-toxcore/testing/netprof:ui_test_support", + "@com_google_googletest//:gtest", + "@com_google_googletest//:gtest_main", + ], +) diff --git a/testing/netprof/views/bottom_bar.cc b/testing/netprof/views/bottom_bar.cc new file mode 100644 index 0000000000..4c951060fb --- /dev/null +++ b/testing/netprof/views/bottom_bar.cc @@ -0,0 +1,49 @@ +#include "bottom_bar.hh" + +#include +#include +#include + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component bottom_bar(const UIModel &model) +{ + return Renderer([&] { + Elements items = { + text(" q: Quit "), + text(" Space: Pause "), + text(" s: Step "), + text(" a: Add Node "), + text(" d: Delete "), + text(model.cursor_mode ? (model.grab_mode ? " g: Drop " : " g: Grab ") : ""), + text(" c: Cursor "), + text(" p: Pin "), + text(" o: Offline "), + text(" l: Toggle Layer "), + text(" F: Fast "), + text(" +/-: Speed, =: Reset "), + text(" S: Save "), + text(" L: Load "), + filler(), + }; + + if (model.marked_node_id != 0) { + std::string name = "???"; + if (model.nodes.count(model.marked_node_id)) + name = model.nodes.at(model.marked_node_id).name; + items.push_back(text(" [Linking from " + name + "] ") | bgcolor(Color::Blue)); + items.push_back(text(" f: Connect ")); + items.push_back(text(" u: Unfriend ")); + items.push_back(text(" Esc: Cancel ")); + } else { + items.push_back(text(" f: Mark for linking ")); + } + + items.push_back(text(" Tab: Switch Pane ")); + return hbox(std::move(items)); + }); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/bottom_bar.hh b/testing/netprof/views/bottom_bar.hh new file mode 100644 index 0000000000..d9e04e2fc7 --- /dev/null +++ b/testing/netprof/views/bottom_bar.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_BOTTOM_BAR_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_BOTTOM_BAR_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the Bottom Status Bar component. + */ +ftxui::Component bottom_bar(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_BOTTOM_BAR_HH diff --git a/testing/netprof/views/command_log.cc b/testing/netprof/views/command_log.cc new file mode 100644 index 0000000000..75fac67ac6 --- /dev/null +++ b/testing/netprof/views/command_log.cc @@ -0,0 +1,30 @@ +#include "command_log.hh" + +#include + +#include "../constants.hh" +#include "focusable.hh" + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component command_log(const UIModel &model) +{ + return make_focusable(Renderer([&] { + Elements list; + int count = 0; + for (int i = static_cast(model.logs.size()) - 1; i >= 0 && count < kLogHeight; --i) { + const auto &log = model.logs[i]; + + if (log.level != LogLevel::Command) + continue; + + list.insert(list.begin(), text(log.message) | color(Color::Cyan)); + count++; + } + return vbox(std::move(list)); + })); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/command_log.hh b/testing/netprof/views/command_log.hh new file mode 100644 index 0000000000..17851c7354 --- /dev/null +++ b/testing/netprof/views/command_log.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_COMMAND_LOG_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_COMMAND_LOG_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the Command Log component. + */ +ftxui::Component command_log(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_COMMAND_LOG_HH diff --git a/testing/netprof/views/command_log_test.cc b/testing/netprof/views/command_log_test.cc new file mode 100644 index 0000000000..c41a6546c4 --- /dev/null +++ b/testing/netprof/views/command_log_test.cc @@ -0,0 +1,48 @@ +#include "command_log.hh" + +#include "../ui_test_support.hh" +#include "event_log.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, LogSeparation) +{ + ui_.emit(MsgLog{"This is a command", LogLevel::Command}); + ui_.emit(MsgLog{"This is an event", LogLevel::Info}); + ui_.process_messages(); + + auto event_view = views::event_log(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(10)); + ftxui::Render(screen, event_view); + std::string event_out = screen.ToString(); + EXPECT_NE(event_out.find("This is an event"), std::string::npos); + EXPECT_EQ(event_out.find("This is a command"), std::string::npos); + + auto command_view = views::command_log(ui_.get_model())->Render(); + screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(10)); + ftxui::Render(screen, command_view); + std::string command_out = screen.ToString(); + EXPECT_NE(command_out.find("This is a command"), std::string::npos); + EXPECT_EQ(command_out.find("This is an event"), std::string::npos); +} + +TEST_F(NetProfUITest, CommandLogLatestVisible) +{ + for (int i = 1; i <= 10; ++i) { + ui_.emit(MsgLog{"Command " + std::to_string(i), LogLevel::Command}); + } + ui_.process_messages(); + + auto element = views::command_log(ui_.get_model())->Render(); + element |= ftxui::size(ftxui::HEIGHT, ftxui::EQUAL, 6); + + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(6)); + ftxui::Render(screen, element); + std::string out = screen.ToString(); + + EXPECT_NE(out.find("Command 10"), std::string::npos); + EXPECT_NE(out.find("Command 5"), std::string::npos); + EXPECT_EQ(out.find("Command 4"), std::string::npos); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/command_palette.cc b/testing/netprof/views/command_palette.cc new file mode 100644 index 0000000000..c113f464fb --- /dev/null +++ b/testing/netprof/views/command_palette.cc @@ -0,0 +1,79 @@ +#include "command_palette.hh" + +#include +#include + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component command_palette( + UIModel &model, std::function on_execute, std::function on_change) +{ + auto input_option = InputOption(); + input_option.on_enter = std::move(on_execute); + input_option.on_change = std::move(on_change); + input_option.multiline = false; + input_option.transform = [](InputState state) { + auto e = state.element; + if (state.focused) { + e |= color(Color::White) | bgcolor(Color::Black); + } else { + e |= dim; + } + return e; + }; + + auto input + = Input(&model.command_input, "Type command (e.g. 'pause', 'speed 2.0')...", input_option); + + return Renderer(input, [input, &model] { + int name_w = model.command_name_max_width + 2; + int desc_w = model.command_description_max_width + 2; + int total_w = std::max(60, name_w + desc_w + 3); + + Elements suggestions; + for (size_t i = 0; i < model.command_suggestions.size(); ++i) { + bool selected = (static_cast(i) == model.command_selected_index); + const auto &s = model.command_suggestions[i]; + auto element = hbox({ + text(" " + s.name) | size(WIDTH, EQUAL, name_w), + separator(), + text(" " + s.description) | dim, + filler(), + }); + if (selected) { + element |= ftxui::focus | bgcolor(Color::Blue) | bold; + } + suggestions.push_back(element); + } + + auto suggestion_list = vbox(std::move(suggestions)); + if (model.command_suggestions.empty()) { + suggestion_list = text(" (No matching commands) ") | dim | hcenter; + } + + return vbox({ + text(" COMMAND PALETTE ") | bold | center | bgcolor(Color::Blue), + hbox({text("> ") | bold, + [&]() { + auto e = input->Render() | focus; + if (!model.command_input.empty()) { + e |= focusCursorBlockBlinking; + } + return e + | size(WIDTH, EQUAL, + model.command_input.empty() + ? (total_w - 6) + : static_cast(model.command_input.length()) + 1); + }(), + filler() | xflex}) + | size(WIDTH, EQUAL, total_w - 2) | border, + suggestion_list | frame | size(HEIGHT, EQUAL, 10) | vscroll_indicator | border, + text(" Press Enter to execute, Esc to cancel ") | dim | hcenter, + }) + | size(WIDTH, EQUAL, total_w) | border | bgcolor(Color::Black) | clear_under | center; + }); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/command_palette.hh b/testing/netprof/views/command_palette.hh new file mode 100644 index 0000000000..eca34e5d68 --- /dev/null +++ b/testing/netprof/views/command_palette.hh @@ -0,0 +1,19 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_COMMAND_PALETTE_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_COMMAND_PALETTE_HH + +#include +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the Command Palette component. + */ +ftxui::Component command_palette( + UIModel &model, std::function on_execute, std::function on_change = nullptr); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_COMMAND_PALETTE_HH diff --git a/testing/netprof/views/command_palette_test.cc b/testing/netprof/views/command_palette_test.cc new file mode 100644 index 0000000000..d58fab6d82 --- /dev/null +++ b/testing/netprof/views/command_palette_test.cc @@ -0,0 +1,79 @@ +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, CommandPaletteToggle) +{ + ui_.get_main_container()->TakeFocus(); + EXPECT_FALSE(ui_.get_model().show_command_palette); + ui_.handle_event(ftxui::Event::Character(':')); + EXPECT_TRUE(ui_.get_model().show_command_palette); + ui_.handle_event(ftxui::Event::Escape); + EXPECT_FALSE(ui_.get_model().show_command_palette); +} + +TEST_F(NetProfUITest, CommandPaletteVisualAndInputTest) +{ + ui_.get_main_container()->TakeFocus(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, ui_.get_main_container()->Render()); + EXPECT_EQ(screen.ToString().find("COMMAND PALETTE"), std::string::npos); + + ui_.get_main_container()->OnEvent(ftxui::Event::Character(':')); + screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, ui_.get_main_container()->Render()); + EXPECT_NE(screen.ToString().find("COMMAND PALETTE"), std::string::npos); + + ui_.emit(MsgReset{}); + ui_.process_messages(); + last_command_ = {CmdType::Step, {}}; + + ui_.get_main_container()->OnEvent(ftxui::Event::Character('a')); + EXPECT_EQ(ui_.get_model().command_input, "a"); + EXPECT_NE(last_command_.type, CmdType::AddNode); +} + +TEST_F(NetProfUITest, CommandPaletteCompletionAndNavigation) +{ + ui_.get_main_container()->TakeFocus(); + ui_.handle_event(ftxui::Event::Character(':')); + ui_.get_main_container()->OnEvent(ftxui::Event::Character('p')); + + bool found_pause = false; + for (const auto &s : ui_.get_model().command_suggestions) { + if (s.name == "pause") + found_pause = true; + } + EXPECT_TRUE(found_pause); + + ASSERT_GT(ui_.get_model().command_suggestions.size(), 1u); + int initial_index = ui_.get_model().command_selected_index; + ui_.handle_event(ftxui::Event::ArrowDown); + EXPECT_NE(ui_.get_model().command_selected_index, initial_index); + + ui_.get_main_container()->OnEvent(ftxui::Event::Return); + EXPECT_FALSE(ui_.get_model().show_command_palette); +} + +TEST_F(NetProfUITest, CommandPaletteRenderingAndScrolling) +{ + ui_.emit(MsgResize{150, 60}); + ui_.process_messages(); + ui_.get_main_container()->TakeFocus(); + ui_.handle_event(ftxui::Event::Special({16})); + + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(150), ftxui::Dimension::Fixed(60)); + ftxui::Render(screen, ui_.get_main_container()->Render()); + std::string output = screen.ToString(); + + EXPECT_NE(output.find("add node"), std::string::npos); + + ui_.handle_event(ftxui::Event::ArrowUp); + screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(120), ftxui::Dimension::Fixed(60)); + ftxui::Render(screen, ui_.get_main_container()->Render()); + output = screen.ToString(); + + EXPECT_NE(output.find("step"), std::string::npos); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/dht_filter.cc b/testing/netprof/views/dht_filter.cc new file mode 100644 index 0000000000..031b0bfd51 --- /dev/null +++ b/testing/netprof/views/dht_filter.cc @@ -0,0 +1,18 @@ +#include "dht_filter.hh" + +#include +#include + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component dht_filter(UIModel &model) +{ + return Container::Vertical({ + Checkbox(" Responder", &model.show_dht_responder_lines) | color(Color::Cyan), + Checkbox(" Discovered", &model.show_dht_discovery_lines) | color(Color::Yellow), + }); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/dht_filter.hh b/testing/netprof/views/dht_filter.hh new file mode 100644 index 0000000000..abfc6ba1f3 --- /dev/null +++ b/testing/netprof/views/dht_filter.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_DHT_FILTER_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_DHT_FILTER_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the DHT Filter controls component. + */ +ftxui::Component dht_filter(UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_DHT_FILTER_HH diff --git a/testing/netprof/views/dht_filter_test.cc b/testing/netprof/views/dht_filter_test.cc new file mode 100644 index 0000000000..09813455ce --- /dev/null +++ b/testing/netprof/views/dht_filter_test.cc @@ -0,0 +1,23 @@ +#include "dht_filter.hh" + +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, DHTFilterInteractions) +{ + auto is_dht_filters_focused = [&]() { return ui_.get_dht_filter_controls()->Focused(); }; + + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(150), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, ui_.get_main_container()->Render()); + + EXPECT_TRUE(ui_.get_model().show_dht_responder_lines); + EXPECT_TRUE(ui_.get_model().show_dht_discovery_lines); + + ui_.get_main_container()->OnEvent(ftxui::Event::Tab); // Command Log + ui_.get_main_container()->OnEvent(ftxui::Event::Tab); // DHT Topology + ui_.get_main_container()->OnEvent(ftxui::Event::Tab); // DHT Filters + EXPECT_TRUE(is_dht_filters_focused()); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/dht_topology.cc b/testing/netprof/views/dht_topology.cc new file mode 100644 index 0000000000..b73b44398d --- /dev/null +++ b/testing/netprof/views/dht_topology.cc @@ -0,0 +1,149 @@ +#include "dht_topology.hh" + +#include +#include +#include +#include +#include +#include +#include + +#include "../constants.hh" +#include "../model_utils.hh" +#include "focusable.hh" + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component dht_topology(const UIModel &model) +{ + auto component = Renderer([&] { + int dimx = model.screen_width; + int dimy = model.screen_height; + + if (dimx <= 0 || dimy <= 0) { + auto terminal = ftxui::Terminal::Size(); + dimx = terminal.dimx; + dimy = terminal.dimy; + } + + if (dimx <= 0) + dimx = 200; + if (dimy <= 0) + dimy = 60; + + int avail_w = std::max(20, (dimx - 6) / 3); + int avail_h = std::max(20, dimy - 18); + + int canvas_w = avail_w * 2; + int canvas_h = avail_h * 4; + + auto c = Canvas(canvas_w, canvas_h); + + float scale = std::min(static_cast(canvas_w), static_cast(canvas_h)) / 100.0f; + float off_x = (static_cast(canvas_w) - 100.0f * scale) / 2.0f; + float off_y = (static_cast(canvas_h) - 100.0f * scale) / 2.0f; + + struct NodePos { + uint32_t id; + float theta; + float r; + float x, y; + }; + std::vector sorted_nodes; + for (const auto &kv : model.nodes) { + sorted_nodes.push_back( + {kv.first, project_dht_id_to_theta(kv.second.dht_id), kDHTRingRadius, 0, 0}); + } + std::sort(sorted_nodes.begin(), sorted_nodes.end(), + [](const auto &a, const auto &b) { return a.theta < b.theta; }); + + for (size_t i = 0; i < sorted_nodes.size(); ++i) { + int stack = 0; + for (int j = static_cast(i) - 1; j >= 0; --j) { + float diff = std::abs(sorted_nodes[i].theta - sorted_nodes[j].theta); + if (diff > 3.14159f) + diff = 2.0f * 3.14159f - diff; + if (diff < 0.05f) { + stack++; + } else { + break; + } + } + sorted_nodes[i].r += static_cast(stack) * 5.0f; + sorted_nodes[i].x = 50.0f + sorted_nodes[i].r * std::cos(sorted_nodes[i].theta); + sorted_nodes[i].y = 50.0f + sorted_nodes[i].r * std::sin(sorted_nodes[i].theta); + } + + std::map pos_map; + for (const auto &np : sorted_nodes) + pos_map[np.id] = np; + + for (int i = 0; i < 100; ++i) { + float t1 = static_cast(i) / 100.0f * 2.0f * 3.14159f; + float t2 = static_cast(i + 1) / 100.0f * 2.0f * 3.14159f; + c.DrawPointLine(static_cast(off_x + (50.0f + 42.0f * std::cos(t1)) * scale), + static_cast(off_y + (50.0f + 42.0f * std::sin(t1)) * scale), + static_cast(off_x + (50.0f + 42.0f * std::cos(t2)) * scale), + static_cast(off_y + (50.0f + 42.0f * std::sin(t2)) * scale), Color::GrayDark); + } + + for (const auto &kv : model.dht_interactions) { + const auto &key = kv.first; + uint64_t timestamp_ms = kv.second; + + if (!pos_map.count(key.id1) || !pos_map.count(key.id2)) + continue; + + if (key.is_discovery && !model.show_dht_discovery_lines) + continue; + if (!key.is_discovery && !model.show_dht_responder_lines) + continue; + + auto p1 = pos_map.at(key.id1); + auto p2 = pos_map.at(key.id2); + + uint64_t diff = 0; + if (model.stats.virtual_time_ms > timestamp_ms) { + diff = model.stats.virtual_time_ms - timestamp_ms; + } + uint8_t brightness + = static_cast(std::max(0, 255 - static_cast(diff * 255 / 1000))); + + Color col; + if (key.is_discovery) { + col = Color::RGB(brightness, brightness, 0); // Yellow for discovery + } else { + col = Color::RGB(0, brightness, brightness); // Cyan for interaction + } + + c.DrawPointLine(static_cast(off_x + p1.x * scale), + static_cast(off_y + p1.y * scale), static_cast(off_x + p2.x * scale), + static_cast(off_y + p2.y * scale), col); + } + + for (const auto &np : sorted_nodes) { + const auto &n = model.nodes.at(np.id); + bool selected = (n.id == model.selected_node_id); + + Color color = n.is_online ? Color::Cyan : Color::GrayDark; + c.DrawPointCircle(static_cast(off_x + np.x * scale), + static_cast(off_y + np.y * scale), 2, color); + c.DrawText(static_cast(off_x + np.x * scale) + 2, + static_cast(off_y + np.y * scale), n.name, [&](Pixel &pix) { + pix.foreground_color = Color::White; + if (selected) { + pix.bold = true; + pix.underlined = true; + } + }); + } + + return canvas(std::move(c)) | flex; + }); + + return make_focusable(component); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/dht_topology.hh b/testing/netprof/views/dht_topology.hh new file mode 100644 index 0000000000..6dc10d45d2 --- /dev/null +++ b/testing/netprof/views/dht_topology.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_DHT_TOPOLOGY_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_DHT_TOPOLOGY_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the DHT Topology (Kademlia Ring) component. + */ +ftxui::Component dht_topology(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_DHT_TOPOLOGY_HH diff --git a/testing/netprof/views/dht_topology_test.cc b/testing/netprof/views/dht_topology_test.cc new file mode 100644 index 0000000000..ca4dfe6197 --- /dev/null +++ b/testing/netprof/views/dht_topology_test.cc @@ -0,0 +1,54 @@ +#include "dht_topology.hh" + +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, DHTTopologyRenderTest) +{ + std::vector dht_id(32, 0); + dht_id[0] = 0x80; + ui_.emit(MsgNodeAdded{1, "Alice", 10, 10, dht_id}); + ui_.process_messages(); + + auto element = views::dht_topology(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, element); + + std::string output = screen.ToString(); + EXPECT_NE(output.find("Alice"), std::string::npos); +} + +TEST_F(NetProfUITest, DHTRadialStackingTest) +{ + std::vector dht_id(32, 0); + dht_id[0] = 0x40; + ui_.emit(MsgNodeAdded{1, "Alice", 10, 10, dht_id}); + ui_.emit(MsgNodeAdded{2, "Bob", 10, 10, dht_id}); + ui_.emit(MsgResize{306, 100}); + ui_.process_messages(); + + auto element = views::dht_topology(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(306), ftxui::Dimension::Fixed(100)); + ftxui::Render(screen, element); + + auto find_node_y = [&](const std::string &name) { + for (int y = 0; y < 100; ++y) { + std::string line; + for (int x = 0; x < 200; ++x) + line += screen.at(x, y); + if (line.find(name) != std::string::npos) + return y; + } + return -1; + }; + + int alice_y = find_node_y("Alice"); + int bob_y = find_node_y("Bob"); + + ASSERT_NE(alice_y, -1); + ASSERT_NE(bob_y, -1); + EXPECT_NE(alice_y, bob_y); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/event_log.cc b/testing/netprof/views/event_log.cc new file mode 100644 index 0000000000..e7aa87b159 --- /dev/null +++ b/testing/netprof/views/event_log.cc @@ -0,0 +1,57 @@ +#include "event_log.hh" + +#include +#include +#include + +#include "../constants.hh" +#include "focusable.hh" + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component event_log(const UIModel &model) +{ + return make_focusable(Renderer([&] { + Elements list; + int count = 0; + for (int i = static_cast(model.logs.size()) - 1; i >= 0 && count < kLogHeight; --i) { + const auto &log = model.logs[i]; + + Color col = Color::White; + switch (log.level) { + case LogLevel::Info: + break; + case LogLevel::Warn: + col = Color::Yellow; + break; + case LogLevel::Error: + col = Color::Red; + break; + case LogLevel::DHT: + col = Color::Cyan; + break; + case LogLevel::Crypto: + col = Color::Magenta; + break; + case LogLevel::Conn: + col = Color::Green; + break; + case LogLevel::Command: + // Commands are in the command log. + continue; + } + + if (!model.log_filter.empty() + && log.message.find(model.log_filter) == std::string::npos) + continue; + + list.insert(list.begin(), text(log.message) | color(col)); + count++; + } + return vbox(std::move(list)); + })); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/event_log.hh b/testing/netprof/views/event_log.hh new file mode 100644 index 0000000000..ba6e5c2060 --- /dev/null +++ b/testing/netprof/views/event_log.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_EVENT_LOG_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_EVENT_LOG_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the Event Log component. + */ +ftxui::Component event_log(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_EVENT_LOG_HH diff --git a/testing/netprof/views/event_log_test.cc b/testing/netprof/views/event_log_test.cc new file mode 100644 index 0000000000..65d50f13be --- /dev/null +++ b/testing/netprof/views/event_log_test.cc @@ -0,0 +1,31 @@ +#include "event_log.hh" + +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, LogFiltering) +{ + ui_.emit(MsgLog{"Important event", LogLevel::Info}); + ui_.emit(MsgLog{"Spammy event", LogLevel::Info}); + ui_.process_messages(); + + auto get_rendered_logs = [&]() { + auto element = views::event_log(ui_.get_model())->Render(); + auto screen + = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(10)); + ftxui::Render(screen, element); + return screen.ToString(); + }; + + std::string out = get_rendered_logs(); + EXPECT_NE(out.find("Important event"), std::string::npos); + EXPECT_NE(out.find("Spammy event"), std::string::npos); + + ui_.execute_command("filter Important"); + out = get_rendered_logs(); + EXPECT_NE(out.find("Important event"), std::string::npos); + EXPECT_EQ(out.find("Spammy event"), std::string::npos); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/focusable.cc b/testing/netprof/views/focusable.cc new file mode 100644 index 0000000000..b8ff3f64b8 --- /dev/null +++ b/testing/netprof/views/focusable.cc @@ -0,0 +1,16 @@ +#include "focusable.hh" + +namespace tox::netprof::views { + +FocusableComponent::FocusableComponent(ftxui::Component child) { Add(std::move(child)); } + +FocusableComponent::~FocusableComponent() = default; + +bool FocusableComponent::Focusable() const { return true; } + +ftxui::Component make_focusable(ftxui::Component child) +{ + return std::make_shared(std::move(child)); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/focusable.hh b/testing/netprof/views/focusable.hh new file mode 100644 index 0000000000..877ad9eec6 --- /dev/null +++ b/testing/netprof/views/focusable.hh @@ -0,0 +1,23 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_FOCUSABLE_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_FOCUSABLE_HH + +#include + +namespace tox::netprof::views { + +/** + * @brief A component wrapper that makes its content focusable. + */ +class FocusableComponent : public ftxui::ComponentBase { +public: + explicit FocusableComponent(ftxui::Component child); + ~FocusableComponent() override; + + bool Focusable() const override; +}; + +ftxui::Component make_focusable(ftxui::Component child); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_FOCUSABLE_HH diff --git a/testing/netprof/views/hud.cc b/testing/netprof/views/hud.cc new file mode 100644 index 0000000000..0655e9621c --- /dev/null +++ b/testing/netprof/views/hud.cc @@ -0,0 +1,69 @@ +#include "hud.hh" + +#include +#include +#include +#include + +namespace tox::netprof::views { + +using namespace ftxui; + +static std::string format_time(uint64_t ms) +{ + uint64_t s = ms / 1000; + uint64_t m = s / 60; + uint64_t h = m / 60; + std::stringstream ss; + ss << "T+" << std::setfill('0') << std::setw(2) << h << ":" << std::setw(2) << (m % 60) << ":" + << std::setw(2) << (s % 60) << "." << std::setw(3) << (ms % 1000); + return ss.str(); +} + +ftxui::Component hud(const UIModel &model) +{ + return Renderer([&] { + return hbox({ + text(" NetProf v1.0 ") | bold | bgcolor(Color::Blue) | color(Color::White), + separator(), + text(format_time(model.stats.virtual_time_ms)), + separator(), + text(model.stats.paused ? " PAUSED " : " RUNNING ") + | color(model.stats.paused ? Color::Red : Color::Green), + separator(), + text(" Speed: " + + [&]() { + if (model.stats.real_time_factor <= 0.0) + return std::string("MAX"); + std::stringstream ss; + ss << std::fixed << std::setprecision(1) << model.stats.real_time_factor << "x"; + return ss.str(); + }()) + | color(Color::Cyan), + separator(), + text(std::string(" Layer: ") + + [&]() { + switch (model.layer_mode) { + case LayerMode::Normal: + return "Normal"; + case LayerMode::TrafficType: + return "Traffic"; + } + return "Unknown"; + }()) + | color(Color::Yellow), + separator(), + text(" Term: " + std::to_string(model.screen_width) + "x" + + std::to_string(model.screen_height)) + | color(Color::GrayDark), + filler(), + text("Nodes: " + std::to_string(model.nodes.size())), + separator(), + text("Pkts: " + std::to_string(model.stats.total_packets_sent)), + separator(), + text("Bytes: " + std::to_string(model.stats.total_bytes_sent)), + }); + }); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/hud.hh b/testing/netprof/views/hud.hh new file mode 100644 index 0000000000..b60b54c3f6 --- /dev/null +++ b/testing/netprof/views/hud.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_HUD_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_HUD_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the HUD (Heads-Up Display) component. + */ +ftxui::Component hud(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_HUD_HH diff --git a/testing/netprof/views/hud_test.cc b/testing/netprof/views/hud_test.cc new file mode 100644 index 0000000000..e0db4b4b24 --- /dev/null +++ b/testing/netprof/views/hud_test.cc @@ -0,0 +1,50 @@ +#include "hud.hh" + +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, HUDGraphEvolutionTest) +{ + { + ui_.emit(MsgTick{{1000, 1.0, 0, 0, true}}); + ui_.process_messages(); + auto screen + = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, views::hud(ui_.get_model())->Render()); + std::string out = screen.ToString(); + EXPECT_NE(out.find("T+00:00:01.000"), std::string::npos); + EXPECT_NE(out.find("PAUSED"), std::string::npos); + } + + { + ui_.emit(MsgTick{{5000, 1.0, 100, 1000, false}}); + ui_.process_messages(); + auto screen + = ftxui::Screen::Create(ftxui::Dimension::Fixed(150), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, views::hud(ui_.get_model())->Render()); + std::string out = screen.ToString(); + EXPECT_NE(out.find("T+00:00:05.000"), std::string::npos); + EXPECT_NE(out.find("RUNNING"), std::string::npos); + } +} + +TEST_F(NetProfUITest, SimulationSpeedCycle) +{ + ui_.emit(MsgTick{{0, 1.0, 0, 0, true}}); + ui_.process_messages(); + + for (int i = 0; i < 19; ++i) { + ui_.handle_event(ftxui::Event::Character('+')); + ui_.emit(MsgTick{{0, std::stod(last_command_.args[0]), 0, 0, true}}); + ui_.process_messages(); + } + + EXPECT_DOUBLE_EQ(ui_.get_model().stats.real_time_factor, 0.0); + + ui_.handle_event(ftxui::Event::Character('=')); + EXPECT_EQ(last_command_.type, CmdType::SetSpeed); + EXPECT_EQ(last_command_.args[0], "1.0"); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/inspector.cc b/testing/netprof/views/inspector.cc new file mode 100644 index 0000000000..40e466f7a8 --- /dev/null +++ b/testing/netprof/views/inspector.cc @@ -0,0 +1,335 @@ +#include "inspector.hh" + +#include +#include +#include +#include +#include +#include +#include + +#include "../../../toxcore/tox_private.h" +#include "../model_utils.hh" +#include "../packet_utils.hh" +#include "focusable.hh" + +namespace tox::netprof::views { + +using namespace ftxui; + +ftxui::Component inspector(const UIModel &model) +{ + return make_focusable(Renderer([&] { + int dimx = model.screen_width; + int dimy = model.screen_height; + + if (dimx <= 0 || dimy <= 0) { + auto terminal = ftxui::Terminal::Size(); + dimx = terminal.dimx; + dimy = terminal.dimy; + } + + if (dimx <= 0) + dimx = 100; + if (dimy <= 0) + dimy = 50; + + int avail_w = std::max(20, (dimx - 6) / 3); + int canvas_w = avail_w * 2; + + if (model.nodes.empty()) + return text("No nodes") | center; + + if (model.nodes.count(model.selected_node_id) == 0) { + return vbox({ + text("NETWORK DASHBOARD") | bold | hcenter, + separator(), + hbox({ + vbox({ + text("Fleet Status") | bold, + text("Total Nodes: " + std::to_string(model.nodes.size())), + text("Total Links: " + std::to_string(model.links.size())), + }) | flex, + separator(), + vbox({ + text("Aggregate Traffic") | bold, + text("Pkts: " + std::to_string(model.stats.total_packets_sent)), + text("Bytes: " + std::to_string(model.stats.total_bytes_sent)), + }) | flex, + }), + separator(), + text(" ๐Ÿงฌ GLOBAL PROTOCOL BREAKDOWN ") | bold | hcenter, + hbox({ + text(" Protocol") | size(WIDTH, GREATER_THAN, 22) | flex | bold, + text("Sent / Recv") | size(WIDTH, EQUAL, 25) | hcenter | bold, + }), + [&] { + Elements rows; + struct ProtoStat { + std::string name; + uint64_t sent; + uint64_t recv; + uint64_t total; + }; + std::vector stats; + + for (const auto &kv : model.stats.protocol_breakdown) { + uint64_t total = kv.second.sent + kv.second.recv; + if (total > 0) { + std::string name = get_packet_name( + static_cast(kv.first.protocol), + kv.first.id); + stats.push_back({name, kv.second.sent, kv.second.recv, total}); + } + } + + std::sort(stats.begin(), stats.end(), + [](const auto &a, const auto &b) { return a.total > b.total; }); + + for (const auto &s : stats) { + rows.push_back(hbox({ + text(" " + s.name) | size(WIDTH, GREATER_THAN, 22) | flex, + hbox({ + text(std::to_string(s.sent)) | color(Color::RedLight), + text(" / "), + text(std::to_string(s.recv)) | color(Color::GreenLight), + }) | size(WIDTH, EQUAL, 25) + | hcenter, + })); + } + if (rows.empty()) + return text("No traffic recorded") | dim | hcenter; + return vbox(std::move(rows)); + }(), + filler(), + }); + } + + const auto &n = model.nodes.at(model.selected_node_id); + + int max_val = 1024; + for (int v : n.bw_in_history) + max_val = std::max(max_val, v); + for (int v : n.bw_out_history) + max_val = std::max(max_val, v); + max_val = max_val * 11 / 10; + + int total_protocols = 0; + for (const auto &kv : n.protocol_breakdown) { + if (kv.second.sent + kv.second.recv > 0) + total_protocols++; + } + + // Layout constants for space estimation. + const int kIdentityHeight = 4; + const int kSeparatorHeight = 1; + const int kBandwidthHeight = 15; // 1 header + 12 graph + 2 border + const int kDHTActivityHeight = 10; // 1 header + 7 graph + 2 border + const int kProtocolHeaderHeight = 3; // Title + Column Headers + blank/sep + + // Fixed overhead: Identity + 2 separators + BW graph + Protocol headers. + const int kFixedOverhead = kIdentityHeight + kSeparatorHeight + kBandwidthHeight + + kSeparatorHeight + kProtocolHeaderHeight; + + // Effective available height for the inspector content. + // dimy - HUD(1) - Border(2) - Top Header(1) - Event Log(9) - Bottom(1) = dimy - 14. + int avail_h = std::max(0, dimy - 14); + int prot_space = std::max(0, avail_h - kFixedOverhead); + + // We want to show at least 10 protocols if possible. + // DHT fits if there's space for DHT(10) + baseline protocols(10). + bool show_dht_activity = (prot_space >= (kDHTActivityHeight + 10)); + + int protocols_to_show; + if (show_dht_activity) { + protocols_to_show = prot_space - (kDHTActivityHeight + kSeparatorHeight); + } else { + protocols_to_show = prot_space; + } + + // Account for the "... and X more" line if we're truncating. + if (total_protocols > protocols_to_show && protocols_to_show > 0) { + protocols_to_show -= 1; + } + + protocols_to_show = std::max(0, std::min(total_protocols, protocols_to_show)); + + Elements content; + + // Identity and DHT Status. + content.push_back(hbox(Elements({ + vbox(Elements({ + text(" ๐Ÿ‘ค Identity") | bold | color(Color::Yellow), + text(" ID: " + std::to_string(n.id)), + text(" Name: " + n.name), + text(n.is_online ? " Status: ONLINE" : " Status: OFFLINE") + | color(n.is_online ? Color::Green : Color::Red), + })) | flex, + separator(), + vbox(Elements({ + text(" ๐ŸŒ DHT Status") | bold | color(Color::Cyan), + hbox(Elements({ + text(" State: "), + [&]() { + switch (n.dht.connection_status) { + case TOX_CONNECTION_UDP: + return text("โ— ONLINE (UDP)") | bold | color(Color::Green); + case TOX_CONNECTION_TCP: + return text("โ— ONLINE (TCP)") | bold | color(Color::Yellow); + case TOX_CONNECTION_NONE: + return text("โ—‹ OFFLINE") | bold | color(Color::Red); + } + return text("UNKNOWN") | dim; + }(), + })), + text(" Nodes: " + std::to_string(n.dht.num_closelist)), + text(" Friends: " + std::to_string(n.dht.num_friends) + " (" + + std::to_string(n.dht.num_friends_udp) + " UDP, " + + std::to_string(n.dht.num_friends_tcp) + " TCP)"), + })) | flex, + }))); + + content.push_back(separator()); + + // Bandwidth Section. + content.push_back(vbox(Elements({ + hbox(Elements({ + text(" ๐Ÿ“Š Bandwidth (B/s) ") | bold, + text("(Max Y: " + std::to_string(max_val) + ")") | dim, + filler(), + text(" IN: " + std::to_string(static_cast(n.ema_bw_in))) | color(Color::Green), + text(" "), + text(" OUT: " + std::to_string(static_cast(n.ema_bw_out))) | color(Color::Red), + })), + vbox(Elements({ + canvas([&, max_val, canvas_w] { + auto c = Canvas(canvas_w, 40); + const auto &in_h = n.bw_in_history; + const auto &out_h = n.bw_out_history; + + auto draw_history = [&](const std::vector &h, Color col, int offset_y) { + if (h.size() < 2) + return; + int start_x = canvas_w - static_cast(h.size()); + for (size_t i = 0; i < h.size() - 1; ++i) { + int y1 = 18 - (h[i] * 18 / max_val); + int y2 = 18 - (h[i + 1] * 18 / max_val); + c.DrawPointLine(start_x + static_cast(i), y1 + offset_y, + start_x + static_cast(i) + 1, y2 + offset_y, col); + } + }; + + draw_history(in_h, Color::Green, 0); + draw_history(out_h, Color::Red, 21); + for (int x = 0; x < canvas_w; ++x) + c.DrawPoint(x, 20, Color::GrayDark); + return c; + }()) | size(HEIGHT, EQUAL, 12) + | border, + })), + }))); + + // DHT Activity Section (Conditional). + if (show_dht_activity) { + content.push_back(vbox(Elements({ + hbox(Elements({ + text(" ๐Ÿ” DHT Activity (Resp/tick) ") | bold, + filler(), + })), + vbox(Elements({ + canvas([&, canvas_w] { + auto c = Canvas(canvas_w, 20); + const auto &h = n.dht_response_history; + if (h.empty()) + return c; + int max_dht = 1; + for (int v : h) + max_dht = std::max(max_dht, v); + int start_x = canvas_w - static_cast(h.size()); + for (size_t i = 0; i < h.size() - 1; ++i) { + int y1 = 18 - (h[i] * 18 / max_dht); + int y2 = 18 - (h[i + 1] * 18 / max_dht); + c.DrawPointLine(start_x + static_cast(i), y1, + start_x + static_cast(i) + 1, y2, Color::Cyan); + } + return c; + }()) | size(HEIGHT, EQUAL, 7) + | border, + })), + }))); + } + + // Protocol Breakdown Section. + content.push_back(separator()); + content.push_back(text(" ๐Ÿงฌ PROTOCOL BREAKDOWN (Cumulative Bytes) ") | bold | hcenter); + content.push_back(hbox(Elements({ + text(" Protocol") | size(WIDTH, GREATER_THAN, 22) | flex | bold, + text("Sent / Recv") | size(WIDTH, EQUAL, 25) | hcenter | bold, + text("Share") | size(WIDTH, EQUAL, 15) | hcenter | bold, + }))); + + content.push_back([&] { + Elements rows; + uint64_t total_bytes = 0; + struct ProtoStat { + std::string name; + uint64_t sent; + uint64_t recv; + uint64_t total; + }; + std::vector stats; + + for (const auto &kv : n.protocol_breakdown) { + uint64_t node_total = kv.second.sent + kv.second.recv; + if (node_total > 0) { + std::string name = get_packet_name( + static_cast(kv.first.protocol), kv.first.id); + stats.push_back({name, kv.second.sent, kv.second.recv, node_total}); + total_bytes += node_total; + } + } + + std::sort(stats.begin(), stats.end(), + [](const auto &a, const auto &b) { return a.total > b.total; }); + + int count = 0; + for (const auto &s : stats) { + if (++count > protocols_to_show) + break; + float share = static_cast(s.total) / static_cast(total_bytes); + int bar_width = static_cast(share * 14.0f); + std::string bar_str; + for (int i = 0; i < bar_width; ++i) + bar_str += "โ–ˆ"; + rows.push_back(hbox(Elements({ + text(" " + s.name) | size(WIDTH, GREATER_THAN, 22) | flex, + hbox(Elements({ + text(std::to_string(s.sent)) | color(Color::RedLight), + text(" / "), + text(std::to_string(s.recv)) | color(Color::GreenLight), + })) | size(WIDTH, EQUAL, 25) + | hcenter, + hbox(Elements({ + text(bar_str) | color(Color::Cyan), + text(std::string(std::max(0, 14 - bar_width), ' ')) | dim, + })) | size(WIDTH, EQUAL, 15) + | color(Color::GrayDark), + }))); + } + if (rows.empty()) + return text("No traffic recorded") | dim | hcenter; + if (total_protocols > protocols_to_show) { + rows.push_back(text(" ... and " + + std::to_string(total_protocols - protocols_to_show) + " more") + | dim); + } + return vbox(std::move(rows)); + }()); + + content.push_back(filler()); + + return vbox(std::move(content)); + })); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/inspector.hh b/testing/netprof/views/inspector.hh new file mode 100644 index 0000000000..69a359e2a4 --- /dev/null +++ b/testing/netprof/views/inspector.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_INSPECTOR_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_INSPECTOR_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the Node Inspector component. + */ +ftxui::Component inspector(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_INSPECTOR_HH diff --git a/testing/netprof/views/inspector_test.cc b/testing/netprof/views/inspector_test.cc new file mode 100644 index 0000000000..a3007b31f2 --- /dev/null +++ b/testing/netprof/views/inspector_test.cc @@ -0,0 +1,92 @@ +#include "inspector.hh" + +#include "../../../toxcore/tox_private.h" +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, RenderInspectorDoesNotCrashOnEmptyHistory) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.process_messages(); + + auto element = views::inspector(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(40), ftxui::Dimension::Fixed(20)); + ftxui::Render(screen, element); +} + +TEST_F(NetProfUITest, InspectorShowsNodeInfo) +{ + ui_.emit(MsgNodeAdded{42, "BobtheBuilder"}); + ui_.process_messages(); + + auto element = views::inspector(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, element); + + std::string output = screen.ToString(); + EXPECT_NE(output.find("BobtheBuilder"), std::string::npos); + EXPECT_NE(output.find("ID: 42"), std::string::npos); +} + +TEST_F(NetProfUITest, InspectorResponsiveDHTActivity) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + std::map breakdown; + for (int i = 0; i < 20; ++i) { + breakdown[{TOX_NETPROF_PACKET_TYPE_UDP, static_cast(200 + i)}] + = {static_cast(1000 - i), 100}; + } + ui_.emit(MsgNodeStats{1, 0, 0, 0, 0, 0, 0, TOX_CONNECTION_UDP, true, false, 1, breakdown}); + ui_.process_messages(); + + { + ui_.emit(MsgResize{120, 100}); + ui_.process_messages(); + auto element = views::inspector(ui_.get_model())->Render(); + auto screen + = ftxui::Screen::Create(ftxui::Dimension::Fixed(120), ftxui::Dimension::Fixed(100)); + ftxui::Render(screen, element); + std::string out = screen.ToString(); + EXPECT_NE(out.find("DHT Activity"), std::string::npos); + EXPECT_NE(out.find("UDP 219"), std::string::npos); + } + + { + ui_.emit(MsgResize{120, 60}); + ui_.process_messages(); + auto element = views::inspector(ui_.get_model())->Render(); + auto screen + = ftxui::Screen::Create(ftxui::Dimension::Fixed(120), ftxui::Dimension::Fixed(46)); + ftxui::Render(screen, element); + std::string out = screen.ToString(); + EXPECT_NE(out.find("DHT Activity"), std::string::npos); + EXPECT_NE(out.find("UDP 209"), std::string::npos); + EXPECT_EQ(out.find("UDP 210"), std::string::npos); + EXPECT_NE(out.find("... and 10 more"), std::string::npos); + } +} + +TEST_F(NetProfUITest, ProtocolBreakdownInInspector) +{ + std::map breakdown + = {{{TOX_NETPROF_PACKET_TYPE_UDP, TOX_NETPROF_PACKET_ID_CRYPTO}, {400, 600}}, + {{TOX_NETPROF_PACKET_TYPE_UDP, TOX_NETPROF_PACKET_ID_ZERO}, {200, 300}}}; + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.emit(MsgNodeStats{1, 0, 0, 0, 0, 0, 0, TOX_CONNECTION_NONE, true, false, 1, breakdown}); + ui_.emit(MsgResize{100, 50}); + ui_.process_messages(); + + auto element = views::inspector(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(100), ftxui::Dimension::Fixed(50)); + ftxui::Render(screen, element); + std::string output = screen.ToString(); + + EXPECT_NE(output.find("PROTOCOL BREAKDOWN"), std::string::npos); + EXPECT_NE(output.find("Encrypted Data"), std::string::npos); + EXPECT_NE(output.find("400"), std::string::npos); + EXPECT_NE(output.find("600"), std::string::npos); + EXPECT_NE(output.find("Ping Req"), std::string::npos); +} + +} // namespace tox::netprof diff --git a/testing/netprof/views/topology.cc b/testing/netprof/views/topology.cc new file mode 100644 index 0000000000..b959a54af7 --- /dev/null +++ b/testing/netprof/views/topology.cc @@ -0,0 +1,223 @@ +#include "topology.hh" + +#include +#include +#include +#include + +#include "../model_utils.hh" +#include "focusable.hh" + +namespace tox::netprof::views { + +using namespace ftxui; + +static void draw_links(Canvas &c, const UIModel &model) +{ + float sx = static_cast(c.width()) / 100.0f; + float sy = static_cast(c.height()) / 100.0f; + + for (const auto &link : model.links) { + if (!model.nodes.count(link.from) || !model.nodes.count(link.to)) + continue; + auto &n1 = model.nodes.at(link.from); + auto &n2 = model.nodes.at(link.to); + + Color color = link.connected ? Color::Green : Color::Red; + if (link.connected && link.latency_ms > 100) + color = Color::Yellow; + if (link.connected && link.latency_ms > 300) + color = Color::Red; + + c.DrawPointLine(static_cast(n1.x * sx), static_cast(n1.y * sy), + static_cast(n2.x * sx), static_cast(n2.y * sy), color); + + if (link.congestion > 0.5f) { + c.DrawPointLine(static_cast(n1.x * sx) + 1, static_cast(n1.y * sy), + static_cast(n2.x * sx) + 1, static_cast(n2.y * sy), color); + } + if (link.congestion > 0.8f) { + c.DrawPointLine(static_cast(n1.x * sx), static_cast(n1.y * sy) + 1, + static_cast(n2.x * sx), static_cast(n2.y * sy) + 1, color); + } + } +} + +static void draw_nodes(Canvas &c, const UIModel &model) +{ + float sx = static_cast(c.width()) / 100.0f; + float sy = static_cast(c.height()) / 100.0f; + + for (const auto &kv : model.nodes) { + const auto &n = kv.second; + bool selected = (n.id == model.selected_node_id); + bool marked = (n.id == model.marked_node_id); + Color color = selected ? Color::Cyan : Color::White; + if (marked) + color = Color::Blue; + + if (!n.is_online) { + color = Color::GrayDark; + } else if (model.layer_mode == LayerMode::TrafficType) { + switch (get_dominant_traffic_category(n)) { + case TrafficCategory::DHT: + color = Color::Cyan; + break; + case TrafficCategory::Data: + color = Color::Magenta; + break; + case TrafficCategory::Onion: + color = Color::Yellow; + break; + case TrafficCategory::None: + break; + } + } + + c.DrawPointCircle(static_cast(n.x * sx), static_cast(n.y * sy), 3, color); + if (n.is_pinned) { + c.DrawPointCircle(static_cast(n.x * sx), static_cast(n.y * sy), 4, color); + } + + if (marked) { + c.DrawPointCircle( + static_cast(n.x * sx), static_cast(n.y * sy), 5, Color::Blue); + } + + std::string label = n.name; + if (n.is_pinned) { + label += " [P]"; + } + + c.DrawText( + static_cast(n.x * sx) - 2, static_cast(n.y * sy) - 5, label, [&](Pixel &p) { + p.foreground_color = Color::White; + if (selected) { + p.bold = true; + p.underlined = true; + } + }); + } +} + +static void draw_dht_interactions(Canvas &c, const UIModel &model) +{ + float sx = static_cast(c.width()) / 100.0f; + float sy = static_cast(c.height()) / 100.0f; + + for (const auto &kv : model.dht_interactions) { + const auto &key = kv.first; + uint64_t timestamp_ms = kv.second; + + if (!model.nodes.count(key.id1) || !model.nodes.count(key.id2)) + continue; + + if (key.is_discovery && !model.show_dht_discovery_lines) + continue; + if (!key.is_discovery && !model.show_dht_responder_lines) + continue; + + const auto &n1 = model.nodes.at(key.id1); + const auto &n2 = model.nodes.at(key.id2); + + uint64_t diff = 0; + if (model.stats.virtual_time_ms > timestamp_ms) { + diff = model.stats.virtual_time_ms - timestamp_ms; + } + uint8_t brightness + = static_cast(std::max(0, 255 - static_cast(diff * 255 / 1000))); + + Color col; + if (key.is_discovery) { + col = Color::RGB(brightness, brightness, 0); // Yellow for discovery + } else { + col = Color::RGB(0, brightness, brightness); // Cyan for interaction + } + + c.DrawPointLine(static_cast(n1.x * sx), static_cast(n1.y * sy), + static_cast(n2.x * sx), static_cast(n2.y * sy), col); + } +} + +static void draw_preview_line(Canvas &c, const UIModel &model) +{ + if (model.marked_node_id == 0 || !model.nodes.count(model.marked_node_id)) { + return; + } + + float sx = static_cast(c.width()) / 100.0f; + float sy = static_cast(c.height()) / 100.0f; + + const auto &n1 = model.nodes.at(model.marked_node_id); + int target_x, target_y; + + if (model.cursor_mode) { + target_x = static_cast(static_cast(model.cursor_x) * sx); + target_y = static_cast(static_cast(model.cursor_y) * sy); + } else if (model.nodes.count(model.selected_node_id)) { + target_x = static_cast(model.nodes.at(model.selected_node_id).x * sx); + target_y = static_cast(model.nodes.at(model.selected_node_id).y * sy); + } else { + return; + } + + if (n1.id != model.selected_node_id || model.cursor_mode) { + c.DrawPointLine(static_cast(n1.x * sx), static_cast(n1.y * sy), target_x, + target_y, Color::BlueLight); + } +} + +static void draw_cursor(Canvas &c, const UIModel &model) +{ + if (model.cursor_mode) { + float sx = static_cast(c.width()) / 100.0f; + float sy = static_cast(c.height()) / 100.0f; + + int cx = static_cast(static_cast(model.cursor_x) * sx); + int cy = static_cast(static_cast(model.cursor_y) * sy); + + c.DrawPointLine(cx - 2, cy, cx + 2, cy, Color::Yellow); + c.DrawPointLine(cx, cy - 2, cx, cy + 2, Color::Yellow); + } +} + +ftxui::Component topology(const UIModel &model) +{ + auto component = Renderer([&] { + int dimx = model.screen_width; + int dimy = model.screen_height; + + if (dimx <= 0 || dimy <= 0) { + auto terminal = ftxui::Terminal::Size(); + dimx = terminal.dimx; + dimy = terminal.dimy; + } + + if (dimx <= 0) + dimx = 200; + if (dimy <= 0) + dimy = 60; + + int avail_w = std::max(20, (dimx - 6) / 3); + int avail_h = std::max(20, dimy - 18); + + int canvas_w = avail_w * 2; + int canvas_h = avail_h * 4; + + auto c = Canvas(canvas_w, canvas_h); + + draw_links(c, model); + draw_nodes(c, model); + if (model.show_dht_interactions_physical) { + draw_dht_interactions(c, model); + } + draw_preview_line(c, model); + draw_cursor(c, model); + + return canvas(std::move(c)) | flex; + }); + + return make_focusable(component); +} + +} // namespace tox::netprof::views diff --git a/testing/netprof/views/topology.hh b/testing/netprof/views/topology.hh new file mode 100644 index 0000000000..2eb0a755a9 --- /dev/null +++ b/testing/netprof/views/topology.hh @@ -0,0 +1,17 @@ +#ifndef C_TOXCORE_TESTING_NETPROF_VIEWS_TOPOLOGY_HH +#define C_TOXCORE_TESTING_NETPROF_VIEWS_TOPOLOGY_HH + +#include + +#include "../model.hh" + +namespace tox::netprof::views { + +/** + * @brief Creates the Physical Topology component. + */ +ftxui::Component topology(const UIModel &model); + +} // namespace tox::netprof::views + +#endif // C_TOXCORE_TESTING_NETPROF_VIEWS_TOPOLOGY_HH diff --git a/testing/netprof/views/topology_test.cc b/testing/netprof/views/topology_test.cc new file mode 100644 index 0000000000..61d9097368 --- /dev/null +++ b/testing/netprof/views/topology_test.cc @@ -0,0 +1,222 @@ +#include "topology.hh" + +#include "../ui_test_support.hh" + +namespace tox::netprof { + +TEST_F(NetProfUITest, CursorModeToggle) +{ + ui_.get_topology_comp()->TakeFocus(); + EXPECT_TRUE(ui_.get_topology_comp()->Focused()); + + EXPECT_FALSE(ui_.get_model().cursor_mode); + ui_.handle_event(ftxui::Event::Character('c')); + EXPECT_TRUE(ui_.get_model().cursor_mode); + ui_.handle_event(ftxui::Event::Character('c')); + EXPECT_FALSE(ui_.get_model().cursor_mode); +} + +TEST_F(NetProfUITest, LayerToggleShortcut) +{ + ui_.get_topology_comp()->TakeFocus(); + EXPECT_EQ(ui_.get_model().layer_mode, LayerMode::Normal); + ui_.handle_event(ftxui::Event::Character('l')); + EXPECT_EQ(ui_.get_model().layer_mode, LayerMode::TrafficType); + ui_.handle_event(ftxui::Event::Character('l')); + EXPECT_EQ(ui_.get_model().layer_mode, LayerMode::Normal); +} + +TEST_F(NetProfUITest, LayerCommandExecution) +{ + EXPECT_EQ(ui_.get_model().layer_mode, LayerMode::Normal); + + ui_.execute_command("layer traffic"); + EXPECT_EQ(ui_.get_model().layer_mode, LayerMode::TrafficType); + + ui_.execute_command(" layer normal "); + EXPECT_EQ(ui_.get_model().layer_mode, LayerMode::Normal); +} + +TEST_F(NetProfUITest, PinnedNodeVisualIndicator) +{ + ui_.emit(MsgNodeAdded{1, "Alice", 50, 50}); + ui_.emit(MsgNodeStats{1, 0, 0, 0, 0, 0, 0, TOX_CONNECTION_NONE, true, true, 1, {}}); + ui_.process_messages(); + + auto element = views::topology(ui_.get_model())->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(200), ftxui::Dimension::Fixed(60)); + ftxui::Render(screen, element); + + std::string output = screen.ToString(); + EXPECT_NE(output.find("Alice [P]"), std::string::npos); +} + +TEST_F(NetProfUITest, GrabAndDragNode) +{ + ui_.get_topology_comp()->TakeFocus(); + ui_.emit(MsgNodeAdded{1, "Alice", 20.0f, 20.0f}); + ui_.process_messages(); + + ui_.handle_event(ftxui::Event::Character('c')); + ui_.handle_event(ftxui::Event::Character('g')); + + ui_.handle_event(ftxui::Event::ArrowDown); + ui_.process_messages(); + + EXPECT_EQ(last_command_.type, CmdType::MoveNode); + ASSERT_EQ(last_command_.args.size(), 3u); + EXPECT_EQ(last_command_.args[0], "1"); + + ui_.handle_event(ftxui::Event::Character('g')); + EXPECT_FALSE(ui_.get_model().grab_mode); +} + +TEST_F(NetProfUITest, DirectionalNavigation) +{ + ui_.emit(MsgNodeAdded{1, "Center", 50, 50}); + ui_.emit(MsgNodeAdded{2, "Left", 20, 50}); + ui_.emit(MsgNodeAdded{3, "Right", 80, 50}); + ui_.emit(MsgNodeAdded{4, "Up", 50, 20}); + ui_.emit(MsgNodeAdded{5, "Down", 50, 80}); + ui_.process_messages(); + + ui_.select_node_in_direction(0, 0); + EXPECT_EQ(ui_.get_model().selected_node_id, 1u); + + ui_.select_node_in_direction(1, 0); + EXPECT_EQ(ui_.get_model().selected_node_id, 3u); + + ui_.select_node_in_direction(-1, 0); + EXPECT_EQ(ui_.get_model().selected_node_id, 1u); +} + +TEST_F(NetProfUITest, DHTInteractionBidirectionalDedupeTest) +{ + ui_.emit(MsgNodeAdded{1, "Alice", 10, 10}); + ui_.emit(MsgNodeAdded{2, "Bob", 90, 90}); + ui_.process_messages(); + + ui_.emit(MsgDHTResponse{1, 2}); + ui_.process_messages(); + UIModel::InteractionKey key{1, 2, false}; + ASSERT_TRUE(ui_.get_model().dht_interactions.count(key)); + + GlobalStats stats; + stats.virtual_time_ms = 500; + ui_.emit(MsgTick{stats}); + ui_.process_messages(); + + ui_.emit(MsgDHTResponse{2, 1}); + ui_.process_messages(); + + EXPECT_EQ(ui_.get_model().dht_interactions.size(), 1u); + EXPECT_EQ(ui_.get_model().dht_interactions.at(key), 500u); +} + +TEST_F(NetProfUITest, DHTInteractionRenderingTest) +{ + ui_.emit(MsgNodeAdded{1, "Alice", 10, 10}); + ui_.emit(MsgNodeAdded{2, "Bob", 90, 90}); + ui_.emit(MsgDHTResponse{1, 2}); + ui_.process_messages(); + + UIModel model = ui_.get_model(); + model.show_dht_interactions_physical = true; + model.screen_width = 200; + model.screen_height = 100; + + auto element = views::topology(model)->Render(); + auto screen = ftxui::Screen::Create(ftxui::Dimension::Fixed(200), ftxui::Dimension::Fixed(100)); + ftxui::Render(screen, element); + + bool found_interaction_line = false; + for (int y = 20; y < 80; ++y) { + for (int x = 20; x < 180; ++x) { + if (screen.at(x, y) != " " && screen.at(x, y) != "ยท") { + found_interaction_line = true; + break; + } + } + } + EXPECT_TRUE(found_interaction_line); +} + +TEST_F(NetProfUITest, DeleteNodeSelectsNearest) +{ + ui_.emit(MsgNodeAdded{1, "Alice", 20.0f, 50.0f}); + ui_.emit(MsgNodeAdded{2, "Bob", 50.0f, 50.0f}); + ui_.emit(MsgNodeAdded{3, "Charlie", 80.0f, 50.0f}); + ui_.process_messages(); + + ui_.select_node_in_direction(1, 0); + EXPECT_EQ(ui_.get_model().selected_node_id, 2u); + + ui_.emit(MsgNodeRemoved{2}); + ui_.process_messages(); + + EXPECT_NE(ui_.get_model().selected_node_id, 0u); + EXPECT_TRUE(ui_.get_model().selected_node_id == 1u || ui_.get_model().selected_node_id == 3u); +} + +TEST_F(NetProfUITest, DeleteMarkedNodeClearsMark) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.emit(MsgNodeAdded{2, "Bob"}); + ui_.process_messages(); + + ui_.select_node_in_direction(0, 0); + ui_.handle_event(ftxui::Event::Character('f')); + EXPECT_EQ(ui_.get_model().marked_node_id, 1u); + + ui_.emit(MsgNodeRemoved{1}); + ui_.process_messages(); + + EXPECT_EQ(ui_.get_model().marked_node_id, 0u); +} + +TEST_F(NetProfUITest, DisconnectNodesRemovesLink) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.emit(MsgNodeAdded{2, "Bob"}); + ui_.emit(MsgLinkUpdated{1, 2, true, 20, 0.0}); + ui_.process_messages(); + + EXPECT_EQ(ui_.get_model().links.size(), 1u); + + ui_.emit(MsgLinkUpdated{1, 2, false, 0, 0.0}); + ui_.process_messages(); + + EXPECT_EQ(ui_.get_model().links.size(), 0u); +} + +TEST_F(NetProfUITest, DeleteNodeRemovesLinks) +{ + ui_.emit(MsgNodeAdded{1, "Alice"}); + ui_.emit(MsgNodeAdded{2, "Bob"}); + ui_.emit(MsgLinkUpdated{1, 2, true, 20, 0.0}); + ui_.process_messages(); + + ASSERT_EQ(ui_.get_model().links.size(), 1u); + + ui_.emit(MsgNodeRemoved{1}); + ui_.process_messages(); + + EXPECT_EQ(ui_.get_model().links.size(), 0u); +} + +TEST_F(NetProfUITest, NavigationToNodeZero) +{ + ui_.emit(MsgNodeAdded{1, "Alice", 20, 50}); + ui_.emit(MsgNodeAdded{2, "Bob", 80, 50}); + ui_.process_messages(); + + EXPECT_EQ(ui_.get_model().selected_node_id, 1u); + + ui_.select_node_in_direction(1, 0); + EXPECT_EQ(ui_.get_model().selected_node_id, 2u); + + ui_.select_node_in_direction(-1, 0); + EXPECT_EQ(ui_.get_model().selected_node_id, 1u); +} + +} // namespace tox::netprof diff --git a/testing/support/doubles/fake_sockets.cc b/testing/support/doubles/fake_sockets.cc index 87863abd1c..8e1c8b6ce4 100644 --- a/testing/support/doubles/fake_sockets.cc +++ b/testing/support/doubles/fake_sockets.cc @@ -661,6 +661,7 @@ bool FakeTcpSocket::handle_packet(const Packet &p) } } return true; + } } return false; diff --git a/toxcore/DHT.c b/toxcore/DHT.c index 318f8bd6f3..1d570f0d0d 100644 --- a/toxcore/DHT.c +++ b/toxcore/DHT.c @@ -1520,7 +1520,7 @@ static int handle_nodes_response(void *_Nonnull object, const IP_Port *_Nonnull returnedip_ports(dht, &plain_nodes[i].ip_port, plain_nodes[i].public_key, packet + 1); if (dht->nodes_response_callback != nullptr) { - dht->nodes_response_callback(dht, &plain_nodes[i], userdata); + dht->nodes_response_callback(dht, packet + 1, &plain_nodes[i], userdata); } } } diff --git a/toxcore/DHT.h b/toxcore/DHT.h index 634bff2630..d6443da754 100644 --- a/toxcore/DHT.h +++ b/toxcore/DHT.h @@ -244,7 +244,7 @@ bool dht_send_nodes_request(DHT *_Nonnull dht, const IP_Port *_Nonnull ip_port, typedef void dht_ip_cb(void *_Nullable object, int32_t number, const IP_Port *_Nonnull ip_port); -typedef void dht_nodes_response_cb(const DHT *_Nonnull dht, const Node_format *_Nonnull node, void *_Nullable user_data); +typedef void dht_nodes_response_cb(const DHT *_Nonnull dht, const uint8_t *_Nonnull responder_public_key, const Node_format *_Nonnull node, void *_Nullable user_data); /** Sets the callback to be triggered on a nodes response. */ void dht_callback_nodes_response(DHT *_Nonnull dht, dht_nodes_response_cb *_Nullable function); diff --git a/toxcore/events/dht_nodes_response.c b/toxcore/events/dht_nodes_response.c index d2dd58507e..fd37764679 100644 --- a/toxcore/events/dht_nodes_response.c +++ b/toxcore/events/dht_nodes_response.c @@ -24,12 +24,25 @@ *****************************************************/ struct Tox_Event_Dht_Nodes_Response { + uint8_t responder_public_key[TOX_PUBLIC_KEY_SIZE]; uint8_t public_key[TOX_PUBLIC_KEY_SIZE]; char *_Nullable ip; uint32_t ip_length; uint16_t port; }; +static bool tox_event_dht_nodes_response_set_responder_public_key(Tox_Event_Dht_Nodes_Response *_Nonnull dht_nodes_response, const uint8_t responder_public_key[TOX_PUBLIC_KEY_SIZE]) +{ + assert(dht_nodes_response != nullptr); + memcpy(dht_nodes_response->responder_public_key, responder_public_key, TOX_PUBLIC_KEY_SIZE); + return true; +} +const uint8_t *tox_event_dht_nodes_response_get_responder_public_key(const Tox_Event_Dht_Nodes_Response *dht_nodes_response) +{ + assert(dht_nodes_response != nullptr); + return dht_nodes_response->responder_public_key; +} + static bool tox_event_dht_nodes_response_set_public_key(Tox_Event_Dht_Nodes_Response *_Nonnull dht_nodes_response, const uint8_t public_key[TOX_PUBLIC_KEY_SIZE]) { assert(dht_nodes_response != nullptr); @@ -111,7 +124,8 @@ static void tox_event_dht_nodes_response_destruct(Tox_Event_Dht_Nodes_Response * bool tox_event_dht_nodes_response_pack( const Tox_Event_Dht_Nodes_Response *event, Bin_Pack *bp) { - return bin_pack_array(bp, 3) + return bin_pack_array(bp, 4) + && bin_pack_bin(bp, event->responder_public_key, TOX_PUBLIC_KEY_SIZE) && bin_pack_bin(bp, event->public_key, TOX_PUBLIC_KEY_SIZE) && bin_pack_str(bp, event->ip, event->ip_length) && bin_pack_u16(bp, event->port); @@ -120,11 +134,12 @@ bool tox_event_dht_nodes_response_pack( static bool tox_event_dht_nodes_response_unpack_into(Tox_Event_Dht_Nodes_Response *_Nonnull event, Bin_Unpack *_Nonnull bu) { assert(event != nullptr); - if (!bin_unpack_array_fixed(bu, 3, nullptr)) { + if (!bin_unpack_array_fixed(bu, 4, nullptr)) { return false; } - return bin_unpack_bin_fixed(bu, event->public_key, TOX_PUBLIC_KEY_SIZE) + return bin_unpack_bin_fixed(bu, event->responder_public_key, TOX_PUBLIC_KEY_SIZE) + && bin_unpack_bin_fixed(bu, event->public_key, TOX_PUBLIC_KEY_SIZE) && bin_unpack_str(bu, &event->ip, &event->ip_length) && bin_unpack_u16(bu, &event->port); } @@ -218,6 +233,7 @@ static Tox_Event_Dht_Nodes_Response *tox_event_dht_nodes_response_alloc(Tox_Even void tox_events_handle_dht_nodes_response( Tox *tox, + const uint8_t *responder_public_key, const uint8_t *public_key, const char *ip, uint32_t ip_length, uint16_t port, @@ -230,6 +246,7 @@ void tox_events_handle_dht_nodes_response( return; } + tox_event_dht_nodes_response_set_responder_public_key(dht_nodes_response, responder_public_key); tox_event_dht_nodes_response_set_public_key(dht_nodes_response, public_key); if (!tox_event_dht_nodes_response_set_ip(dht_nodes_response, state->mem, ip, ip_length)) { state->error = TOX_ERR_EVENTS_ITERATE_MALLOC; @@ -244,6 +261,7 @@ void tox_events_handle_dht_nodes_response_dispatch(Tox *tox, const Tox_Event_Dht } tox_unlock(tox); - tox->dht_nodes_response_callback(tox, event->public_key, (const char *)event->ip, event->ip_length, event->port, user_data); + tox->dht_nodes_response_callback(tox, event->responder_public_key, event->public_key, (const char *)event->ip, + event->ip_length, event->port, user_data); tox_lock(tox); } diff --git a/toxcore/net_profile.c b/toxcore/net_profile.c index 6d9b2dd346..1a3b3d6a78 100644 --- a/toxcore/net_profile.c +++ b/toxcore/net_profile.c @@ -16,8 +16,6 @@ #include "ccompat.h" -#define NETPROF_TCP_DATA_PACKET_ID 0x10 - typedef struct Net_Profile { uint64_t packets_recv[NET_PROF_MAX_PACKET_IDS]; uint64_t packets_sent[NET_PROF_MAX_PACKET_IDS]; @@ -32,9 +30,11 @@ typedef struct Net_Profile { uint64_t total_bytes_sent; } Net_Profile; -/** Returns the number of sent or received packets for all ID's between `start_id` and `end_id`. */ -static uint64_t netprof_get_packet_count_id_range(const Net_Profile *_Nullable profile, uint8_t start_id, uint8_t end_id, - Packet_Direction dir) +/** + * Returns the number of sent or received packets for the given ID range. + */ +uint64_t netprof_get_packet_count_range(const Net_Profile *_Nullable profile, uint8_t start_id, uint8_t end_id, + Packet_Direction dir) { if (profile == nullptr) { return 0; @@ -50,9 +50,11 @@ static uint64_t netprof_get_packet_count_id_range(const Net_Profile *_Nullable p return count; } -/** Returns the number of sent or received bytes for all ID's between `start_id` and `end_id`. */ -static uint64_t netprof_get_bytes_id_range(const Net_Profile *_Nullable profile, uint8_t start_id, uint8_t end_id, - Packet_Direction dir) +/** + * Returns the number of bytes sent or received for the given ID range. + */ +uint64_t netprof_get_bytes_range(const Net_Profile *_Nullable profile, uint8_t start_id, uint8_t end_id, + Packet_Direction dir) { if (profile == nullptr) { return 0; @@ -95,11 +97,6 @@ uint64_t netprof_get_packet_count_id(const Net_Profile *profile, uint8_t id, Pac return 0; } - // Special case - TCP data packets can have any ID between 0x10 and 0xff - if (id == NETPROF_TCP_DATA_PACKET_ID) { - return netprof_get_packet_count_id_range(profile, id, UINT8_MAX, dir); - } - return dir == PACKET_DIRECTION_SEND ? profile->packets_sent[id] : profile->packets_recv[id]; } @@ -118,11 +115,6 @@ uint64_t netprof_get_bytes_id(const Net_Profile *profile, uint8_t id, Packet_Dir return 0; } - // Special case - TCP data packets can have any ID between 0x10 and 0xff - if (id == NETPROF_TCP_DATA_PACKET_ID) { - return netprof_get_bytes_id_range(profile, id, 0xff, dir); - } - return dir == PACKET_DIRECTION_SEND ? profile->bytes_sent[id] : profile->bytes_recv[id]; } diff --git a/toxcore/net_profile.h b/toxcore/net_profile.h index b95070bfce..b6adca2804 100644 --- a/toxcore/net_profile.h +++ b/toxcore/net_profile.h @@ -39,6 +39,11 @@ void netprof_record_packet(Net_Profile *_Nullable profile, uint8_t id, size_t le * Returns the number of sent or received packets of type `id` for the given profile. */ uint64_t netprof_get_packet_count_id(const Net_Profile *_Nullable profile, uint8_t id, Packet_Direction dir); +/** + * Returns the number of sent or received packets for the given ID range. + */ +uint64_t netprof_get_packet_count_range(const Net_Profile *_Nullable profile, uint8_t start_id, uint8_t end_id, + Packet_Direction dir); /** * Returns the total number of sent or received packets for the given profile. */ @@ -47,6 +52,11 @@ uint64_t netprof_get_packet_count_total(const Net_Profile *_Nullable profile, Pa * Returns the number of bytes sent or received of packet type `id` for the given profile. */ uint64_t netprof_get_bytes_id(const Net_Profile *_Nullable profile, uint8_t id, Packet_Direction dir); +/** + * Returns the number of bytes sent or received for the given ID range. + */ +uint64_t netprof_get_bytes_range(const Net_Profile *_Nullable profile, uint8_t start_id, uint8_t end_id, + Packet_Direction dir); /** * Returns the total number of bytes sent or received for the given profile. */ diff --git a/toxcore/tox.c b/toxcore/tox.c index c03b4ba08d..ac0253e0c4 100644 --- a/toxcore/tox.c +++ b/toxcore/tox.c @@ -325,7 +325,7 @@ static void tox_conference_peer_list_changed_handler(Messenger *_Nonnull m, uint } static dht_nodes_response_cb tox_dht_nodes_response_handler; -static void tox_dht_nodes_response_handler(const DHT *_Nonnull dht, const Node_format *_Nonnull node, void *_Nullable user_data) +static void tox_dht_nodes_response_handler(const DHT *_Nonnull dht, const uint8_t *_Nonnull responder_public_key, const Node_format *_Nonnull node, void *_Nullable user_data) { struct Tox_Userdata *tox_data = (struct Tox_Userdata *)user_data; if (tox_data->tox->dht_nodes_response_callback == nullptr) { @@ -337,7 +337,7 @@ static void tox_dht_nodes_response_handler(const DHT *_Nonnull dht, const Node_f tox_unlock(tox_data->tox); tox_data->tox->dht_nodes_response_callback( - tox_data->tox, node->public_key, ip_str.buf, ip_str.length, net_ntohs(node->ip_port.port), + tox_data->tox, responder_public_key, node->public_key, ip_str.buf, ip_str.length, net_ntohs(node->ip_port.port), tox_data->user_data); tox_lock(tox_data->tox); } diff --git a/toxcore/tox_api.c b/toxcore/tox_api.c index 704ccdc293..7e1f074ab6 100644 --- a/toxcore/tox_api.c +++ b/toxcore/tox_api.c @@ -1558,6 +1558,10 @@ const char *tox_netprof_packet_id_to_string(Tox_Netprof_Packet_Id value) return "TOX_NETPROF_PACKET_ID_TCP_ONION_REQUEST"; case TOX_NETPROF_PACKET_ID_TCP_ONION_RESPONSE: return "TOX_NETPROF_PACKET_ID_TCP_ONION_RESPONSE"; + case TOX_NETPROF_PACKET_ID_TCP_FORWARD_REQUEST: + return "TOX_NETPROF_PACKET_ID_TCP_FORWARD_REQUEST"; + case TOX_NETPROF_PACKET_ID_TCP_FORWARDING: + return "TOX_NETPROF_PACKET_ID_TCP_FORWARDING"; case TOX_NETPROF_PACKET_ID_TCP_DATA: return "TOX_NETPROF_PACKET_ID_TCP_DATA"; case TOX_NETPROF_PACKET_ID_COOKIE_REQUEST: diff --git a/toxcore/tox_events.h b/toxcore/tox_events.h index beabe5fdf4..3a6540cd76 100644 --- a/toxcore/tox_events.h +++ b/toxcore/tox_events.h @@ -357,6 +357,8 @@ Tox_Group_Mod_Event tox_event_group_moderation_get_mod_type( const Tox_Event_Group_Moderation *_Nonnull group_moderation); typedef struct Tox_Event_Dht_Nodes_Response Tox_Event_Dht_Nodes_Response; +const uint8_t *_Nonnull tox_event_dht_nodes_response_get_responder_public_key( + const Tox_Event_Dht_Nodes_Response *_Nonnull dht_nodes_response); const uint8_t *_Nonnull tox_event_dht_nodes_response_get_public_key( const Tox_Event_Dht_Nodes_Response *_Nonnull dht_nodes_response); const char *_Nullable tox_event_dht_nodes_response_get_ip( diff --git a/toxcore/tox_private.c b/toxcore/tox_private.c index 303e2d471a..39e7ef5ed6 100644 --- a/toxcore/tox_private.c +++ b/toxcore/tox_private.c @@ -234,6 +234,20 @@ bool tox_group_peer_get_ip_address(const Tox *tox, uint32_t group_number, uint32 return true; } +static uint64_t netprof_get_id_count(const Net_Profile *_Nullable profile, uint8_t id, Packet_Direction dir, + Tox_Netprof_Packet_Type type) +{ + if (profile == nullptr) { + return 0; + } + + if (id == TOX_NETPROF_PACKET_ID_TCP_DATA && type != TOX_NETPROF_PACKET_TYPE_UDP) { + return netprof_get_packet_count_range(profile, id, UINT8_MAX, dir); + } + + return netprof_get_packet_count_id(profile, id, dir); +} + uint64_t tox_netprof_get_packet_id_count(const Tox *tox, Tox_Netprof_Packet_Type type, uint8_t id, Tox_Netprof_Direction direction) { @@ -250,25 +264,25 @@ uint64_t tox_netprof_get_packet_id_count(const Tox *tox, Tox_Netprof_Packet_Type switch (type) { case TOX_NETPROF_PACKET_TYPE_TCP_CLIENT: { - count = netprof_get_packet_count_id(tcp_c_profile, id, dir); + count = netprof_get_id_count(tcp_c_profile, id, dir, type); break; } case TOX_NETPROF_PACKET_TYPE_TCP_SERVER: { - count = netprof_get_packet_count_id(tcp_s_profile, id, dir); + count = netprof_get_id_count(tcp_s_profile, id, dir, type); break; } case TOX_NETPROF_PACKET_TYPE_TCP: { - const uint64_t tcp_c_count = netprof_get_packet_count_id(tcp_c_profile, id, dir); - const uint64_t tcp_s_count = netprof_get_packet_count_id(tcp_s_profile, id, dir); + const uint64_t tcp_c_count = netprof_get_id_count(tcp_c_profile, id, dir, type); + const uint64_t tcp_s_count = netprof_get_id_count(tcp_s_profile, id, dir, type); count = tcp_c_count + tcp_s_count; break; } case TOX_NETPROF_PACKET_TYPE_UDP: { const Net_Profile *udp_profile = net_get_net_profile(tox->m->net); - count = netprof_get_packet_count_id(udp_profile, id, dir); + count = netprof_get_id_count(udp_profile, id, dir, type); break; } @@ -332,6 +346,20 @@ uint64_t tox_netprof_get_packet_total_count(const Tox *tox, Tox_Netprof_Packet_T return count; } +static uint64_t netprof_get_id_bytes(const Net_Profile *_Nullable profile, uint8_t id, Packet_Direction dir, + Tox_Netprof_Packet_Type type) +{ + if (profile == nullptr) { + return 0; + } + + if (id == TOX_NETPROF_PACKET_ID_TCP_DATA && type != TOX_NETPROF_PACKET_TYPE_UDP) { + return netprof_get_bytes_range(profile, id, UINT8_MAX, dir); + } + + return netprof_get_bytes_id(profile, id, dir); +} + uint64_t tox_netprof_get_packet_id_bytes(const Tox *tox, Tox_Netprof_Packet_Type type, uint8_t id, Tox_Netprof_Direction direction) { @@ -348,25 +376,25 @@ uint64_t tox_netprof_get_packet_id_bytes(const Tox *tox, Tox_Netprof_Packet_Type switch (type) { case TOX_NETPROF_PACKET_TYPE_TCP_CLIENT: { - bytes = netprof_get_bytes_id(tcp_c_profile, id, dir); + bytes = netprof_get_id_bytes(tcp_c_profile, id, dir, type); break; } case TOX_NETPROF_PACKET_TYPE_TCP_SERVER: { - bytes = netprof_get_bytes_id(tcp_s_profile, id, dir); + bytes = netprof_get_id_bytes(tcp_s_profile, id, dir, type); break; } case TOX_NETPROF_PACKET_TYPE_TCP: { - const uint64_t tcp_c_bytes = netprof_get_bytes_id(tcp_c_profile, id, dir); - const uint64_t tcp_s_bytes = netprof_get_bytes_id(tcp_s_profile, id, dir); + const uint64_t tcp_c_bytes = netprof_get_id_bytes(tcp_c_profile, id, dir, type); + const uint64_t tcp_s_bytes = netprof_get_id_bytes(tcp_s_profile, id, dir, type); bytes = tcp_c_bytes + tcp_s_bytes; break; } case TOX_NETPROF_PACKET_TYPE_UDP: { const Net_Profile *udp_profile = net_get_net_profile(tox->m->net); - bytes = netprof_get_bytes_id(udp_profile, id, dir); + bytes = netprof_get_id_bytes(udp_profile, id, dir, type); break; } diff --git a/toxcore/tox_private.h b/toxcore/tox_private.h index 3796f2df38..0623356b46 100644 --- a/toxcore/tox_private.h +++ b/toxcore/tox_private.h @@ -94,13 +94,14 @@ uint32_t tox_dht_node_ip_string_size(void); uint32_t tox_dht_node_public_key_size(void); /** + * @param responder_public_key The public key of the node that sent the response. * @param public_key The node's public key. * @param ip The node's IP address, represented as a NUL-terminated C string. * @param port The node's port. */ typedef void tox_dht_nodes_response_cb( - Tox *_Nonnull tox, const uint8_t *_Nonnull public_key, const char *_Nonnull ip, uint32_t ip_length, - uint16_t port, void *_Nullable user_data); + Tox *_Nonnull tox, const uint8_t *_Nonnull responder_public_key, const uint8_t *_Nonnull public_key, + const char *_Nonnull ip, uint32_t ip_length, uint16_t port, void *_Nullable user_data); /** * Set the callback for the `dht_nodes_response` event. Pass NULL to unset. @@ -249,6 +250,16 @@ typedef enum Tox_Netprof_Packet_Id { */ TOX_NETPROF_PACKET_ID_TCP_ONION_RESPONSE = 0x09, + /** + * TCP forward request packet. + */ + TOX_NETPROF_PACKET_ID_TCP_FORWARD_REQUEST = 0x0a, + + /** + * TCP forwarding packet. + */ + TOX_NETPROF_PACKET_ID_TCP_FORWARDING = 0x0b, + /** * TCP data packet. */