Files
navegator_dashboard/network_state.cpp
T
egutierrez 225b59069b feat(network): reload page button + ImPlot histogram + WS stats
Bug reportado: tabla Network vacia. Causa real: sin actividad de red en la
pestaña no hay eventos Network.* — la tabla solo se llena cuando el browser
realmente hace peticiones. Faltaba un boton para forzar Page.reload desde la
UI y un overview visual de actividad.

NetworkSession::reload_page(ignore_cache) — envia Page.reload por la WS CDP
activa. Equivalente a F5 / Ctrl+Shift+R.
NetworkSession::ws_frames_in/bytes_in/bytes_out — accessors a stats del CDP
WebSocket subyacente, expuestos para diagnostico vivo.

Network panel toolbar:
- Boton "Reload" (TI_REFRESH) — invoca reload_page().
- Checkbox "Bypass cache" — controla el flag ignoreCache.
- Toggle "Histogram" (TI_CHART_HISTOGRAM) — muestra/oculta overview.

Histograma overview (ImPlot::PlotHistogram):
- Eje X: tiempo de inicio (s) desde apertura de la sesion.
- Eje Y: requests por bin (30 bins por defecto, AutoFit).
- Marcadores TagX: DOMContentLoaded (DCL) y Load (L) tomados de Page.* events.
- Altura fija 100px, sin titulo/menu/box-select.

Status bar:
- Reemplaza placeholder "WS bytes 0/0" por estado real:
  - "CDP: alive" en verde si frames_in>0, "CDP: no events" en warning si 0.
  - Cuenta de frames + bytes in/out humanizados.

Util para diagnosticar: si "CDP: alive" pero tabla vacia → eventos llegan
pero no estan disparando peticiones nuevas → dale a Reload. Si "no events"
→ WS rota o pestaña no enganchada — investigar la conexion.

