cc1b324ffb
CDP HTTP client (cdp_http.h/cpp): WinSock raw + crude_json. GET /json/version,
/json (list tabs), PUT /json/new (Chrome 137+), GET /json/activate/{id},
/json/close/{id}.
CDP WebSocket client (cdp_ws.h/cpp): RFC 6455 handshake + framing manual,
masked client frames, async dispatcher con queue + wait_response. Soporta
fragmentacion (FIN=0 + continuation), ping/pong, close frame. Stats bytes
in/out + frames in.
Cross-panel session (session_state.h/cpp): selected_browser_port +
selected_tab_id. Cambiar tab cierra/abre NetworkSession.
Tabs panel: real. List + filtro titulo/URL + Refresh + New tab + Focus +
Close + Select (alimenta Network panel).
Network panel: DevTools-like.
- Tabla: Name | Status (color) | Method | Type | Initiator | Size | Time | Waterfall
- Filtros: text + invert + chips (Doc/CSS/JS/XHR/Img/Media/Font/WS/Other) + All toggle
- Toggles: Preserve log, Disable cache, Hide data:, Only failed, Pause/Resume
- Detalle por request: Headers (general + req + res) | Payload | Response (lazy
Network.getResponseBody) | Cookies | Timing | WS Messages (frames in/out)
- Right-click row: Copy URL / Copy as cURL / Copy as fetch
- Status bar: N requests | bytes transferred | resources | Finish | DCL | Load
- Export HAR 1.2 a archivo junto al exe
NetworkSession parsea Network.requestWillBeSent + ExtraInfo, responseReceived
+ ExtraInfo, dataReceived, loadingFinished, loadingFailed, webSocketCreated,
webSocketFrameSent/Received/Closed, Page.frameNavigated (autoclear si !preserve),
domContentEventFired, loadEventFired.
API local extendida (local_api.cpp):
- GET /browser/{port}/version
- GET /browser/{port}/tabs
- POST /browser/{port}/tab/new?url=
- POST /browser/{port}/tab/{id}/focus
- POST /browser/{port}/tab/{id}/close
- GET /browser/{port}/har (HAR 1.2 export de la sesion activa)
Build:
- CMakeLists.txt linka imgui_node_editor solo para reusar crude_json (sin
codigo de node-editor en runtime).
- 15 MB exe Windows. Cross-compile mingw-w64 OK.
app.md: bump version 0.2.0 -> 0.3.0, panels matrix actualizado, e2e_checks
añade api_health + api_browsers (warning).
Issue 0002 (sub-issue del roadmap navegator_dashboard 0001).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
508 lines
19 KiB
C++
508 lines
19 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_;
|
|
}
|
|
|
|
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
|