From e384d4cf3dfa2371e4540645d8519c7a0d10cf46 Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 1 Nov 2026 14:45:01 +0100 Subject: [PATCH] Add desktop network traffic graphs and unify microphone icons --- src/Makefile.am | 2 +- src/chat/chat_event.c | 1 + src/chat/chat_event.h | 1 + src/chat/chat_network_stats.h | 40 +++ src/chat/chat_status.c | 58 ++++ src/transport_layer/etcp.c | 3 +- src/transport_layer/etcp.h | 3 + src/transport_layer/etcp_connections.c | 1 + src/transport_layer/etcp_connections.h | 2 + src/transport_layer/etcp_link_io.c | 3 + tests/test_etcp_init_liveness.c | 8 +- tests/test_etcp_packet_link.c | 22 ++ tools/chatgui/CMakeLists.txt | 7 + tools/chatgui/src/callwindow.cpp | 20 +- tools/chatgui/src/channellist.cpp | 19 ++ tools/chatgui/src/channellist.h | 1 + tools/chatgui/src/inputbar.cpp | 12 +- tools/chatgui/src/mainwindow.cpp | 2 + tools/chatgui/src/networktrafficwindow.cpp | 335 +++++++++++++++++++ tools/chatgui/src/networktrafficwindow.h | 33 ++ tools/chatgui/src/outline_icon.h | 12 + tools/chatgui/tests/test_network_traffic.cpp | 191 +++++++++++ tools/chatgui/translations/chatgui_en.ts | 9 + tools/chatgui/translations/chatgui_ru.ts | 9 + tools/chatgui/transport/gui_bridge.h | 6 + tools/chatgui/transport/gui_bridge_impl.cpp | 38 ++- 26 files changed, 809 insertions(+), 29 deletions(-) create mode 100644 src/chat/chat_network_stats.h create mode 100644 tools/chatgui/src/networktrafficwindow.cpp create mode 100644 tools/chatgui/src/networktrafficwindow.h create mode 100644 tools/chatgui/tests/test_network_traffic.cpp diff --git a/src/Makefile.am b/src/Makefile.am index 64684e565..92f79e214 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -3,7 +3,7 @@ noinst_LIBRARIES = libutun.a EXTRA_DIST = lwip_tcp/COPYING dnsmasq/COPYING dnsmasq/COPYING-v3 routing_layer/address_profile.h routing_layer/route_ping_doc.md \ stream/stream_playback_audio.h stream/stream_playback_audio.cpp config_ini.h config_error.h \ - call/call_audio_history.h call/call_history_store.h + call/call_audio_history.h call/call_history_store.h chat/chat_network_stats.h stream_core_files = \ stream/stream.h \ diff --git a/src/chat/chat_event.c b/src/chat/chat_event.c index f4430e629..5804981fd 100644 --- a/src/chat/chat_event.c +++ b/src/chat/chat_event.c @@ -64,6 +64,7 @@ void chat_event_post(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, [60]="ATTACHMENT_KEYS_CHANGED", [61]="LOCAL_DEVICES", [62]="LOCAL_INVITE", [63]="LOCAL_DELIVERY", [64]="LOCAL_DISCOVERY_ACTIVE", [65]="RADIO_CALL", [66]="CALL_PHONE_BUSY", [67]="MSG_UPDATED", + [69]="NETWORK_STATS", }; const char* n = (type >= 1 && type < (int)(sizeof(names) / sizeof(names[0]))) ? names[type] : NULL; if (!n) n = "?"; diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 880d696fd..9ad9c0fb3 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -93,6 +93,7 @@ extern "C" { #define CHAT_EVT_CALL_PHONE_BUSY 66 /* [id:8LE][reserved:1]; вызывающий выбирает продолжить либо завершить */ #define CHAT_EVT_MSG_UPDATED 67 /* [channel_len:1][channel][message_id:32] — правка после commit, без unread/звука */ #define CHAT_EVT_GROUP_AVATAR_SNAPSHOT 68 /* Только Android JNI: id/group/version:8LE, hash:32, can_edit/failed:1, size:2LE, image:size. */ +#define CHAT_EVT_NETWORK_STATS 69 /* Native POD снимок ETCP-подключений и линков, chat_network_stats.h. */ // ── Подключение получателя событий ── diff --git a/src/chat/chat_network_stats.h b/src/chat/chat_network_stats.h new file mode 100644 index 000000000..b1e2bf7f0 --- /dev/null +++ b/src/chat/chat_network_stats.h @@ -0,0 +1,40 @@ +/* Согласованный снимок всех ETCP-подключений для GUI; сбор только в uasync-потоке. + * CHAT_EVT_NETWORK_STATS содержит native POD header, затем count пар conn + link[link_count]. + * Это внутреннее событие одного процесса, не сетевой протокол; структуры копируются через memcpy. + * Все строки завершаются NUL, padding обнуляется. Получатель проверяет весь снимок до применения. */ +#ifndef CHAT_NETWORK_STATS_H +#define CHAT_NETWORK_STATS_H + +#include "../transport_layer/etcp_link_io.h" +#include + +#ifdef __cplusplus +extern "C" { +#endif +struct UTUN_INSTANCE; +struct chat_network_stats_header { + uint64_t request_id, at_ms; /* Корреляция запроса и монотонное время общего снимка. */ + uint32_t count, failed; /* Число подключений; failed=1 возвращает только header при отказе. */ +}; +struct chat_network_stats_conn { + uint64_t id, peer_id; /* Lifetime ID объекта и node_id; peer_id может быть ещё неизвестен. */ + uint64_t tx_bytes, rx_bytes; /* Накопительные ETCP-байты всех линков, включая уже удалённые. */ + uint32_t link_count, up; /* Число следующих link-записей и наличие работающих линков. */ + char name[64]; /* Имя узла либо имя подключения до определения peer_id. */ +}; +struct chat_network_stats_link { + uint64_t id, tx_bytes, rx_bytes, inflight; /* Поколение линка, lifetime трафик и физические DATA-байты в полёте. */ + struct etcp_link_snapshot description; /* Номер, транспорт, состояние и адреса для легенды. */ +}; +struct chat_network_stats_request { + struct UTUN_INSTANCE* inst; /* Заимствованное ядро, привязывается мостом под mutex. */ + uint64_t request_id; /* Ненулевой ID; возвращается также при ошибке сбора. */ +}; +/* arg — один u_malloc-блок chat_network_stats_request; освобождается здесь, при отмене — мостом. + * Результат — CHAT_EVT_NETWORK_STATS; при OOM/ошибке возвращается failed header без частичных данных. */ +void chat_core_collect_network_stats_trampoline(void* arg); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/chat/chat_status.c b/src/chat/chat_status.c index 4dcd27480..ccadec886 100644 --- a/src/chat/chat_status.c +++ b/src/chat/chat_status.c @@ -7,6 +7,7 @@ #include "chat_core_priv.h" #include "chat_event.h" #include "chat_member.h" +#include "chat_network_stats.h" #include "../member_files/protocol.h" #include "../utun_instance.h" @@ -435,6 +436,63 @@ void chat_core_collect_conn_list_trampoline(void* arg) { collect_conn_list(inst); } +/* Два прохода без callbacks: размер и данные относятся к одному состоянию uasync. */ +void chat_core_collect_network_stats_trampoline(void* arg) { + struct chat_network_stats_request* request = arg; + if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "network stats: missing request"); return; } + struct UTUN_INSTANCE* inst = request->inst; + struct chat_network_stats_header header = {0}; + header.request_id = request->request_id; header.at_ms = get_time_us() / 1000; + size_t size = sizeof(header); + uint8_t* buffer = NULL; + if (!inst || !request->request_id || !inst->connections) { + DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "network stats: missing core/connections/request ID"); goto failed; + } + for (struct ll_entry* e = inst->connections->head; e; e = e->next) { + struct conn_queue_entry* entry = (struct conn_queue_entry*)e->data; + struct ETCP_CONN* conn = entry ? entry->conn : NULL; + if (!conn || conn->state == 2) continue; + size += sizeof(struct chat_network_stats_conn); header.count++; + for (struct ETCP_LINK* link = conn->links; link; link = link->next) + if (!link->closing) size += sizeof(struct chat_network_stats_link); + if (size > INT_MAX) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "network stats: snapshot too large bytes=%zu", size); goto failed; } + } + buffer = u_calloc(1, size); + if (!buffer) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "network stats: allocation failed bytes=%zu", size); goto failed; } + uint8_t* p = buffer + sizeof(header); + for (struct ll_entry* e = inst->connections->head; e; e = e->next) { + struct conn_queue_entry* entry = (struct conn_queue_entry*)e->data; + struct ETCP_CONN* conn = entry ? entry->conn : NULL; + if (!conn || conn->state == 2) continue; + struct chat_network_stats_conn value = {0}; + value.id = conn->diagnostic_id; value.peer_id = conn->peer_node_id; value.up = !!conn->links_up; + value.tx_bytes = conn->diagnostic_wire_tx_bytes; value.rx_bytes = conn->diagnostic_wire_rx_bytes; + if (value.peer_id && CC(inst) && CC(inst)->initialized) + chat_core_get_node_name(inst, value.peer_id, value.name, sizeof(value.name)); + if (!value.name[0]) snprintf(value.name, sizeof(value.name), "%s", conn->name ? conn->name : "ETCP"); + uint8_t* conn_record = p; p += sizeof(value); + for (struct ETCP_LINK* link = conn->links; link; link = link->next) { + if (link->closing) continue; + struct chat_network_stats_link item = {0}; + item.id = link->diagnostic_id; item.tx_bytes = link->diagnostic_wire_tx_bytes; + item.rx_bytes = link->diagnostic_wire_rx_bytes; item.inflight = link->inflight_bytes; + if (etcp_link_get_snapshot(link, &item.description)) goto failed; + memcpy(p, &item, sizeof(item)); p += sizeof(item); value.link_count++; + } + memcpy(conn_record, &value, sizeof(value)); + } + memcpy(buffer, &header, sizeof(header)); + DEBUG_TRACE(DEBUG_CATEGORY_CHAT, "network stats: request=%llu connections=%u bytes=%zu at_ms=%llu", + (unsigned long long)header.request_id, header.count, size, (unsigned long long)header.at_ms); + chat_event_post(inst, CHAT_EVT_NETWORK_STATS, buffer, (int)size); + u_free(buffer); u_free(request); + return; +failed: + u_free(buffer); header.count = 0; header.failed = 1; + if (inst) chat_event_post(inst, CHAT_EVT_NETWORK_STATS, (const uint8_t*)&header, sizeof(header)); + u_free(request); +} + void chat_core_collect_conn_metrics_trampoline(void* arg) { struct chat_node_arg* a = (struct chat_node_arg*)arg; if (!a) return; diff --git a/src/transport_layer/etcp.c b/src/transport_layer/etcp.c index e507cff65..98f90d75e 100644 --- a/src/transport_layer/etcp.c +++ b/src/transport_layer/etcp.c @@ -209,7 +209,8 @@ struct ETCP_CONN* etcp_connection_create(struct UTUN_INSTANCE* instance, char* n // etcp->window_size = MAX_INFLIGHT_BYTES; // Not used etcp->mtu = ETCP_RFC791_MIN_MTU; // Default MTU per RFC 791 etcp->next_tx_id = 1; - random_bytes((uint8_t*)&etcp->reset_id, 8); // начальная случайная эпоха ресета + do { random_bytes((uint8_t*)&etcp->reset_id, 8); } while (!etcp->reset_id); + etcp->diagnostic_id = etcp->reset_id; // ID объекта сохраняется при последующих сменах эпохи. etcp->rtt_avg_10 = 10; // Initial guess (1ms) etcp->rtt_history_idx = 0; memset(etcp->rtt_history, 0, sizeof(etcp->rtt_history)); diff --git a/src/transport_layer/etcp.h b/src/transport_layer/etcp.h index fc9d9b0e7..6bafd98ce 100644 --- a/src/transport_layer/etcp.h +++ b/src/transport_layer/etcp.h @@ -193,6 +193,9 @@ struct ETCP_CONN { // Диагностика за lifetime соединения: reset не обнуляет накопительные счётчики. uint64_t diagnostic_acked_bytes; // Уникальные логические DATA-байты, подтверждённые пиром uint64_t diagnostic_wire_tx_bytes; // ETCP-кадры, принятые драйверами: включая ACK/повторы, без обрамления носителя + uint64_t diagnostic_wire_rx_bytes; // ETCP-кадры от драйверов, до декодирования; без обрамления носителя + uint64_t diagnostic_id; // Постоянный случайный ID объекта; reset_id может меняться + uint64_t diagnostic_next_link_id; // Поколения линков этого объекта, не переиспользуются при освобождении local_link_id uint64_t diagnostic_ack_us; // Последний реальный логический ACK; 0 — нет измерения текущей сессии uint64_t diagnostic_retries; // Повторные DATA, действительно принятые транспортом uint64_t diagnostic_rtos; // Физические DATA-попытки, снятые по RTO diff --git a/src/transport_layer/etcp_connections.c b/src/transport_layer/etcp_connections.c index 9e695716f..6afe9432f 100644 --- a/src/transport_layer/etcp_connections.c +++ b/src/transport_layer/etcp_connections.c @@ -1187,6 +1187,7 @@ static struct ETCP_LINK* link_create(struct ETCP_CONN* etcp, struct ETCP_SOCKET* return NULL; } link->local_link_id = (uint8_t)free_id; + link->diagnostic_id = ++etcp->diagnostic_next_link_id; if (link->bbr) link->bbr->link_id = (uint8_t)free_id; if (remote_addr) memcpy(&link->remote_addr, remote_addr, sizeof(struct sockaddr_storage)); diff --git a/src/transport_layer/etcp_connections.h b/src/transport_layer/etcp_connections.h index 9174f2814..b5c6a89b6 100644 --- a/src/transport_layer/etcp_connections.h +++ b/src/transport_layer/etcp_connections.h @@ -245,6 +245,8 @@ struct ETCP_LINK { uint8_t closing; // Close начат: повторный вход из callback игнорируется до окончательного free uint32_t active_users; // Внутренние стеки конструктора/RX/событий; память закрытого линка живёт до выхода последнего uint8_t local_link_id; // id моего линка + uint64_t diagnostic_id; // Неповторяющееся поколение внутри ETCP_CONN, независимо от local_link_id + uint64_t diagnostic_wire_tx_bytes, diagnostic_wire_rx_bytes; // Lifetime ETCP-байты на границе пакетного драйвера uint8_t remote_link_id; // id этого линка на peer (устанавливается в момент initialized) uint8_t remote_socket_id; // socket id peer uint8_t remote_only_local; // only_local flag from peer diff --git a/src/transport_layer/etcp_link_io.c b/src/transport_layer/etcp_link_io.c index a78458aef..e33a2ff82 100644 --- a/src/transport_layer/etcp_link_io.c +++ b/src/transport_layer/etcp_link_io.c @@ -172,6 +172,8 @@ done: static void received(struct packet_link* channel, struct ll_entry* p, void* arg) { (void)channel; struct etcp_link_io* io = arg; + io->owner->diagnostic_wire_rx_bytes += p->len; + io->owner->etcp->diagnostic_wire_rx_bytes += p->len; io->owner->codec->receive(io->owner, p); } @@ -275,6 +277,7 @@ int etcp_link_io_send(struct ETCP_LINK* link, const void* bytes, size_t size) { memcpy(p->dgram, bytes, size); p->len = size; int result = packet_link_send(link->io->channel, p); if (result == PACKET_LINK_OK) { + link->diagnostic_wire_tx_bytes += size; link->etcp->diagnostic_wire_tx_bytes += size; return (int)size; } diff --git a/tests/test_etcp_init_liveness.c b/tests/test_etcp_init_liveness.c index 6755c3622..97a722b8c 100644 --- a/tests/test_etcp_init_liveness.c +++ b/tests/test_etcp_init_liveness.c @@ -217,7 +217,7 @@ static void healthy_alternative(void) { } /* Настоящий wire INIT: криптография не исправляет намеренно усечённое тело запроса. */ -static void send_init_request_body(const struct ETCP_INIT_REQUEST_PKT *request, unsigned length, unsigned flag) { +static size_t send_init_request_body(const struct ETCP_INIT_REQUEST_PKT *request, unsigned length, unsigned flag) { uint8_t plain[ETCP_ENCRYPTED_HDR_SIZE + ETCP_INIT_REQ_SIZE + 32] = {0}; CHECK(length <= sizeof(plain) - ETCP_ENCRYPTED_HDR_SIZE); plain[2] = flag; @@ -236,6 +236,7 @@ static void send_init_request_body(const struct ETCP_INIT_REQUEST_PKT *request, CHECK(socket_sendto(ep[0].socket->fd, wire, size, (const struct sockaddr*)&ep[1].socket->interface_addr, sizeof(struct sockaddr_in)) == (ssize_t)size); etcp_connections_read_callback_socket(ep[1].socket->fd, ep[1].socket); + return size; } struct accept_case { @@ -341,7 +342,7 @@ static void request_boundaries(unsigned established) { if (established) { memcpy(&before_conn, ep[1].conn, sizeof(before_conn)); memcpy(&before_link, first_link(1), sizeof(before_link)); } int connections = ep[1].inst->connections->count, pool_free = ep[1].inst->pkt_pool->free_count; size_t errors = ep[1].socket->pkt_format_errors; - send_init_request_body(&request, length, 0); + size_t received_size = send_init_request_body(&request, length, 0); if (invalid >= sizeof(request) && fault < 8) request.node_id[fault] ^= 1; memcpy(request.mtu, saved_mtu, 2); request.link_id = saved_id; DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "INIT_REQUEST boundary: established=%u code=%u body=%u errors=%zu->%zu", @@ -349,6 +350,9 @@ static void request_boundaries(unsigned established) { CHECK(ep[1].socket->pkt_format_errors == errors + 1 && ep[1].socket->errorcode != 0); CHECK(ep[1].inst->connections->count == connections && ep[1].inst->pkt_pool->free_count == pool_free); if (established) { + /* Граница драйвера учитывает также отклонённый wire; протокольное состояние остаётся прежним. */ + before_conn.diagnostic_wire_rx_bytes += received_size; + before_link.diagnostic_wire_rx_bytes += received_size; CHECK(!memcmp(&before_conn, ep[1].conn, sizeof(before_conn))); CHECK(!memcmp(&before_link, first_link(1), sizeof(before_link))); } diff --git a/tests/test_etcp_packet_link.c b/tests/test_etcp_packet_link.c index d48d3cdb6..ea9a084d1 100644 --- a/tests/test_etcp_packet_link.c +++ b/tests/test_etcp_packet_link.c @@ -32,6 +32,11 @@ static ssize_t wire(socket_t fd, const void* bytes, size_t size, const struct so static struct ll_entry* packet(unsigned char value) { struct ll_entry* p = ll_alloc_lldgram(8); assert(p); p->len = 8; memset(p->dgram, value, 8); return p; } +/* Наблюдаем общую границу RX до декодирования, сохраняя штатное владение пакетом. */ +static void received_probe(struct ETCP_LINK* link, struct ll_entry* p) { + assert(link->diagnostic_wire_rx_bytes >= p->len); + queue_dgram_free(p); queue_entry_free(p); +} int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); size_t baseline = u_get_allocated_count(); @@ -73,11 +78,22 @@ int main(void) { /* Счётчик ETCP измеряет принятие драйвером, включая временную очередь UDP; отказ не считается. */ uint64_t sent_before = conn->diagnostic_wire_tx_bytes; + uint64_t link_sent_before = a->diagnostic_wire_tx_bytes; const uint8_t value[8] = {42}; first.blocked = 1; assert(etcp_link_io_send(a, value, sizeof(value)) == 8 && conn->diagnostic_wire_tx_bytes == sent_before + sizeof(value)); + assert(a->diagnostic_wire_tx_bytes == link_sent_before + sizeof(value)); assert(etcp_link_io_send(a, value, sizeof(value)) == -2 && conn->diagnostic_wire_tx_bytes == sent_before + sizeof(value)); + assert(a->diagnostic_wire_tx_bytes == link_sent_before + sizeof(value)); first.blocked = 0; etcp_link_io_socket_writable(sock.fd, &sock); assert(conn->diagnostic_wire_tx_bytes == sent_before + sizeof(value) && etcp_link_io_ready(a)); + assert(a->diagnostic_wire_tx_bytes == link_sent_before + sizeof(value)); + + const struct etcp_link_codec probe = {0, NULL, received_probe}; + const struct etcp_link_codec* saved_codec = a->codec; a->codec = &probe; + uint64_t received_before = conn->diagnostic_wire_rx_bytes, link_received_before = a->diagnostic_wire_rx_bytes; + etcp_link_io_receive(a, packet(42)); uasync_drain_immediate(ua); + assert(a->diagnostic_wire_rx_bytes == link_received_before + 8); + assert(conn->diagnostic_wire_rx_bytes == received_before + 8); a->codec = saved_codec; /* Подготовка не снимает ACK; отказ не меняет очередь/служебные признаки. */ b->initialized = b->link_status = 0; @@ -93,10 +109,16 @@ int main(void) { assert(!a->ack_q->count && !a->ack_reply_pending && !a->burst_resp_pending && !a->last_recv_updated); /* Ошибка после принятия ожидающей датаграммы закрывает только её путь. */ + uint64_t old_generation = a->diagnostic_id; + uint8_t old_number = a->local_link_id; first.blocked = 1; assert(packet_link_send(etcp_link_io_get(a), packet(42)) == PACKET_LINK_OK); first.blocked = 0; first.failed = 1; etcp_link_io_socket_writable(sock.fd, &sock); assert(packet_link_get_state(etcp_link_io_get(a)).phase == PACKET_LINK_TERMINATED); uasync_drain_immediate(ua); assert(conn->links == b && !b->next); + conn->last_assigned_link_id = old_number - 1; + ip->sin_port = htons(1111); + struct ETCP_LINK* replacement = etcp_link_new(conn, &sock, &addr, 1); assert(replacement); + assert(replacement->local_link_id == old_number && replacement->diagnostic_id > old_generation); etcp_connection_close(conn); uasync_drain_immediate(ua); uasync_remove_socket(ua, sock.socket_id); socket_close_wrapper(sock.fd); queue_free(sock.links_queue); queue_free(inst.connections); diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 6c8324aef..f67388f9f 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -369,6 +369,7 @@ add_executable(vibechat WIN32 src/audiodevicesettingspage.cpp src/storagesettingspage.cpp src/connmonitorwindow.cpp + src/networktrafficwindow.cpp src/callwindow.cpp src/call_audio_graphs.cpp src/call_audio_engine.cpp @@ -588,6 +589,12 @@ endif() add_test(NAME test_message_events COMMAND test_message_events) set_tests_properties(test_message_events PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 15) +add_executable(test_network_traffic tests/test_network_traffic.cpp src/networktrafficwindow.cpp src/connmonitorwindow.cpp + transport/gui_bridge_impl.cpp) +target_link_libraries(test_network_traffic PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun utun_voice pthread ${CMAKE_DL_LIBS}) +add_test(NAME test_network_traffic COMMAND test_network_traffic) +set_tests_properties(test_network_traffic PROPERTIES ENVIRONMENT "QT_QPA_PLATFORM=offscreen" TIMEOUT 60) + add_executable(test_call_commands tests/test_call_commands.cpp src/callwindow.cpp src/call_audio_graphs.cpp src/call_video_engine.cpp src/call_audio_engine.cpp src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp src/connmonitorwindow.cpp transport/gui_bridge_impl.cpp transport/miniaudio_impl.c transport/audio_diagnostics.cpp) diff --git a/tools/chatgui/src/callwindow.cpp b/tools/chatgui/src/callwindow.cpp index 7196df4eb..b28bced1e 100644 --- a/tools/chatgui/src/callwindow.cpp +++ b/tools/chatgui/src/callwindow.cpp @@ -1,6 +1,7 @@ #include #include "callwindow.h" #include "capture_controls.h" +#include "outline_icon.h" #include "../transport/gui_bridge.h" #include "call_audio_engine.h" #include "call_audio_graphs.h" @@ -196,22 +197,13 @@ static void drawPhoneGlyph(QPainter* p, const QRectF& r, bool down) { p->restore(); } +/* Тот же контур и толщина штриха, что у микрофона в панели ввода. */ static void drawMicGlyph(QPainter* p, const QRectF& r, bool off) { - const qreal s = r.width(); - const QPointF c = r.center(); p->save(); - QPen pen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin); - p->setPen(pen); + p->translate(r.topLeft()); p->scale(r.width() / 24.0, r.height() / 24.0); + p->setPen(QPen(Qt::white, 1.8, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); p->setBrush(Qt::NoBrush); - const qreal cw = s * 0.30; - p->drawRoundedRect(QRectF(c.x() - cw / 2, r.top() + s * 0.10, cw, s * 0.40), cw / 2, cw / 2); - p->drawArc(QRectF(c.x() - s * 0.20, r.top() + s * 0.42, s * 0.40, s * 0.26), 180 * 16, 180 * 16); - p->drawLine(QPointF(c.x(), r.top() + s * 0.60), QPointF(c.x(), r.top() + s * 0.68)); - p->drawLine(QPointF(c.x() - s * 0.17, r.top() + s * 0.68), QPointF(c.x() + s * 0.17, r.top() + s * 0.68)); - if (off) { - p->setPen(QPen(Qt::white, s * 0.09, Qt::SolidLine, Qt::RoundCap)); - p->drawLine(QPointF(c.x() - s * 0.26, c.y() - s * 0.26), QPointF(c.x() + s * 0.26, c.y() + s * 0.26)); - } + p->drawPath(microphoneOutline(off)); p->restore(); } @@ -356,7 +348,9 @@ CallWindow::CallWindow(QWidget* parent) m_declineBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QCoreApplication::translate("ChatGui", "Decline"), this); m_hangupBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QCoreApplication::translate("ChatGui", "End call"), this); m_muteBtn = makeRoundBtn(makeRoundIcon(QColor("#7f8c8d"), Glyph::Mic, 96), QCoreApplication::translate("ChatGui", "Mute"), this); + m_muteBtn->setObjectName(QStringLiteral("callMuteButton")); m_muteBtn->setCheckable(true); + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call microphone button initialized icon=shared-outline muted_icon=diagonal-strike"); auto* root = new QVBoxLayout(this); root->setContentsMargins(12, 12, 12, 12); diff --git a/tools/chatgui/src/channellist.cpp b/tools/chatgui/src/channellist.cpp index c9d7fc798..7ba95cc7a 100644 --- a/tools/chatgui/src/channellist.cpp +++ b/tools/chatgui/src/channellist.cpp @@ -144,6 +144,16 @@ static QIcon connectionMonitorIcon(const QColor& color) { return outlineIcon(path, color, 24); } +/* Три ряда обозначают inflight/TX/RX в отдельном окне трафика. */ +static QIcon networkTrafficIcon(const QColor& color) { + QPainterPath path; + path.moveTo(3, 2); path.lineTo(3, 22); + for (int y : {6, 13, 20}) { + path.moveTo(5, y); path.lineTo(9, y); path.lineTo(12, y - 3); path.lineTo(16, y + 1); path.lineTo(21, y - 2); + } + return outlineIcon(path, color, 24); +} + ChannelList::ChannelList(DbManager* db, QWidget *parent) : QWidget(parent) , m_listView(new ChannelListView(this)) @@ -237,6 +247,15 @@ ChannelList::ChannelList(DbManager* db, QWidget *parent) fl->addWidget(monitorBtn); connect(monitorBtn, &QPushButton::clicked, this, [this] { emit connMonitorRequested(); }); + auto* trafficBtn = new QPushButton(footer); + trafficBtn->setObjectName(QStringLiteral("networkTrafficButton")); + trafficBtn->setIcon(networkTrafficIcon(palette().color(QPalette::ButtonText))); trafficBtn->setIconSize(QSize(24, 24)); + trafficBtn->setFixedSize(34, 34); trafficBtn->setCursor(Qt::PointingHandCursor); trafficBtn->setProperty("instantTooltip", true); + trafficBtn->setToolTip(QCoreApplication::translate("ChatGui", "Network Traffic")); + trafficBtn->setAccessibleName(trafficBtn->toolTip()); trafficBtn->setStyleSheet(actionStyle); fl->addWidget(trafficBtn); + connect(trafficBtn, &QPushButton::clicked, this, &ChannelList::networkTrafficRequested); + DEBUG_DEBUG(DEBUG_CATEGORY_CHAT, "network traffic button initialized icon=three-graphs icon_px=24 button=34x34"); + fl->addStretch(); auto* versionLabel = new QLabel(QStringLiteral("v%1").arg(QCoreApplication::applicationVersion()), footer); diff --git a/tools/chatgui/src/channellist.h b/tools/chatgui/src/channellist.h index 986dfd918..3ce1b22c8 100644 --- a/tools/chatgui/src/channellist.h +++ b/tools/chatgui/src/channellist.h @@ -66,6 +66,7 @@ signals: void createGroupRequested(); void settingsRequested(); void connMonitorRequested(); + void networkTrafficRequested(); protected: /* Принимает drag/drop в footer; фильтры устанавливаются на него и его кнопки/подписи. */ diff --git a/tools/chatgui/src/inputbar.cpp b/tools/chatgui/src/inputbar.cpp index 517ae99f2..23977404d 100644 --- a/tools/chatgui/src/inputbar.cpp +++ b/tools/chatgui/src/inputbar.cpp @@ -214,16 +214,6 @@ static QIcon attachmentIcon(const QPalette& palette) { return outlineIcon(rotation.map(path), palette.color(QPalette::ButtonText)); } -/* Микрофон с капсюлем, держателем и подставкой в том же контурном стиле, что и скрепка. */ -static QIcon microphoneIcon(const QPalette& palette) { - QPainterPath path; - path.addRoundedRect(QRectF(9, 2, 6, 12), 3, 3); - path.moveTo(5, 10); path.lineTo(5, 12); - path.cubicTo(5, 15.866, 8.134, 19, 12, 19); path.cubicTo(15.866, 19, 19, 15.866, 19, 12); path.lineTo(19, 10); - path.moveTo(12, 19); path.lineTo(12, 22); path.moveTo(8, 22); path.lineTo(16, 22); - return outlineIcon(path, palette.color(QPalette::ButtonText)); -} - /* Смайлик с короткой улыбкой и свободным отступом от внешнего круга. */ static QIcon smileIcon(const QPalette& palette) { QPainterPath path; @@ -366,7 +356,7 @@ InputBar::InputBar(EmojiPanel *emojiPanel, AudioRecorder *recorder, QWidget *par QCoreApplication::translate("ChatGui", "Emoji"), this); layout->addWidget(wrapBottomBtn(m_emojiBtn, 3), 0, Qt::AlignBottom); - m_pttBtn = makeIconBtn(microphoneIcon(palette()), + m_pttBtn = makeIconBtn(outlineIcon(microphoneOutline(), palette().color(QPalette::ButtonText)), QCoreApplication::translate("ChatGui", "Voice message (hold)"), this); GUI_DEBUG("composer: attachment, emoji and voice buttons initialized icons=vector icon_px=20 stroke=1.8 HiDPI=1x..4x"); layout->addWidget(wrapBottomBtn(m_pttBtn, 3), 0, Qt::AlignBottom); diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 28be6ff1c..f169b525f 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -19,6 +19,7 @@ #include "settingsdialog.h" #include "channelsettingsdialog.h" #include "connmonitorwindow.h" +#include "networktrafficwindow.h" #include "videoplayer_window.h" #include "imageviewer_window.h" #include "member_files/browser.h" @@ -876,6 +877,7 @@ void MainWindow::setupConnects() { connect(m_channelList, &ChannelList::createGroupRequested, this, &MainWindow::onCreateGroupRequested); connect(m_channelList, &ChannelList::settingsRequested, this, &MainWindow::showSettings); connect(m_channelList, &ChannelList::connMonitorRequested, this, [] { ConnMonitorWindow::showMonitor(); }); + connect(m_channelList, &ChannelList::networkTrafficRequested, this, [] { NetworkTrafficWindow::showMonitor(); }); connect(m_channelList, &ChannelList::dmDeleteRequested, this, &MainWindow::onDmDeleteRequested); connect(m_messageList, &MessageList::channelSettingsRequested, this, &MainWindow::showChannelSettings); connect(m_accountList, &AccountList::memberRenamed, diff --git a/tools/chatgui/src/networktrafficwindow.cpp b/tools/chatgui/src/networktrafficwindow.cpp new file mode 100644 index 000000000..9dd33fbc3 --- /dev/null +++ b/tools/chatgui/src/networktrafficwindow.cpp @@ -0,0 +1,335 @@ +#include "networktrafficwindow.h" +#include "../transport/gui_bridge.h" +#include "../../../src/chat/chat_network_stats.h" +#include "../../../lib/debug_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static QString text(const char* value) { return QCoreApplication::translate("ChatGui", value); } +static constexpr uint64_t HistoryMs = 30000, RateMs = 1000, MaxGapMs = 750; +static NetworkTrafficWindow* activeMonitor = nullptr; + +struct TrafficSample { + uint64_t at, tx, rx, inflight; + double txRate, rxRate; + bool gap; +}; +struct TrafficSeries { + QColor color; + unsigned number = 0; // Номер в легенде; поколение используется только как ключ истории. + QToolButton* button = nullptr; // Владеет панель; checked управляет всеми тремя кривыми. + std::deque samples; // Ограниченная история и накопительные счётчики для секундного среднего. +}; + +/* Единственный холст панели: одна временная шкала, независимый масштаб каждого ряда. */ +class TrafficCanvas : public QWidget { +public: + explicit TrafficCanvas(QWidget* parent) : QWidget(parent) { setObjectName("trafficCanvas"); setFixedHeight(366); } + QMap series; + uint64_t now = 0; +protected: + void paintEvent(QPaintEvent*) override; +}; + +/* Владеет историей одного подключения и кнопками общей легенды; DOWN не удаляет историю. */ +class TrafficConnection : public QWidget { +public: + explicit TrafficConnection(quint64 id, QWidget* parent) : QWidget(parent), m_id(id) { + setObjectName(QStringLiteral("trafficConnection_%1").arg(id, 0, 16)); + auto* layout = new QVBoxLayout(this); layout->setContentsMargins(8, 8, 8, 8); layout->setSpacing(4); + m_title = new QLabel(this); m_title->setTextFormat(Qt::PlainText); + QFont font = m_title->font(); font.setBold(true); m_title->setFont(font); layout->addWidget(m_title); + m_legend = new QGridLayout; m_legend->setContentsMargins(0, 0, 0, 0); m_legend->setSpacing(4); + m_legend->setAlignment(Qt::AlignLeft); layout->addLayout(m_legend); + m_canvas = new TrafficCanvas(this); layout->addWidget(m_canvas); + } + + void apply(const chat_network_stats_conn& conn, const std::vector& links, uint64_t at) { + if (at <= m_canvas->now) { + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "traffic graph: stale snapshot connection=%llx at=%llu previous=%llu", + (unsigned long long)m_id, (unsigned long long)at, (unsigned long long)m_canvas->now); return; + } + QString peer = conn.peer_id ? QStringLiteral("%1").arg(conn.peer_id, 16, 16, QChar('0')) : text("Connecting"); + m_title->setText(QStringLiteral("%1 [%2] · %3").arg(QString::fromUtf8(conn.name), peer, conn.up ? "UP" : "DOWN")); + QSet present{0}; uint64_t inflight = 0; + bool changed = !m_canvas->series.contains(0); + addSeries(0, 0, QStringLiteral("CONN"), text("All links: inflight / TX / RX")); + for (const auto& link : links) { + present.insert(link.id); inflight += link.inflight; + changed |= !m_canvas->series.contains(link.id); + QString local = address(link.description.local), remote = address(link.description.remote); + QString tooltip = QStringLiteral("%1 · %2\n%3 → %4\n%5").arg(QString::fromUtf8(link.description.protocol), + QString::fromUtf8(link.description.socket_name), local, remote, link.description.status ? "UP" : "DOWN"); + addSeries(link.id, link.description.id, QStringLiteral("LINK%1").arg(link.description.id), tooltip); + append(m_canvas->series[link.id], at, link.tx_bytes, link.rx_bytes, link.inflight); + } + for (auto it = m_canvas->series.begin(); it != m_canvas->series.end();) { + if (present.contains(it.key())) { ++it; continue; } + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "traffic graph link removed: connection=%llx generation=%llu", + (unsigned long long)m_id, (unsigned long long)it.key()); + delete it->button; it = m_canvas->series.erase(it); changed = true; + } + append(m_canvas->series[0], at, conn.tx_bytes, conn.rx_bytes, inflight); + m_canvas->now = at; + if (changed) arrangeLegend(); + m_canvas->update(); + } +protected: + void resizeEvent(QResizeEvent* event) override { QWidget::resizeEvent(event); arrangeLegend(); } +private: + // Отдельно хранить поколение: local_link_id циклически переиспользуется транспортом. + quint64 m_id; + QLabel* m_title; + QGridLayout* m_legend; + TrafficCanvas* m_canvas; + unsigned m_nextColor = 0; + + static QString address(const etcp_link_address& value) { + if (!value.family) return QStringLiteral("—"); + QHostAddress host; + if (value.family == 4) { + quint32 ipv4 = (quint32(value.bytes[0]) << 24) | (quint32(value.bytes[1]) << 16) | + (quint32(value.bytes[2]) << 8) | value.bytes[3]; + host.setAddress(ipv4); + } else { Q_IPV6ADDR ipv6; memcpy(ipv6.c, value.bytes, 16); host.setAddress(ipv6); } + return QStringLiteral("[%1]:%2").arg(host.toString()).arg(value.port); + } + + void addSeries(quint64 id, unsigned number, const QString& name, const QString& tooltip) { + auto it = m_canvas->series.find(id); + if (it == m_canvas->series.end()) { + TrafficSeries value; + value.number = number; + value.color = id ? QColor::fromHsv((35 + m_nextColor++ * 137) % 360, 190, 210) : QColor("#3498db"); + value.button = new QToolButton(this); value.button->setCheckable(true); value.button->setChecked(true); + value.button->setFixedWidth(90); value.button->setCursor(Qt::PointingHandCursor); + value.button->setObjectName(QStringLiteral("trafficSeries_%1").arg(id)); + value.button->setStyleSheet(QStringLiteral( + "QToolButton { border: none; border-radius: 4px; padding: 3px; color: palette(mid); }" + "QToolButton:checked { color: %1; } QToolButton:hover { background: palette(midlight); }").arg(value.color.name())); + connect(value.button, &QToolButton::toggled, m_canvas, [this, id](bool enabled) { + auto& item = m_canvas->series[id]; QFont font = item.button->font(); font.setStrikeOut(!enabled); + item.button->setFont(font); m_canvas->update(); + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "traffic graph series: connection=%llx generation=%llu visible=%d", + (unsigned long long)m_id, (unsigned long long)id, enabled); + }); + it = m_canvas->series.insert(id, value); + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "traffic graph series added: connection=%llx generation=%llu color=%s", + (unsigned long long)m_id, (unsigned long long)id, qPrintable(it->color.name())); + } + it->button->setText(QStringLiteral("● %1").arg(name)); it->button->setAccessibleName(name); it->button->setToolTip(tooltip); + } + + void arrangeLegend() { + int columns = std::max(1, (width() - 12) / 94), index = 0; + std::vector ordered; + for (auto& item : m_canvas->series) { m_legend->removeWidget(item.button); ordered.push_back(&item); } + std::sort(ordered.begin(), ordered.end(), [](const auto* a, const auto* b) { return a->number < b->number; }); + for (auto* item : ordered) { m_legend->addWidget(item->button, index / columns, index % columns); ++index; } + } + + // Среднее фиксируется при получении; скрытие серии не меняет её историю и вычисление скорости. + void append(TrafficSeries& series, uint64_t at, uint64_t tx, uint64_t rx, uint64_t flight) { + auto& history = series.samples; + if (!history.empty() && at <= history.back().at) { + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "traffic graph: non-increasing time connection=%llx now=%llu previous=%llu", + (unsigned long long)m_id, (unsigned long long)at, (unsigned long long)history.back().at); return; + } + bool gap = history.empty() || at - history.back().at > MaxGapMs; + if (!history.empty() && (tx < history.back().tx || rx < history.back().rx)) { + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "traffic graph: counters regressed connection=%llx TX=%llu RX=%llu", + (unsigned long long)m_id, (unsigned long long)tx, (unsigned long long)rx); gap = true; + } + TrafficSample sample{at, tx, rx, flight, NAN, NAN, gap}; + if (!gap) { + const TrafficSample* first = nullptr; + for (auto it = history.rbegin(); it != history.rend(); ++it) { + if (at - it->at > RateMs) break; + first = &*it; + if (it->gap) break; + } + if (first) { + sample.txRate = (tx - first->tx) * 1000.0 / (at - first->at); + sample.rxRate = (rx - first->rx) * 1000.0 / (at - first->at); + } + } + history.push_back(sample); + while (history.size() > 256 || at - history.front().at > HistoryMs + RateMs) history.pop_front(); + } +}; + +/* Каждая ось начинается с нуля и учитывает только включённые серии в видимом временном окне. */ +void TrafficCanvas::paintEvent(QPaintEvent*) { + QPainter p(this); p.setRenderHint(QPainter::Antialiasing); p.fillRect(rect(), palette().base()); + QColor foreground = palette().text().color(), muted = foreground; muted.setAlphaF(.65); + auto value = [](const TrafficSample& sample, int row) { + return row == 0 ? double(sample.inflight) : row == 1 ? sample.txRate : sample.rxRate; + }; + for (int row = 0; row < 3; ++row) { + double maximum = 1; bool have = false; + for (const auto& line : series) if (line.button->isChecked()) for (const auto& sample : line.samples) { + if (now - sample.at > HistoryMs || !std::isfinite(value(sample, row))) continue; + maximum = std::max(maximum, value(sample, row)); have = true; + } + double power = std::pow(10.0, std::floor(std::log10(maximum))), rounded = maximum / power; + double top = (rounded <= 1 ? 1 : rounded <= 2 ? 2 : rounded <= 5 ? 5 : 10) * power; + double factor = top >= 1048576 ? 1048576 : top >= 1024 ? 1024 : 1; + QString unit = factor == 1048576 ? "MiB" : factor == 1024 ? "KiB" : "B"; + if (row) unit += "/s"; + double y = row * 122.0; QRectF box(78, y + 24, width() - 94, 76); + p.setPen(foreground); + p.drawText(QRectF(10, y + 2, width() - 20, 20), + QStringLiteral("%1 · %2%3").arg(row == 0 ? text("Inflight") : row == 1 ? "TX" : "RX", unit, + have ? QString() : QStringLiteral(" · ") + text("No samples"))); + auto px = [&](uint64_t at) { return box.right() - double(now - at) / HistoryMs * box.width(); }; + auto py = [&](double v) { return box.bottom() - v / top * box.height(); }; + for (int tick = 0; tick <= 2; ++tick) { + double v = top * tick / 2, position = py(v); p.setPen(QPen(muted, .4)); + p.drawLine(QPointF(box.left(), position), QPointF(box.right(), position)); + p.setPen(muted); p.drawText(QRectF(0, position - 8, 70, 16), Qt::AlignRight | Qt::AlignVCenter, + QString::number(v / factor, 'f', factor == 1 ? 0 : 1)); + } + for (int second = -30; second <= 0; second += 10) { + double x = box.right() + second / 30.0 * box.width(); p.setPen(QPen(muted, .4)); + p.drawLine(QPointF(x, box.top()), QPointF(x, box.bottom())); p.setPen(muted); + p.drawText(QRectF(x - 22, box.bottom() + 2, 44, 18), Qt::AlignCenter, QStringLiteral("%1 s").arg(second)); + } + p.save(); p.setClipRect(box.adjusted(-1, -1, 1, 1)); + for (auto it = series.cbegin(); it != series.cend(); ++it) { + if (!it->button->isChecked()) continue; + QPainterPath path; bool started = false; + for (const auto& sample : it->samples) { + double v = value(sample, row); + if (now - sample.at > HistoryMs || !std::isfinite(v)) { started = false; continue; } + QPointF point(px(sample.at), py(v)); + if (!started || sample.gap) path.moveTo(point); else path.lineTo(point); + started = true; + } + p.setPen(QPen(it->color, it.key() ? 1.5 : 2.5)); p.drawPath(path); + } + p.restore(); + } +} + +static void networkSnapshot(const uint8_t* data, int len) { if (activeMonitor) activeMonitor->onSnapshot(data, len); } + +NetworkTrafficWindow* NetworkTrafficWindow::showMonitor() { + auto* window = activeMonitor; + if (!window) window = new NetworkTrafficWindow; + if (window->isMinimized()) window->showNormal(); else window->show(); + window->raise(); window->activateWindow(); + return window; +} + +NetworkTrafficWindow::NetworkTrafficWindow() : QDialog(nullptr) { + setObjectName("networkTrafficWindow"); setWindowTitle(text("Network Traffic")); resize(900, 850); setMinimumWidth(460); + setAttribute(Qt::WA_DeleteOnClose); setAttribute(Qt::WA_QuitOnClose, false); + setWindowFlags(windowFlags() & ~Qt::WindowContextHelpButtonHint); + auto* layout = new QVBoxLayout(this); + auto* description = new QLabel(text("Last 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a series"), this); + description->setWordWrap(true); layout->addWidget(description); + auto* scroll = new QScrollArea(this); scroll->setWidgetResizable(true); layout->addWidget(scroll); + auto* content = new QWidget(scroll); m_rows = new QVBoxLayout(content); m_rows->setAlignment(Qt::AlignTop); + m_rows->setContentsMargins(0, 0, 0, 0); m_rows->setSpacing(12); scroll->setWidget(content); + m_empty = new QLabel(text("Waiting for network statistics"), content); m_empty->setWordWrap(true); m_rows->addWidget(m_empty); + m_timer = new QTimer(this); m_timer->setInterval(250); + connect(m_timer, &QTimer::timeout, this, &NetworkTrafficWindow::onTimerTick); + activeMonitor = this; gui_bridge_set_network_stats_cb(networkSnapshot); + DEBUG_INFO(DEBUG_CATEGORY_GENERAL, "network traffic window created: history_ms=30000 interval_ms=250 rate_ms=1000"); +} + +NetworkTrafficWindow::~NetworkTrafficWindow() { + m_timer->stop(); + if (activeMonitor == this) { activeMonitor = nullptr; gui_bridge_set_network_stats_cb(nullptr); } + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "network traffic window destroyed: connections=%d", int(m_connections.size())); +} +void NetworkTrafficWindow::showEvent(QShowEvent* event) { + QDialog::showEvent(event); m_timer->start(); onTimerTick(); +} +void NetworkTrafficWindow::hideEvent(QHideEvent* event) { + m_timer->stop(); m_requestId = 0; QDialog::hideEvent(event); +} +void NetworkTrafficWindow::closeEvent(QCloseEvent* event) { + QDialog::closeEvent(event); + if (event->isAccepted() && activeMonitor == this) { activeMonitor = nullptr; gui_bridge_set_network_stats_cb(nullptr); } +} +void NetworkTrafficWindow::onTimerTick() { + int result = gui_bridge_request_network_stats(&m_requestId); + if (result == 0) { + if (!m_connections.isEmpty()) DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "traffic graphs cleared: core unavailable"); + qDeleteAll(m_connections); m_connections.clear(); m_requestId = 0; + m_empty->setText(text("Network core unavailable")); m_empty->show(); + } else if (result < 0) { m_empty->setText(text("Unable to collect network statistics")); m_empty->show(); } +} + +/* Проверяем native POD и границы всех массивов до изменения существующих графиков. */ +void NetworkTrafficWindow::onSnapshot(const uint8_t* data, int len) { + if (!data || len < int(sizeof(chat_network_stats_header))) { + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "traffic graphs: truncated header bytes=%d", len); return; + } + chat_network_stats_header header; memcpy(&header, data, sizeof(header)); + if (!isVisible() || header.request_id != m_requestId || !m_requestId) return; + if (header.failed == 1 && header.count == 0 && len == int(sizeof(header))) { + DEBUG_WARN(DEBUG_CATEGORY_GENERAL, "traffic graphs: collection failed request=%llu", (unsigned long long)header.request_id); + m_empty->setText(text("Unable to collect network statistics")); m_empty->show(); return; + } + struct Connection { chat_network_stats_conn value; std::vector links; }; + std::vector connections; QSet present; + const uint8_t* p = data + sizeof(header); size_t remaining = len - sizeof(header); + auto invalid = [&] { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "traffic graphs: invalid snapshot request=%llu count=%u bytes=%d remaining=%zu", + (unsigned long long)header.request_id, header.count, len, remaining); }; + if (header.failed || !header.at_ms || header.count > remaining / sizeof(chat_network_stats_conn)) { invalid(); return; } + for (uint32_t i = 0; i < header.count; ++i) { + if (remaining < sizeof(chat_network_stats_conn)) { invalid(); return; } + Connection conn; memcpy(&conn.value, p, sizeof(conn.value)); p += sizeof(conn.value); remaining -= sizeof(conn.value); + if (!conn.value.id || present.contains(conn.value.id) || conn.value.up > 1 || + !memchr(conn.value.name, 0, sizeof(conn.value.name)) || conn.value.link_count > remaining / sizeof(chat_network_stats_link)) { + invalid(); return; + } + present.insert(conn.value.id); QSet links; + for (uint32_t j = 0; j < conn.value.link_count; ++j) { + chat_network_stats_link link; memcpy(&link, p, sizeof(link)); p += sizeof(link); remaining -= sizeof(link); + if (!link.id || links.contains(link.id) || !link.description.id || link.description.status > 1 || + !memchr(link.description.protocol, 0, sizeof(link.description.protocol)) || + !memchr(link.description.socket_name, 0, sizeof(link.description.socket_name)) || + (link.description.local.family && link.description.local.family != 4 && link.description.local.family != 6) || + (link.description.remote.family && link.description.remote.family != 4 && link.description.remote.family != 6)) { + invalid(); return; + } + links.insert(link.id); conn.links.push_back(link); + } + connections.push_back(std::move(conn)); + } + if (remaining) { invalid(); return; } + for (auto it = m_connections.begin(); it != m_connections.end();) { + if (present.contains(it.key())) { ++it; continue; } + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "traffic graph connection removed: id=%llx", (unsigned long long)it.key()); + delete it.value(); it = m_connections.erase(it); + } + for (const auto& conn : connections) { + auto* panel = m_connections.value(conn.value.id); + if (!panel) { + panel = new TrafficConnection(conn.value.id, this); m_rows->addWidget(panel); m_connections.insert(conn.value.id, panel); + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "traffic graph connection added: id=%llx peer=%016llx links=%u", + (unsigned long long)conn.value.id, (unsigned long long)conn.value.peer_id, conn.value.link_count); + } + panel->apply(conn.value, conn.links, header.at_ms); + } + m_empty->setText(text("No ETCP connections")); m_empty->setVisible(connections.empty()); +} diff --git a/tools/chatgui/src/networktrafficwindow.h b/tools/chatgui/src/networktrafficwindow.h new file mode 100644 index 000000000..9c57fa734 --- /dev/null +++ b/tools/chatgui/src/networktrafficwindow.h @@ -0,0 +1,33 @@ +/* Отдельное окно сетевого трафика: по три графика на ETCP_CONN, одна легенда для inflight/TX/RX. + * Только GUI-поток; живые ETCP-объекты здесь не читаются. Закрытие удаляет окно, скрытие останавливает опрос. */ +#pragma once + +#include +#include +#include + +class QLabel; +class QTimer; +class QVBoxLayout; +class TrafficConnection; + +class NetworkTrafficWindow : public QDialog { +public: + // Показать/поднять единственный экземпляр; он владеет независимым callback моста. + static NetworkTrafficWindow* showMonitor(); + ~NetworkTrafficWindow() override; + // Копировать и проверить целиком снимок chat_network_stats.h; data заимствован на время вызова. + void onSnapshot(const uint8_t* data, int len); +protected: + void showEvent(QShowEvent*) override; + void hideEvent(QHideEvent*) override; + void closeEvent(QCloseEvent*) override; +private: + NetworkTrafficWindow(); + void onTimerTick(); + QTimer* m_timer; // Опрос каждые 250 мс только при видимом окне. + QVBoxLayout* m_rows; // Вертикальные панели внутри scroll area, принадлежат окну. + QLabel* m_empty; // Ожидание, отсутствие подключений или ошибка сбора. + QMap m_connections; // Панели по lifetime ID, а не по номеру строки/peer_id. + uint64_t m_requestId = 0; // Последний принятый мостом запрос; старые ответы игнорируются. +}; diff --git a/tools/chatgui/src/outline_icon.h b/tools/chatgui/src/outline_icon.h index ff54cd47a..0fbdda539 100644 --- a/tools/chatgui/src/outline_icon.h +++ b/tools/chatgui/src/outline_icon.h @@ -8,6 +8,18 @@ #include #include +/* Общий контур микрофона для ввода и звонка; muted добавляет диагональное перечёркивание. */ +inline QPainterPath microphoneOutline(bool muted = false) { + QPainterPath path; + path.addRoundedRect(QRectF(9, 2, 6, 12), 3, 3); + path.moveTo(5, 10); path.lineTo(5, 12); + path.cubicTo(5, 15.866, 8.134, 19, 12, 19); path.cubicTo(15.866, 19, 19, 15.866, 19, 12); path.lineTo(19, 10); + path.moveTo(12, 19); path.lineTo(12, 22); path.moveTo(8, 22); path.lineTo(16, 22); + if (muted) { path.moveTo(3, 3); path.lineTo(21, 21); } + return path; +} + +/* Рисует path в цвете color; logicalSize — размер кнопочного значка без учёта масштаба экрана. */ inline QIcon outlineIcon(const QPainterPath& path, const QColor& color, int logicalSize = 20) { QIcon icon; for (int scale : {1, 2, 3, 4}) { diff --git a/tools/chatgui/tests/test_network_traffic.cpp b/tools/chatgui/tests/test_network_traffic.cpp new file mode 100644 index 000000000..2c4169032 --- /dev/null +++ b/tools/chatgui/tests/test_network_traffic.cpp @@ -0,0 +1,191 @@ +/* Снимок uasync → GUI, динамика панелей/поколений, общий переключатель и реальные пиксели трёх графиков. */ +#include "../src/networktrafficwindow.h" +#include "../src/connmonitorwindow.h" +#include "../transport/gui_bridge.h" +#include "../../../src/chat/chat_network_stats.h" +#include "../../../src/chat/chat_event.h" +#include "../../../src/utun_instance.h" +#include "../../../src/transport_layer/etcp.h" +#include "../../../src/transport_layer/etcp_connections.h" +#include "../../../lib/u_async.h" +#include "../../../lib/mem.h" +#include "../../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include + +static QByteArray lastSnapshot; +static uint64_t lastRequest; +static void forward(UTUN_INSTANCE*, int type, const uint8_t* data, int len) { + if (type != CHAT_EVT_NETWORK_STATS) return; + lastSnapshot = QByteArray(reinterpret_cast(data), len); + chat_network_stats_header header; memcpy(&header, data, sizeof(header)); lastRequest = header.request_id; + gui_bridge_post(type, data, len); +} + +struct TestCore { + UASYNC* ua = uasync_create(); + UTUN_INSTANCE instance{}; + char queueName[16] = "traffic_test"; + TestCore() { + instance.ua = ua; instance.connections = queue_new(ua, 0, 0, 0, queueName); + chat_event_set_handler(&instance, forward); + uasync_mark_running(ua); gui_bridge_set_uasync(ua); gui_bridge_set_inst(&instance); + } + ~TestCore() { + gui_bridge_set_inst(nullptr); gui_bridge_set_uasync(nullptr); + queue_clear(instance.connections); queue_free(instance.connections); + uasync_mark_stopped(ua); uasync_destroy(ua, 0); + } + void poll() { uasync_poll(ua, 0); QCoreApplication::processEvents(); } +}; +struct WindowGuard { + QPointer window = NetworkTrafficWindow::showMonitor(); + WindowGuard() { window->findChild()->stop(); } + ~WindowGuard() { delete window.data(); } +}; +struct TestConnection { chat_network_stats_conn value{}; QList links; }; + +static TestConnection connection(quint64 id, unsigned sample = 0) { + TestConnection result; + result.value.id = id; result.value.peer_id = id + 100; result.value.up = 1; + result.value.tx_bytes = result.value.rx_bytes = sample * 750; strcpy(result.value.name, "Test peer"); + for (unsigned index = 1; index <= 2; ++index) { + chat_network_stats_link link{}; link.id = index; link.description.id = index; link.description.status = 1; + strcpy(link.description.protocol, index == 1 ? "UDP" : "TCP+REALITY"); + link.tx_bytes = sample * index * 250; link.rx_bytes = sample * (3 - index) * 250; + link.inflight = index == 1 ? 1000 : 3000; result.links.append(link); + } + return result; +} +static QByteArray snapshot(uint64_t at, const QList& connections) { + chat_network_stats_header header{}; header.at_ms = at; header.request_id = lastRequest; header.count = connections.size(); + QByteArray bytes(reinterpret_cast(&header), sizeof(header)); + for (auto conn : connections) { + conn.value.link_count = conn.links.size(); bytes.append(reinterpret_cast(&conn.value), sizeof(conn.value)); + for (const auto& link : conn.links) bytes.append(reinterpret_cast(&link), sizeof(link)); + } + return bytes; +} +static void apply(NetworkTrafficWindow* window, const QByteArray& bytes) { + window->onSnapshot(reinterpret_cast(bytes.constData()), bytes.size()); + QCoreApplication::processEvents(); +} +static QWidget* panel(NetworkTrafficWindow* window, quint64 id = 17) { + return window->findChild(QStringLiteral("trafficConnection_%1").arg(id, 0, 16)); +} +static QImage render(QWidget* canvas) { + QImage image(canvas->size(), QImage::Format_ARGB32_Premultiplied); image.fill(Qt::transparent); canvas->render(&image); return image; +} +static int coloredPixels(const QImage& image, const QColor& color, int row, double* averageY = nullptr) { + int count = 0; double sum = 0; + for (int y = row * 122 + 23; y < row * 122 + 102; ++y) for (int x = 80; x < image.width() - 18; ++x) { + QColor pixel = image.pixelColor(x, y); + if (qAbs(pixel.red() - color.red()) + qAbs(pixel.green() - color.green()) + qAbs(pixel.blue() - color.blue()) > 35) continue; + ++count; sum += y; + } + if (averageY) *averageY = count ? sum / count : -1; + return count; +} + +class NetworkTrafficTest : public QObject { + Q_OBJECT +private slots: + void initTestCase() { debug_config_init(); debug_set_level(DEBUG_LEVEL_DEBUG); gui_bridge_init(); } + + void nativeSnapshotCopiesCountersAndPhysicalInflight() { + TestCore core; QVERIFY(core.ua); QVERIFY(core.instance.connections); + ETCP_CONN conn{}; ETCP_LINK first{}, second{}; char name[] = "pending transport"; + conn.instance = &core.instance; conn.name = name; conn.diagnostic_id = 123; + conn.diagnostic_wire_tx_bytes = 1234567890123ULL; conn.diagnostic_wire_rx_bytes = 2345678901234ULL; + first.etcp = second.etcp = &conn; first.next = &second; conn.links = &first; + first.local_link_id = 1; first.diagnostic_id = 4; first.inflight_bytes = 100; + second.local_link_id = 2; second.diagnostic_id = 9; second.inflight_bytes = 300; + first.diagnostic_wire_tx_bytes = 77; second.diagnostic_wire_rx_bytes = 88; + auto* entry = queue_entry_new(sizeof(conn_queue_entry)); QVERIFY(entry); + auto* record = reinterpret_cast(entry->data); record->conn = &conn; + QCOMPARE(queue_data_put(core.instance.connections, entry), 0); + uint64_t id = 0; QCOMPARE(gui_bridge_request_network_stats(&id), 1); + QCOMPARE(gui_bridge_request_network_stats(&id), 2); core.poll(); + QCOMPARE(lastSnapshot.size(), qsizetype(sizeof(chat_network_stats_header) + sizeof(chat_network_stats_conn) + + 2 * sizeof(chat_network_stats_link))); + chat_network_stats_header header; memcpy(&header, lastSnapshot.constData(), sizeof(header)); + QCOMPARE(header.count, 1u); QCOMPARE(header.failed, 0u); QCOMPARE(header.request_id, id); + chat_network_stats_conn copied; memcpy(&copied, lastSnapshot.constData() + sizeof(header), sizeof(copied)); + QCOMPARE(copied.id, conn.diagnostic_id); QCOMPARE(copied.peer_id, uint64_t(0)); + QCOMPARE(copied.tx_bytes, conn.diagnostic_wire_tx_bytes); QCOMPARE(copied.rx_bytes, conn.diagnostic_wire_rx_bytes); + chat_network_stats_link link; + memcpy(&link, lastSnapshot.constData() + sizeof(header) + sizeof(copied), sizeof(link)); + QCOMPARE(link.id, first.diagnostic_id); QCOMPARE(link.tx_bytes, uint64_t(77)); QCOMPARE(link.inflight, uint64_t(100)); + QVERIFY(core.ua->posted_tasks_head == nullptr); + } + + void legendControlsAllThreePlotsAndRatesUseElapsedTime() { + TestCore core; WindowGuard guard; core.poll(); QVERIFY(lastRequest); + auto* window = guard.window.data(); + for (unsigned i = 0; i <= 120; ++i) apply(window, snapshot(100000 + i * 250, {connection(17, i)})); + auto* row = panel(window); QVERIFY(row); + auto* canvas = row->findChild("trafficCanvas"); QVERIFY(canvas); + auto* total = row->findChild("trafficSeries_0"); QVERIFY(total); + auto* first = row->findChild("trafficSeries_1"); QVERIFY(first); + const QColor color = QColor::fromHsv(35, 190, 210); + QImage before = render(canvas); + // inflight=1000/5000, TX=1000/5000 B/s, RX=2000/5000 B/s; кривые на разных осях. + for (int graph = 0; graph < 3; ++graph) { + double average = 0; QVERIFY(coloredPixels(before, color, graph, &average) > 200); + double expected = graph * 122 + 100 - 76 * (graph == 2 ? .4 : .2); + QVERIFY2(qAbs(average - expected) < 1.5, qPrintable(QString("row=%1 actual=%2 expected=%3").arg(graph).arg(average).arg(expected))); + } + QTest::mouseClick(first, Qt::LeftButton); QVERIFY(!first->isChecked()); QVERIFY(total->isChecked()); + QImage hidden = render(canvas); + for (int graph = 0; graph < 3; ++graph) QCOMPARE(coloredPixels(hidden, color, graph), 0); + apply(window, snapshot(130250, {connection(17, 121)})); QVERIFY(!first->isChecked()); + QTest::mouseClick(first, Qt::LeftButton); QVERIFY(first->isChecked()); + QImage restored = render(canvas); + for (int graph = 0; graph < 3; ++graph) QVERIFY(coloredPixels(restored, color, graph) > 200); + QTest::mouseClick(total, Qt::LeftButton); QVERIFY(!total->isChecked()); QVERIFY(first->isChecked()); + window->grab().save("/tmp/network-traffic-preview.png"); + } + + void dynamicConnectionsAndReusedLinkNumberKeepSeparateHistory() { + TestCore core; WindowGuard guard; core.poll(); auto* window = guard.window.data(); + apply(window, snapshot(100000, {connection(17), connection(18)})); + QPointer firstPanel = panel(window), removed = panel(window, 18); QVERIFY(firstPanel); QVERIFY(removed); + auto* button = firstPanel->findChild("trafficSeries_1"); QVERIFY(button); + QTest::mouseClick(button, Qt::LeftButton); QPointer old = button; + auto updated = connection(17, 1); updated.links[0].id = 500; // LINK1 с новым поколением. + apply(window, snapshot(100250, {updated})); + QVERIFY(!removed); QVERIFY(!old); QCOMPARE(panel(window), firstPanel.data()); + auto* replacement = firstPanel->findChild("trafficSeries_500"); QVERIFY(replacement); + QVERIFY(replacement->isChecked()); QCOMPARE(replacement->accessibleName(), QString("LINK1")); + auto* other = firstPanel->findChild("trafficSeries_2"); QVERIFY(other); + QVERIFY(replacement->x() < other->x()); // Порядок по номеру линка, независимо от поколения. + updated.value.up = 0; apply(window, snapshot(100500, {updated})); QCOMPARE(panel(window), firstPanel.data()); + auto malformed = snapshot(100750, {}); malformed.append('x'); apply(window, malformed); QVERIFY(firstPanel); + malformed = snapshot(100750, {updated}); malformed.chop(1); apply(window, malformed); QVERIFY(firstPanel); + apply(window, snapshot(101000, {})); QVERIFY(!firstPanel); + } + + void hideCloseDetachAndConnectionMonitorDoNotStealCallbacks() { + TestCore core; WindowGuard first; core.poll(); auto* window = first.window.data(); + QPointer monitor = ConnMonitorWindow::showMonitor(); monitor->findChild()->stop(); + apply(window, snapshot(100000, {connection(17)})); QVERIFY(panel(window)); + window->hide(); QVERIFY(!window->findChild()->isActive()); + apply(window, snapshot(100250, {})); QVERIFY(panel(window)); + QCOMPARE(NetworkTrafficWindow::showMonitor(), window); window->findChild()->stop(); core.poll(); + QVERIFY(!panel(window)); + window->close(); WindowGuard second; + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); QVERIFY(!first.window); + core.poll(); apply(second.window, snapshot(101000, {connection(17)})); QVERIFY(panel(second.window)); + delete monitor.data(); + uint64_t id = 0; QCOMPARE(gui_bridge_request_network_stats(&id), 1); + gui_bridge_set_inst(nullptr); QCOMPARE(gui_bridge_request_network_stats(&id), 0); + gui_bridge_set_inst(&core.instance); QCOMPARE(gui_bridge_request_network_stats(&id), 1); core.poll(); + } +}; + +QTEST_MAIN(NetworkTrafficTest) +#include "test_network_traffic.moc" diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index 76d524fd8..2e72d77f0 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -167,6 +167,15 @@ Use CPU encoding instead? This may cause high CPU usage and slow down the comput Video settingsVideo settings DiagnosticsDiagnostics Audio graphsAudio graphs + Network TrafficNetwork Traffic + ConnectingConnecting + Last 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a seriesLast 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a series + All links: inflight / TX / RXAll links: inflight / TX / RX + InflightInflight + Waiting for network statisticsWaiting for network statistics + Network core unavailableNetwork core unavailable + Unable to collect network statisticsUnable to collect network statistics + No ETCP connectionsNo ETCP connections Audio history — last 30 secondsAudio history — last 30 seconds Real-timeReal-time Waiting for audio / last snapshotWaiting for audio / last snapshot diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 848cd8eee..bbe7e9583 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -167,6 +167,15 @@ Use CPU encoding instead? This may cause high CPU usage and slow down the comput Video settingsНастройки видео DiagnosticsДиагностика Audio graphsГрафики аудио + Network TrafficСетевой трафик + ConnectingПодключение + Last 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a seriesПоследние 30 секунд · TX / RX — среднее за секунду · Нажмите на легенду, чтобы показать или скрыть серию + All links: inflight / TX / RXВсе линки: inflight / TX / RX + InflightДанные в полёте + Waiting for network statisticsОжидание сетевой статистики + Network core unavailableСетевое ядро недоступно + Unable to collect network statisticsНе удалось получить сетевую статистику + No ETCP connectionsНет ETCP-подключений Audio history — last 30 secondsИстория аудио за последние 30 секунд Real-timeВ реальном времени Waiting for audio / last snapshotОжидание аудио / последний снимок diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 9720eb006..93050deff 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -212,6 +212,12 @@ void gui_bridge_set_conn_list_cb(gui_conn_list_fn cb); typedef void (*gui_conn_metrics_fn)(const uint8_t* data, int len); void gui_bridge_set_conn_metrics_cb(gui_conn_metrics_fn cb); +/* Независимый канал графиков, callback в GUI-потоке; формат — chat_network_stats.h. */ +void gui_bridge_set_network_stats_cb(gui_conn_metrics_fn cb); +/* Под mutex привязать запрос к running-ядру. 1 — принят (*request_id заполнен), 2 — предыдущий ещё выполняется, + * 0 — ядро недоступно, -1 — ошибка с логом. Не более одного запроса до ответа/отключения ядра. */ +int gui_bridge_request_network_stats(uint64_t* request_id); + /* Callback для live-снапшота панели деталей участника (вызывается из GUI-потока) data: см. CHAT_MEMBER_DETAIL_* в chat_member.h; проверка целого пакета до применения. */ typedef void (*gui_member_detail_fn)(const uint8_t* data, int len); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index bac2c8b1c..7a18ff77c 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -22,6 +22,7 @@ extern "C" { #include "../../../src/member_files/member_files.h" #include "../../../src/chat/chat_member.h" #include "../../../src/chat/chat_core.h" +#include "../../../src/chat/chat_network_stats.h" #include "../../../src/chat/chat_sync.h" #include "../../../src/chat/chat_join_port.h" #include "../../../src/dm/dm_core.h" @@ -59,6 +60,7 @@ static gui_download_progress_fn g_download_progress_cb = nullptr; static gui_invite_received_fn g_invite_received_cb = nullptr; static gui_conn_list_fn g_conn_list_cb = nullptr; static gui_conn_metrics_fn g_conn_metrics_cb = nullptr; +static gui_conn_metrics_fn g_network_stats_cb = nullptr; static gui_member_detail_fn g_member_detail_cb = nullptr; static gui_member_list_fn g_member_list_cb = nullptr; static gui_member_updated_fn g_member_updated_cb = nullptr; @@ -102,6 +104,7 @@ static gui_dm_messages_fn g_stream_sources_cb = nullptr; static gui_network_changed_fn g_network_changed_cb = nullptr; static struct UASYNC* g_ua = nullptr; static std::atomic g_inst{nullptr}; +static uint64_t network_stats_sequence = 0, network_stats_pending = 0; // Под bridge_mutex, включая detach и ответ. struct MemberCompletion { QPointer context; // Читается только в GUI-потоке. @@ -294,6 +297,20 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { eventType == 16 || eventType == 17 || eventType == 24 || eventType == 25 || eventType == 31 || eventType == 33)) g_network_changed_cb(eventType); switch (eventType) { + case CHAT_EVT_NETWORK_STATS: { + chat_network_stats_header header{}; + if (dlen < int(sizeof(header))) { + DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "network stats bridge: truncated header bytes=%d", dlen); break; + } + memcpy(&header, d, sizeof(header)); + { + std::lock_guard guard(bridge_mutex); + if (header.request_id != network_stats_pending) break; + network_stats_pending = 0; + } + if (g_network_stats_cb) g_network_stats_cb(d, dlen); + break; + } case CHAT_EVT_MSG_UPDATED: if (dlen < 34 || !d[0] || dlen != 1 + d[0] + 32) { DEBUG_ERROR(DEBUG_CATEGORY_CHAT, "invalid message update bytes=%d", dlen); break; @@ -719,7 +736,7 @@ void gui_bridge_set_uasync(struct UASYNC* ua) { void gui_bridge_set_inst(struct UTUN_INSTANCE* inst) { struct chat_join_port* replacement = inst ? chat_sync_join_port(inst) : nullptr; std::lock_guard guard(bridge_mutex); - if (g_inst != inst) cancel_posts(); + if (g_inst != inst) { cancel_posts(); network_stats_pending = 0; } chat_join_port_release(join_port); join_port = replacement; g_inst = inst; DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "GUI bridge instance %s: inst=%p ua=%p", inst ? "attached" : "detached", (void*)inst, (void*)g_ua); @@ -1174,6 +1191,25 @@ void gui_bridge_set_conn_metrics_cb(gui_conn_metrics_fn cb) { g_conn_metrics_cb = cb; } +void gui_bridge_set_network_stats_cb(gui_conn_metrics_fn cb) { + g_network_stats_cb = cb; +} + +/* Очередь не накапливает снимки при занятом uasync или медленном GUI. */ +int gui_bridge_request_network_stats(uint64_t* request_id) { + if (!request_id) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "network stats bridge: missing request output"); return -1; } + std::lock_guard guard(bridge_mutex); + if (!g_receiver || !g_inst || !g_ua || !uasync_is_running(g_ua)) return 0; + if (network_stats_pending) return 2; + auto* request = static_cast(u_calloc(1, sizeof(chat_network_stats_request))); + if (!request) { DEBUG_ERROR(DEBUG_CATEGORY_GENERAL, "network stats bridge: request allocation failed"); return -1; } + request->inst = g_inst; request->request_id = ++network_stats_sequence; + if (post_command(chat_core_collect_network_stats_trampoline, request, true) < 0) { u_free(request); return -1; } + network_stats_pending = *request_id = request->request_id; + DEBUG_TRACE(DEBUG_CATEGORY_GENERAL, "network stats bridge: queued request=%llu", (unsigned long long)*request_id); + return 1; +} + void gui_bridge_set_member_detail_cb(gui_member_detail_fn cb) { g_member_detail_cb = cb; }