Tests: 6/6 siguen pasando (no se tocan los hooks de layouts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-10 13:21:01 +02:00

514 lines
20 KiB
C++

#include "network_state.h"
#include "crude_json.h"
#include <algorithm>
#include <cstdio>
#include <sstream>
namespace navegator {
const char* resource_type_label(ResourceType t) {
switch (t) {
case ResourceType::Document: return "doc";
case ResourceType::Stylesheet: return "css";
case ResourceType::Image: return "img";
case ResourceType::Media: return "media";
case ResourceType::Font: return "font";
case ResourceType::Script: return "js";
case ResourceType::TextTrack: return "track";
case ResourceType::XHR: return "xhr";
case ResourceType::Fetch: return "fetch";
case ResourceType::EventSource: return "eventsource";
case ResourceType::WebSocket: return "ws";
case ResourceType::Manifest: return "manifest";
case ResourceType::SignedExchange: return "sxg";
case ResourceType::Ping: return "ping";
case ResourceType::CSPViolationReport: return "csp";
case ResourceType::Preflight: return "preflight";
default: return "other";
}
}
ResourceType parse_resource_type(const std::string& s) {
if (s == "Document") return ResourceType::Document;
if (s == "Stylesheet") return ResourceType::Stylesheet;
if (s == "Image") return ResourceType::Image;
if (s == "Media") return ResourceType::Media;
if (s == "Font") return ResourceType::Font;
if (s == "Script") return ResourceType::Script;
if (s == "TextTrack") return ResourceType::TextTrack;
if (s == "XHR") return ResourceType::XHR;
if (s == "Fetch") return ResourceType::Fetch;
if (s == "EventSource") return ResourceType::EventSource;
if (s == "WebSocket") return ResourceType::WebSocket;
if (s == "Manifest") return ResourceType::Manifest;
if (s == "SignedExchange") return ResourceType::SignedExchange;
if (s == "Ping") return ResourceType::Ping;
if (s == "CSPViolationReport") return ResourceType::CSPViolationReport;
if (s == "Preflight") return ResourceType::Preflight;
return ResourceType::Other;
}
namespace {
const crude_json::value& at(const crude_json::value& v, const char* key) {
static const crude_json::value null_v;
if (!v.is_object() || !v.contains(key)) return null_v;
return v[key];
}
std::string str_or(const crude_json::value& v, const char* key, const std::string& def = "") {
const auto& f = at(v, key);
return f.is_string() ? f.get<std::string>() : def;
}
double num_or(const crude_json::value& v, const char* key, double def = 0.0) {
const auto& f = at(v, key);
return f.is_number() ? f.get<double>() : def;
}
bool bool_or(const crude_json::value& v, const char* key, bool def = false) {
const auto& f = at(v, key);
return f.is_boolean() ? f.get<bool>() : def;
}
void parse_headers(const crude_json::value& obj, std::vector<HeaderKV>& out) {
out.clear();
if (!obj.is_object()) return;
const auto& m = obj.get<crude_json::object>();
out.reserve(m.size());
for (const auto& kv : m) {
HeaderKV h;
h.name = kv.first;
if (kv.second.is_string()) h.value = kv.second.get<std::string>();
else if (kv.second.is_number()) {
char b[32];
std::snprintf(b, sizeof(b), "%g", kv.second.get<double>());
h.value = b;
}
out.push_back(std::move(h));
}
}
std::string json_escape_str(const std::string& s) {
std::string out;
out.reserve(s.size() + 2);
out.push_back('"');
for (char c : s) {
switch (c) {
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if ((unsigned char)c < 0x20) {
char b[8];
std::snprintf(b, sizeof(b), "\\u%04x", (unsigned)(uint8_t)c);
out += b;
} else out.push_back(c);
}
}
out.push_back('"');
return out;
}
} // namespace
NetworkSession::~NetworkSession() { close(); }
bool NetworkSession::open(const std::string& ws_url, std::string* err) {
close();
std::string host, path;
int port = 0;
if (!CdpWs::parse_ws_url(ws_url, host, port, path)) {
last_err_ = "bad ws url: " + ws_url;
if (err) *err = last_err_;
return false;
}
ws_url_ = ws_url;
ws_ = std::make_unique<CdpWs>();
CdpWsConfig cfg;
cfg.host = host;
cfg.port = port;
cfg.path = path;
std::string e;
if (!ws_->connect(cfg, &e)) {
last_err_ = "ws connect failed: " + e;
if (err) *err = last_err_;
ws_.reset();
return false;
}
// Habilitar dominios.
ws_->send_command("Network.enable",
"{\"maxTotalBufferSize\":10000000,\"maxResourceBufferSize\":5000000,\"maxPostDataSize\":65536}");
ws_->send_command("Page.enable");
ws_->send_command("Runtime.enable");
if (cache_disabled_.load()) {
ws_->send_command("Network.setCacheDisabled", "{\"cacheDisabled\":true}");
}
{
std::lock_guard<std::mutex> lk(mu_);
clear_log_locked();
t0_ = std::chrono::steady_clock::now();
}
return true;
}
void NetworkSession::close() {
if (ws_) {
ws_->close();
ws_.reset();
}
ws_url_.clear();
}
void NetworkSession::clear_log() {
std::lock_guard<std::mutex> lk(mu_);
clear_log_locked();
}
void NetworkSession::clear_log_locked() {
requests_.clear();
by_id_.clear();
stats_ = {};
}
void NetworkSession::set_cache_disabled(bool v) {
cache_disabled_.store(v);
if (ws_ && ws_->is_connected()) {
std::ostringstream os;
os << "{\"cacheDisabled\":" << (v ? "true" : "false") << "}";
ws_->send_command("Network.setCacheDisabled", os.str());
}
}
void NetworkSession::pump() {
if (!ws_) return;
auto msgs = ws_->drain(2048);
for (auto& m : msgs) on_message(m);
}
void NetworkSession::request_body(const std::string& request_id) {
if (!ws_ || request_id.empty()) return;
std::shared_ptr<NetworkRequest> req;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(request_id);
if (it == by_id_.end()) return;
req = it->second;
if (req->body_fetched) return;
req->body_fetched = true; // optimistic; on response set text
}
std::string params = "{\"requestId\":" + json_escape_str(request_id) + "}";
ws_->send_command("Network.getResponseBody", params);
}
void NetworkSession::on_message(const std::string& json) {
crude_json::value v = crude_json::value::parse(json);
if (!v.is_object()) return;
// Si trae result + id => respuesta a un comando enviado por nosotros.
if (v.contains("id") && v.contains("result")) {
const auto& result = v["result"];
if (result.is_object() && result.contains("body")) {
// Network.getResponseBody. Necesitamos id->requestId map. Lo
// hacemos buscando el primer request sin body fetched. Mejor:
// CDP no devuelve requestId aqui, asi que adoptamos heuristica:
// marcamos en request_body() un campo pending y lo ataremos al
// siguiente result. Para v1 nos conformamos con dejar el body
// fuera hasta que matcheemos manualmente.
// (Limitacion conocida — mejora futura: enviar getResponseBody
// con sus propios ids y guardar id->requestId.)
}
return;
}
if (!v.contains("method")) return;
const std::string method = v["method"].is_string() ? v["method"].get<std::string>() : "";
const auto& params = at(v, "params");
if (!params.is_object()) return;
const double now_secs =
std::chrono::duration<double>(std::chrono::steady_clock::now() - t0_).count();
if (method == "Network.requestWillBeSent") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::shared_ptr<NetworkRequest> req;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) {
req = std::make_shared<NetworkRequest>();
req->id = rid;
req->t_started = now_secs;
requests_.push_back(req);
by_id_[rid] = req;
stats_.total_requests++;
} else {
req = it->second;
}
}
req->loader_id = str_or(params, "loaderId");
req->frame_id = str_or(params, "frameId");
req->document_url = str_or(params, "documentURL");
req->type = parse_resource_type(str_or(params, "type"));
req->ts_request_will_be_sent = num_or(params, "timestamp");
const auto& reqv = at(params, "request");
if (reqv.is_object()) {
req->url = str_or(reqv, "url");
req->url_fragment = str_or(reqv, "urlFragment");
req->method = str_or(reqv, "method");
req->post_data = str_or(reqv, "postData");
req->has_post_data = bool_or(reqv, "hasPostData") || !req->post_data.empty();
parse_headers(at(reqv, "headers"), req->request_headers);
}
const auto& init = at(params, "initiator");
if (init.is_object()) {
req->initiator_type = str_or(init, "type");
req->initiator_url = str_or(init, "url");
req->initiator_line = (int)num_or(init, "lineNumber", -1);
}
// Page.frameNavigated => limpiar log si !preserve_log. Hecho mas abajo.
}
else if (method == "Network.requestWillBeSentExtraInfo") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
parse_headers(at(params, "headers"), it->second->request_headers);
}
else if (method == "Network.responseReceived") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::shared_ptr<NetworkRequest> req;
{
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
req = it->second;
}
req->ts_response_received = num_or(params, "timestamp");
req->t_response = now_secs;
const auto& resp = at(params, "response");
if (resp.is_object()) {
req->status = (int)num_or(resp, "status");
req->status_text = str_or(resp, "statusText");
req->mime_type = str_or(resp, "mimeType");
req->remote_ip = str_or(resp, "remoteIPAddress");
req->remote_port = (int)num_or(resp, "remotePort");
req->protocol = str_or(resp, "protocol");
req->from_cache = bool_or(resp, "fromDiskCache") || bool_or(resp, "fromPrefetchCache");
req->from_disk_cache = bool_or(resp, "fromDiskCache");
req->from_service_worker= bool_or(resp, "fromServiceWorker");
parse_headers(at(resp, "headers"), req->response_headers);
req->encoded_data_length = (int64_t)num_or(resp, "encodedDataLength", req->encoded_data_length);
}
}
else if (method == "Network.responseReceivedExtraInfo") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
parse_headers(at(params, "headers"), it->second->response_headers);
if (params.contains("statusCode")) it->second->status = (int)num_or(params, "statusCode");
}
else if (method == "Network.dataReceived") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
int64_t enc = (int64_t)num_or(params, "encodedDataLength");
int64_t dat = (int64_t)num_or(params, "dataLength");
it->second->encoded_data_length += enc;
it->second->data_received_bytes += dat;
stats_.transferred += enc;
}
else if (method == "Network.loadingFinished") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
auto& r = it->second;
r->finished = true;
r->ts_loading_finished = num_or(params, "timestamp");
r->t_finished = now_secs;
int64_t total_enc = (int64_t)num_or(params, "encodedDataLength");
if (total_enc > r->encoded_data_length) {
stats_.transferred += (total_enc - r->encoded_data_length);
r->encoded_data_length = total_enc;
}
r->response_body_length = r->data_received_bytes;
stats_.resources += r->response_body_length;
stats_.finish_time = now_secs;
}
else if (method == "Network.loadingFailed") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
auto& r = it->second;
r->failed = true;
r->canceled = bool_or(params, "canceled");
r->error_text = str_or(params, "errorText");
r->ts_loading_failed = num_or(params, "timestamp");
r->t_finished = now_secs;
}
else if (method == "Network.webSocketCreated") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
std::shared_ptr<NetworkRequest> req;
if (it == by_id_.end()) {
req = std::make_shared<NetworkRequest>();
req->id = rid;
req->t_started = now_secs;
req->type = ResourceType::WebSocket;
req->url = str_or(params, "url");
requests_.push_back(req);
by_id_[rid] = req;
stats_.total_requests++;
} else {
req = it->second;
req->type = ResourceType::WebSocket;
if (req->url.empty()) req->url = str_or(params, "url");
}
}
else if (method == "Network.webSocketFrameSent" || method == "Network.webSocketFrameReceived") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it == by_id_.end()) return;
WsFrame f;
f.outgoing = (method == "Network.webSocketFrameSent");
f.time = num_or(params, "timestamp");
const auto& resp = at(params, "response");
if (resp.is_object()) {
f.opcode = (int)num_or(resp, "opcode");
f.masked = (int)bool_or(resp, "mask");
f.payload = str_or(resp, "payloadData");
}
if (it->second->ws_frames.size() < 2000) {
it->second->ws_frames.push_back(std::move(f));
}
}
else if (method == "Network.webSocketClosed") {
std::string rid = str_or(params, "requestId");
if (rid.empty()) return;
std::lock_guard<std::mutex> lk(mu_);
auto it = by_id_.find(rid);
if (it != by_id_.end()) {
it->second->finished = true;
it->second->t_finished = now_secs;
}
}
else if (method == "Page.frameNavigated") {
if (!preserve_log_.load()) {
const auto& frame = at(params, "frame");
// Solo limpiar si es el frame top-level (sin parentId).
if (frame.is_object() && !frame.contains("parentId")) {
std::lock_guard<std::mutex> lk(mu_);
clear_log_locked();
t0_ = std::chrono::steady_clock::now();
}
}
}
else if (method == "Page.domContentEventFired") {
std::lock_guard<std::mutex> lk(mu_);
stats_.dom_content_loaded = now_secs;
}
else if (method == "Page.loadEventFired") {
std::lock_guard<std::mutex> lk(mu_);
stats_.load_event = now_secs;
}
}
std::vector<std::shared_ptr<NetworkRequest>> NetworkSession::snapshot() const {
std::lock_guard<std::mutex> lk(mu_);
return requests_;
}
NetworkStats NetworkSession::stats() const {
std::lock_guard<std::mutex> lk(mu_);
return stats_;
}
bool NetworkSession::reload_page(bool ignore_cache) {
if (!ws_ || !ws_->is_connected()) return false;
std::string params = ignore_cache ? "{\"ignoreCache\":true}" : "{\"ignoreCache\":false}";
return ws_->send_command("Page.reload", params) > 0;
}
std::string NetworkSession::export_har_json() const {
// HAR 1.2 minimo. log.entries[].request/response/timings.
std::lock_guard<std::mutex> lk(mu_);
std::ostringstream os;
os << "{\"log\":{\"version\":\"1.2\",\"creator\":{\"name\":\"navegator_dashboard\",\"version\":\"1.0\"},\"entries\":[";
bool first = true;
for (const auto& r : requests_) {
if (!first) os << ",";
first = false;
os << "{";
os << "\"startedDateTime\":\"" << r->t_started << "\",";
os << "\"time\":" << ((r->t_finished > 0 ? r->t_finished : r->t_response) - r->t_started) * 1000.0 << ",";
// request
os << "\"request\":{\"method\":" << json_escape_str(r->method)
<< ",\"url\":" << json_escape_str(r->url)
<< ",\"httpVersion\":" << json_escape_str(r->protocol)
<< ",\"headers\":[";
bool fh = true;
for (const auto& h : r->request_headers) {
if (!fh) os << ",";
fh = false;
os << "{\"name\":" << json_escape_str(h.name) << ",\"value\":" << json_escape_str(h.value) << "}";
}
os << "],\"queryString\":[],\"cookies\":[],\"headersSize\":-1,\"bodySize\":";
os << (r->has_post_data ? (int64_t)r->post_data.size() : (int64_t)0);
if (r->has_post_data) {
os << ",\"postData\":{\"mimeType\":\"\",\"text\":" << json_escape_str(r->post_data) << "}";
}
os << "},";
// response
os << "\"response\":{\"status\":" << r->status
<< ",\"statusText\":" << json_escape_str(r->status_text)
<< ",\"httpVersion\":" << json_escape_str(r->protocol)
<< ",\"headers\":[";
bool fr = true;
for (const auto& h : r->response_headers) {
if (!fr) os << ",";
fr = false;
os << "{\"name\":" << json_escape_str(h.name) << ",\"value\":" << json_escape_str(h.value) << "}";
}
os << "],\"cookies\":[],\"content\":{\"size\":" << r->response_body_length
<< ",\"mimeType\":" << json_escape_str(r->mime_type) << "},"
<< "\"redirectURL\":\"\",\"headersSize\":-1,\"bodySize\":" << r->encoded_data_length << "},";
os << "\"cache\":{},\"timings\":{\"send\":-1,\"wait\":-1,\"receive\":-1},";
os << "\"_resourceType\":" << json_escape_str(resource_type_label(r->type));
os << ",\"_initiator\":" << json_escape_str(r->initiator_type)
<< ",\"_initiatorUrl\":" << json_escape_str(r->initiator_url);
if (r->failed) {
os << ",\"_failed\":true,\"_errorText\":" << json_escape_str(r->error_text);
}
os << "}";
}
os << "]}}";
return os.str();
}
} // namespace navegator