Add desktop network traffic graphs and unify microphone icons
This commit is contained in:
1 parent
3dff681ff0
commit
e384d4cf3d
26 files changed
+809
-29
No files matched your search
+1
-1
@@ -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 \
|
||||
|
||||
@@ -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 = "?";
|
||||
|
||||
@@ -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. */
|
||||
|
||||
// ── Подключение получателя событий ──
|
||||
|
||||
|
||||
@@ -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 <stdint.h>
|
||||
|
||||
#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
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <QCoreApplication>
|
||||
#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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -66,6 +66,7 @@ signals:
|
||||
void createGroupRequested();
|
||||
void settingsRequested();
|
||||
void connMonitorRequested();
|
||||
void networkTrafficRequested();
|
||||
|
||||
protected:
|
||||
/* Принимает drag/drop в footer; фильтры устанавливаются на него и его кнопки/подписи. */
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 <QCloseEvent>
|
||||
#include <QCoreApplication>
|
||||
#include <QGridLayout>
|
||||
#include <QHostAddress>
|
||||
#include <QLabel>
|
||||
#include <QPainter>
|
||||
#include <QPainterPath>
|
||||
#include <QScrollArea>
|
||||
#include <QSet>
|
||||
#include <QTimer>
|
||||
#include <QToolButton>
|
||||
#include <QVBoxLayout>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstring>
|
||||
#include <deque>
|
||||
#include <vector>
|
||||
|
||||
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<TrafficSample> samples; // Ограниченная история и накопительные счётчики для секундного среднего.
|
||||
};
|
||||
|
||||
/* Единственный холст панели: одна временная шкала, независимый масштаб каждого ряда. */
|
||||
class TrafficCanvas : public QWidget {
|
||||
public:
|
||||
explicit TrafficCanvas(QWidget* parent) : QWidget(parent) { setObjectName("trafficCanvas"); setFixedHeight(366); }
|
||||
QMap<quint64, TrafficSeries> 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<chat_network_stats_link>& 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<quint64> 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<TrafficSeries*> 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<chat_network_stats_link> links; };
|
||||
std::vector<Connection> connections; QSet<quint64> 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<quint64> 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());
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/* Отдельное окно сетевого трафика: по три графика на ETCP_CONN, одна легенда для inflight/TX/RX.
|
||||
* Только GUI-поток; живые ETCP-объекты здесь не читаются. Закрытие удаляет окно, скрытие останавливает опрос. */
|
||||
#pragma once
|
||||
|
||||
#include <QDialog>
|
||||
#include <QMap>
|
||||
#include <cstdint>
|
||||
|
||||
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<quint64, TrafficConnection*> m_connections; // Панели по lifetime ID, а не по номеру строки/peer_id.
|
||||
uint64_t m_requestId = 0; // Последний принятый мостом запрос; старые ответы игнорируются.
|
||||
};
|
||||
@@ -8,6 +8,18 @@
|
||||
#include <QPainterPath>
|
||||
#include <QPixmap>
|
||||
|
||||
/* Общий контур микрофона для ввода и звонка; 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}) {
|
||||
|
||||
@@ -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 <QtTest>
|
||||
#include <QImage>
|
||||
#include <QPointer>
|
||||
#include <QToolButton>
|
||||
#include <QTimer>
|
||||
#include <cstring>
|
||||
|
||||
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<const char*>(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<NetworkTrafficWindow> window = NetworkTrafficWindow::showMonitor();
|
||||
WindowGuard() { window->findChild<QTimer*>()->stop(); }
|
||||
~WindowGuard() { delete window.data(); }
|
||||
};
|
||||
struct TestConnection { chat_network_stats_conn value{}; QList<chat_network_stats_link> 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<TestConnection>& connections) {
|
||||
chat_network_stats_header header{}; header.at_ms = at; header.request_id = lastRequest; header.count = connections.size();
|
||||
QByteArray bytes(reinterpret_cast<const char*>(&header), sizeof(header));
|
||||
for (auto conn : connections) {
|
||||
conn.value.link_count = conn.links.size(); bytes.append(reinterpret_cast<const char*>(&conn.value), sizeof(conn.value));
|
||||
for (const auto& link : conn.links) bytes.append(reinterpret_cast<const char*>(&link), sizeof(link));
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
static void apply(NetworkTrafficWindow* window, const QByteArray& bytes) {
|
||||
window->onSnapshot(reinterpret_cast<const uint8_t*>(bytes.constData()), bytes.size());
|
||||
QCoreApplication::processEvents();
|
||||
}
|
||||
static QWidget* panel(NetworkTrafficWindow* window, quint64 id = 17) {
|
||||
return window->findChild<QWidget*>(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<conn_queue_entry*>(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<QWidget*>("trafficCanvas"); QVERIFY(canvas);
|
||||
auto* total = row->findChild<QToolButton*>("trafficSeries_0"); QVERIFY(total);
|
||||
auto* first = row->findChild<QToolButton*>("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<QWidget> firstPanel = panel(window), removed = panel(window, 18); QVERIFY(firstPanel); QVERIFY(removed);
|
||||
auto* button = firstPanel->findChild<QToolButton*>("trafficSeries_1"); QVERIFY(button);
|
||||
QTest::mouseClick(button, Qt::LeftButton); QPointer<QToolButton> 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<QToolButton*>("trafficSeries_500"); QVERIFY(replacement);
|
||||
QVERIFY(replacement->isChecked()); QCOMPARE(replacement->accessibleName(), QString("LINK1"));
|
||||
auto* other = firstPanel->findChild<QToolButton*>("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<ConnMonitorWindow> monitor = ConnMonitorWindow::showMonitor(); monitor->findChild<QTimer*>()->stop();
|
||||
apply(window, snapshot(100000, {connection(17)})); QVERIFY(panel(window));
|
||||
window->hide(); QVERIFY(!window->findChild<QTimer*>()->isActive());
|
||||
apply(window, snapshot(100250, {})); QVERIFY(panel(window));
|
||||
QCOMPARE(NetworkTrafficWindow::showMonitor(), window); window->findChild<QTimer*>()->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"
|
||||
@@ -167,6 +167,15 @@ Use CPU encoding instead? This may cause high CPU usage and slow down the comput
|
||||
<message><source>Video settings</source><translation>Video settings</translation></message>
|
||||
<message><source>Diagnostics</source><translation>Diagnostics</translation></message>
|
||||
<message><source>Audio graphs</source><translation>Audio graphs</translation></message>
|
||||
<message><source>Network Traffic</source><translation>Network Traffic</translation></message>
|
||||
<message><source>Connecting</source><translation>Connecting</translation></message>
|
||||
<message><source>Last 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a series</source><translation>Last 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a series</translation></message>
|
||||
<message><source>All links: inflight / TX / RX</source><translation>All links: inflight / TX / RX</translation></message>
|
||||
<message><source>Inflight</source><translation>Inflight</translation></message>
|
||||
<message><source>Waiting for network statistics</source><translation>Waiting for network statistics</translation></message>
|
||||
<message><source>Network core unavailable</source><translation>Network core unavailable</translation></message>
|
||||
<message><source>Unable to collect network statistics</source><translation>Unable to collect network statistics</translation></message>
|
||||
<message><source>No ETCP connections</source><translation>No ETCP connections</translation></message>
|
||||
<message><source>Audio history — last 30 seconds</source><translation>Audio history — last 30 seconds</translation></message>
|
||||
<message><source>Real-time</source><translation>Real-time</translation></message>
|
||||
<message><source>Waiting for audio / last snapshot</source><translation>Waiting for audio / last snapshot</translation></message>
|
||||
|
||||
@@ -167,6 +167,15 @@ Use CPU encoding instead? This may cause high CPU usage and slow down the comput
|
||||
<message><source>Video settings</source><translation>Настройки видео</translation></message>
|
||||
<message><source>Diagnostics</source><translation>Диагностика</translation></message>
|
||||
<message><source>Audio graphs</source><translation>Графики аудио</translation></message>
|
||||
<message><source>Network Traffic</source><translation>Сетевой трафик</translation></message>
|
||||
<message><source>Connecting</source><translation>Подключение</translation></message>
|
||||
<message><source>Last 30 seconds · TX / RX averaged over 1 second · Click legend to show or hide a series</source><translation>Последние 30 секунд · TX / RX — среднее за секунду · Нажмите на легенду, чтобы показать или скрыть серию</translation></message>
|
||||
<message><source>All links: inflight / TX / RX</source><translation>Все линки: inflight / TX / RX</translation></message>
|
||||
<message><source>Inflight</source><translation>Данные в полёте</translation></message>
|
||||
<message><source>Waiting for network statistics</source><translation>Ожидание сетевой статистики</translation></message>
|
||||
<message><source>Network core unavailable</source><translation>Сетевое ядро недоступно</translation></message>
|
||||
<message><source>Unable to collect network statistics</source><translation>Не удалось получить сетевую статистику</translation></message>
|
||||
<message><source>No ETCP connections</source><translation>Нет ETCP-подключений</translation></message>
|
||||
<message><source>Audio history — last 30 seconds</source><translation>История аудио за последние 30 секунд</translation></message>
|
||||
<message><source>Real-time</source><translation>В реальном времени</translation></message>
|
||||
<message><source>Waiting for audio / last snapshot</source><translation>Ожидание аудио / последний снимок</translation></message>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<UTUN_INSTANCE*> g_inst{nullptr};
|
||||
static uint64_t network_stats_sequence = 0, network_stats_pending = 0; // Под bridge_mutex, включая detach и ответ.
|
||||
|
||||
struct MemberCompletion {
|
||||
QPointer<QObject> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<chat_network_stats_request*>(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;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user