8357774b86
- CMakeLists.txt - app.md - main.cpp - panels.cpp - appicon.ico - autoextract_panel.cpp - picker_state.cpp - picker_state.h - py_subprocess.cpp - py_subprocess.h - ... Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1368 lines
53 KiB
C++
1368 lines
53 KiB
C++
// Panels v1+v2:
|
|
// - Browsers : scan + spawn + kill + seleccion (click selecciona instancia)
|
|
// - Tabs : lista pestañas via CDP HTTP /json + Focus/Close/New/Select
|
|
// - Tab Detail : Runtime.evaluate REPL minimo (placeholder upgradeable)
|
|
// - Network : panel DevTools-like — tabla + filtros + detalle por tab
|
|
//
|
|
// Estado cross-panel via g_session() (session_state.h).
|
|
// Issue 0081-J: tablas ##browsers, ##tabs, ##wsframes, ##requests migradas a
|
|
// data_table::render() con CellRenderers declarativos (Badge, Duration).
|
|
|
|
#include "imgui.h"
|
|
#include "implot.h"
|
|
#include "core/icons_tabler.h"
|
|
#include "core/tokens.h"
|
|
#include "core/data_table_types.h"
|
|
#include "viz/data_table.h"
|
|
|
|
#include "chrome_scanner.h"
|
|
#include "chrome_launcher.h"
|
|
#include "local_api.h"
|
|
#include "cdp_http.h"
|
|
#include "session_state.h"
|
|
#include "picker_state.h"
|
|
#include "py_subprocess.h"
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdio>
|
|
#include <cstring>
|
|
#include <cstdlib>
|
|
#include <fstream>
|
|
#include <map>
|
|
#include <mutex>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <vector>
|
|
#include <ctime>
|
|
|
|
#ifdef _WIN32
|
|
# define WIN32_LEAN_AND_MEAN
|
|
# include <windows.h>
|
|
#endif
|
|
|
|
namespace navegator {
|
|
|
|
// ===========================================================================
|
|
// Browsers panel
|
|
// ===========================================================================
|
|
namespace {
|
|
|
|
struct BrowsersState {
|
|
std::mutex mu;
|
|
std::vector<ChromeInstance> instances;
|
|
std::chrono::steady_clock::time_point last_scan;
|
|
std::atomic<bool> scanning{false};
|
|
std::atomic<bool> ever_scanned{false};
|
|
char new_profile[128] = "default";
|
|
int new_port = 19222;
|
|
bool new_headless = false;
|
|
std::string last_error;
|
|
// data_table state (persists between frames).
|
|
data_table::State dt_state;
|
|
};
|
|
|
|
BrowsersState g_browsers;
|
|
|
|
void rescan_async() {
|
|
if (g_browsers.scanning.exchange(true)) return;
|
|
std::thread([]{
|
|
auto v = scan_chrome_instances();
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_browsers.mu);
|
|
g_browsers.instances = std::move(v);
|
|
g_browsers.last_scan = std::chrono::steady_clock::now();
|
|
}
|
|
g_browsers.ever_scanned.store(true);
|
|
g_browsers.scanning.store(false);
|
|
}).detach();
|
|
}
|
|
|
|
std::string default_user_data_dir(const std::string& profile) {
|
|
#ifdef _WIN32
|
|
char buf[MAX_PATH] = {0};
|
|
DWORD n = GetEnvironmentVariableA("USERPROFILE", buf, sizeof(buf));
|
|
std::string base = (n > 0 && n < sizeof(buf)) ? buf : "C:\\Users\\Public";
|
|
return base + "\\AppData\\Local\\navegator_profiles\\" + profile;
|
|
#else
|
|
return std::string("/tmp/navegator_profiles/") + profile;
|
|
#endif
|
|
}
|
|
|
|
} // anon
|
|
|
|
void render_browsers_panel(bool* p_open) {
|
|
if (!ImGui::Begin(TI_BROWSER " Browsers", p_open)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_browsers.mu);
|
|
if (now - g_browsers.last_scan > std::chrono::seconds(2) && !g_browsers.scanning.load()) {
|
|
rescan_async();
|
|
}
|
|
}
|
|
|
|
if (g_api_running.load()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::success);
|
|
ImGui::Text("API: 127.0.0.1:%d", g_api_port.load());
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("(reqs: %d)", g_api_request_count.load());
|
|
} else {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
|
ImGui::TextUnformatted("API: down");
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Separator();
|
|
|
|
if (ImGui::Button(TI_REFRESH " Rescan")) rescan_async();
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted("New profile:");
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(160);
|
|
ImGui::InputText("##profile", g_browsers.new_profile, sizeof(g_browsers.new_profile));
|
|
ImGui::SameLine();
|
|
ImGui::TextUnformatted("Port:");
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(80);
|
|
ImGui::InputInt("##port", &g_browsers.new_port, 0, 0);
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Headless", &g_browsers.new_headless);
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_PLAYER_PLAY " Launch")) {
|
|
LaunchOpts o;
|
|
o.port = g_browsers.new_port;
|
|
o.headless = g_browsers.new_headless;
|
|
std::string profile = g_browsers.new_profile;
|
|
if (profile.empty()) profile = "default";
|
|
o.user_data_dir = default_user_data_dir(profile);
|
|
auto r = launch_chrome(o);
|
|
g_browsers.last_error = r.ok ? "" : r.error;
|
|
std::thread([]{
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(800));
|
|
rescan_async();
|
|
}).detach();
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_X " Kill all navegator")) {
|
|
kill_chromes_by_userdata("navegator_profiles");
|
|
std::thread([]{
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
|
rescan_async();
|
|
}).detach();
|
|
}
|
|
|
|
if (!g_browsers.last_error.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
|
ImGui::TextWrapped("Error: %s", g_browsers.last_error.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
ImGui::Separator();
|
|
|
|
int sel_port = 0;
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
sel_port = g_session().selected_port;
|
|
}
|
|
|
|
std::lock_guard<std::mutex> lk(g_browsers.mu);
|
|
if (!g_browsers.ever_scanned.load() && g_browsers.instances.empty()) {
|
|
ImGui::TextUnformatted("Scanning...");
|
|
} else if (g_browsers.instances.empty()) {
|
|
ImGui::TextDisabled("No Chrome instances with --remote-debugging-port detected.");
|
|
ImGui::TextDisabled("Lanza una con el formulario de arriba o con scripts/start.sh.");
|
|
} else {
|
|
// --- Build TableInput (6 data cols, no Actions) ---
|
|
// Cols: Sel, PID, Port, Profile, Mode, user-data-dir
|
|
static std::vector<std::string> cell_backing;
|
|
const int NCOLS = 6;
|
|
const int nrows = (int)g_browsers.instances.size();
|
|
cell_backing.clear();
|
|
cell_backing.reserve((size_t)(nrows * NCOLS));
|
|
for (const auto& inst : g_browsers.instances) {
|
|
cell_backing.push_back(sel_port == inst.port ? TI_CHECK : "");
|
|
cell_backing.push_back(std::to_string(inst.pid));
|
|
cell_backing.push_back(std::to_string(inst.port));
|
|
cell_backing.push_back(inst.profile_name.empty() ? "(none)" : inst.profile_name);
|
|
cell_backing.push_back(inst.headless ? "headless" : "visible");
|
|
cell_backing.push_back(inst.user_data_dir);
|
|
}
|
|
static std::vector<const char*> cell_ptrs;
|
|
cell_ptrs.clear();
|
|
cell_ptrs.reserve(cell_backing.size());
|
|
for (const auto& s : cell_backing) cell_ptrs.push_back(s.c_str());
|
|
|
|
data_table::TableInput tbl;
|
|
tbl.name = "browsers";
|
|
tbl.headers = {"", "PID", "Port", "Profile", "Mode", "user-data-dir"};
|
|
tbl.types = {
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::Int,
|
|
data_table::ColumnType::Int,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String
|
|
};
|
|
tbl.cells = cell_ptrs.data();
|
|
tbl.rows = nrows;
|
|
tbl.cols = NCOLS;
|
|
|
|
// Declarative renderers.
|
|
tbl.column_specs.resize((size_t)NCOLS);
|
|
// Col 4: Mode -> Badge
|
|
{
|
|
data_table::ColumnSpec& cs = tbl.column_specs[4];
|
|
cs.id = "mode";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
data_table::BadgeRule{"headless", "#f59e0b", "headless"},
|
|
data_table::BadgeRule{"visible", "#22c55e", "visible"},
|
|
};
|
|
}
|
|
|
|
data_table::render("##dt_browsers", {tbl}, g_browsers.dt_state, false);
|
|
|
|
// --- Actions: inline button list per row (no BeginTable — Button renderer not Fase-1) ---
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Actions:");
|
|
int idx = 0;
|
|
for (const auto& inst : g_browsers.instances) {
|
|
bool is_sel = (sel_port == inst.port);
|
|
ImGui::PushID(idx);
|
|
ImGui::Text(":%d", inst.port); ImGui::SameLine();
|
|
if (ImGui::SmallButton(is_sel ? "Selected" : "Select")) {
|
|
g_session().select_browser(inst.port);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("Kill")) {
|
|
if (!inst.user_data_dir.empty()) {
|
|
kill_chromes_by_userdata(inst.user_data_dir);
|
|
}
|
|
if (sel_port == inst.port) g_session().clear_selection();
|
|
std::thread([]{
|
|
std::this_thread::sleep_for(std::chrono::milliseconds(500));
|
|
rescan_async();
|
|
}).detach();
|
|
}
|
|
ImGui::PopID();
|
|
++idx;
|
|
}
|
|
}
|
|
ImGui::End();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Tabs panel
|
|
// ===========================================================================
|
|
namespace {
|
|
|
|
struct TabsUiState {
|
|
std::atomic<bool> refreshing{false};
|
|
char new_url_input[1024] = "https://example.com";
|
|
char filter[128] = "";
|
|
// data_table state (persists between frames).
|
|
data_table::State dt_state;
|
|
};
|
|
TabsUiState g_tabs_ui;
|
|
|
|
void refresh_tabs_async(int port) {
|
|
if (port <= 0) return;
|
|
if (g_tabs_ui.refreshing.exchange(true)) return;
|
|
std::thread([port]{
|
|
std::vector<CdpTab> v;
|
|
std::string err;
|
|
bool ok = cdp_list_tabs(port, v, &err);
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
if (g_session().selected_port == port) {
|
|
g_session().tabs = std::move(v);
|
|
g_session().tabs_error = ok ? "" : err;
|
|
g_session().last_tabs_refresh = std::chrono::steady_clock::now();
|
|
}
|
|
}
|
|
g_tabs_ui.refreshing.store(false);
|
|
}).detach();
|
|
}
|
|
|
|
} // anon
|
|
|
|
void render_tabs_panel(bool* p_open) {
|
|
if (!ImGui::Begin(TI_LIST " Tabs", p_open)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
int port = 0;
|
|
std::string sel_tab_id;
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
port = g_session().selected_port;
|
|
sel_tab_id = g_session().selected_tab_id;
|
|
}
|
|
if (port <= 0) {
|
|
ImGui::TextDisabled("Select a browser in the Browsers panel.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
auto now = std::chrono::steady_clock::now();
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
if (now - g_session().last_tabs_refresh > std::chrono::seconds(2) &&
|
|
!g_tabs_ui.refreshing.load()) {
|
|
refresh_tabs_async(port);
|
|
}
|
|
}
|
|
|
|
ImGui::Text("Browser :%d", port);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_REFRESH " Refresh")) refresh_tabs_async(port);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(280);
|
|
ImGui::InputTextWithHint("##new_url", "https://...", g_tabs_ui.new_url_input, sizeof(g_tabs_ui.new_url_input));
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_PLUS " New tab")) {
|
|
std::string url = g_tabs_ui.new_url_input;
|
|
std::thread([port, url]{
|
|
CdpTab t; std::string err;
|
|
cdp_new_tab(port, url, t, &err);
|
|
refresh_tabs_async(port);
|
|
}).detach();
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(200);
|
|
ImGui::InputTextWithHint("##filter", "filter title/url", g_tabs_ui.filter, sizeof(g_tabs_ui.filter));
|
|
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
if (!g_session().tabs_error.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
|
ImGui::TextWrapped("Error: %s", g_session().tabs_error.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
}
|
|
|
|
ImGui::Separator();
|
|
|
|
std::vector<CdpTab> tabs_copy;
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
tabs_copy = g_session().tabs;
|
|
}
|
|
|
|
if (tabs_copy.empty()) {
|
|
ImGui::TextDisabled("No tabs (or CDP HTTP not reachable on :%d).", port);
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Apply filter to get visible tabs.
|
|
std::string filter_str = g_tabs_ui.filter;
|
|
std::transform(filter_str.begin(), filter_str.end(), filter_str.begin(), ::tolower);
|
|
std::vector<const CdpTab*> visible_tabs;
|
|
visible_tabs.reserve(tabs_copy.size());
|
|
for (const auto& t : tabs_copy) {
|
|
if (!filter_str.empty()) {
|
|
std::string lt = t.title; std::transform(lt.begin(), lt.end(), lt.begin(), ::tolower);
|
|
std::string lu = t.url; std::transform(lu.begin(), lu.end(), lu.begin(), ::tolower);
|
|
if (lt.find(filter_str) == std::string::npos &&
|
|
lu.find(filter_str) == std::string::npos) continue;
|
|
}
|
|
visible_tabs.push_back(&t);
|
|
}
|
|
|
|
// --- Build TableInput (5 data cols, no Actions) ---
|
|
// Cols: Sel, Type, Title, URL, Attached
|
|
static std::vector<std::string> tab_cell_backing;
|
|
const int NCOLS = 5;
|
|
const int nrows = (int)visible_tabs.size();
|
|
tab_cell_backing.clear();
|
|
tab_cell_backing.reserve((size_t)(nrows * NCOLS));
|
|
for (const CdpTab* tp : visible_tabs) {
|
|
tab_cell_backing.push_back(sel_tab_id == tp->id ? TI_CHECK : "");
|
|
tab_cell_backing.push_back(tp->type);
|
|
tab_cell_backing.push_back(tp->title.empty() ? "(no title)" : tp->title);
|
|
tab_cell_backing.push_back(tp->url);
|
|
tab_cell_backing.push_back(tp->attached ? "yes" : "no");
|
|
}
|
|
static std::vector<const char*> tab_cell_ptrs;
|
|
tab_cell_ptrs.clear();
|
|
tab_cell_ptrs.reserve(tab_cell_backing.size());
|
|
for (const auto& s : tab_cell_backing) tab_cell_ptrs.push_back(s.c_str());
|
|
|
|
data_table::TableInput tbl;
|
|
tbl.name = "tabs";
|
|
tbl.headers = {"", "Type", "Title", "URL", "Attached"};
|
|
tbl.types = {
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String
|
|
};
|
|
tbl.cells = tab_cell_ptrs.data();
|
|
tbl.rows = nrows;
|
|
tbl.cols = NCOLS;
|
|
|
|
// Declarative renderers.
|
|
tbl.column_specs.resize((size_t)NCOLS);
|
|
// Col 1: Type -> Badge
|
|
{
|
|
data_table::ColumnSpec& cs = tbl.column_specs[1];
|
|
cs.id = "type";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
data_table::BadgeRule{"page", "#3b82f6", "page"},
|
|
data_table::BadgeRule{"iframe", "#8b5cf6", "iframe"},
|
|
data_table::BadgeRule{"service_worker", "#f59e0b", "service_worker"},
|
|
data_table::BadgeRule{"worker", "#f59e0b", "worker"},
|
|
};
|
|
}
|
|
// Col 4: Attached -> Badge
|
|
{
|
|
data_table::ColumnSpec& cs = tbl.column_specs[4];
|
|
cs.id = "attached";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
data_table::BadgeRule{"yes", "#22c55e", "yes"},
|
|
data_table::BadgeRule{"no", "#6b7280", "no"},
|
|
};
|
|
}
|
|
|
|
data_table::render("##dt_tabs", {tbl}, g_tabs_ui.dt_state, false);
|
|
|
|
// --- Actions: inline button list per visible row (no BeginTable — Button renderer not Fase-1) ---
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Actions:");
|
|
{
|
|
int idx = 0;
|
|
for (const CdpTab* tp : visible_tabs) {
|
|
bool is_sel = (sel_tab_id == tp->id);
|
|
ImGui::PushID(idx);
|
|
std::string short_title = tp->title.empty() ? "(no title)" : tp->title;
|
|
if (short_title.size() > 32) short_title = short_title.substr(0, 29) + "...";
|
|
ImGui::TextUnformatted(short_title.c_str()); ImGui::SameLine();
|
|
if (ImGui::SmallButton(is_sel ? "Sel*" : "Select")) {
|
|
if (!tp->ws_url.empty()) g_session().select_tab(tp->id, tp->ws_url);
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("Focus")) {
|
|
std::string id = tp->id;
|
|
std::thread([port, id]{
|
|
cdp_activate_tab(port, id, nullptr);
|
|
refresh_tabs_async(port);
|
|
}).detach();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("Close")) {
|
|
std::string id = tp->id;
|
|
bool is_s = is_sel;
|
|
std::thread([port, id]{
|
|
cdp_close_tab(port, id, nullptr);
|
|
refresh_tabs_async(port);
|
|
}).detach();
|
|
if (is_s) g_session().select_tab("", "");
|
|
}
|
|
ImGui::PopID();
|
|
++idx;
|
|
}
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Tab Detail panel (placeholder funcional)
|
|
// ===========================================================================
|
|
namespace {
|
|
|
|
struct TabDetailUiState {
|
|
char repl_input[4096] = "1+1";
|
|
std::string repl_output;
|
|
std::mutex mu;
|
|
};
|
|
TabDetailUiState g_tab_detail_ui;
|
|
|
|
void tab_detail_eval_async(const std::string& expr) {
|
|
NetworkSession* net = nullptr;
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
net = g_session().net.get();
|
|
if (!net) return;
|
|
}
|
|
// No tenemos acceso directo al CdpWs desde NetworkSession publicamente.
|
|
// Para v1.5 — Tab Detail dedicado abrira su propio CdpWs. De momento,
|
|
// el panel solo muestra info estatica + tip.
|
|
(void)expr;
|
|
}
|
|
|
|
} // anon
|
|
|
|
void render_tab_detail_panel(bool* p_open) {
|
|
if (!ImGui::Begin(TI_FILE_INFO " Tab Detail", p_open)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
std::string sel_id, sel_ws;
|
|
int port = 0;
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
port = g_session().selected_port;
|
|
sel_id = g_session().selected_tab_id;
|
|
sel_ws = g_session().selected_tab_ws_url;
|
|
}
|
|
if (sel_id.empty()) {
|
|
ImGui::TextDisabled("Select a tab in the Tabs panel.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
ImGui::Text("Browser :%d", port);
|
|
ImGui::Text("Tab id %s", sel_id.c_str());
|
|
ImGui::TextWrapped("WS %s", sel_ws.c_str());
|
|
ImGui::Separator();
|
|
|
|
// --- Pick element ---
|
|
bool active = picker_is_active();
|
|
if (active) ImGui::PushStyleColor(ImGuiCol_Button, fn_tokens::colors::primary);
|
|
if (ImGui::Button(active ? (TI_FLASK " Picking... (click to stop)")
|
|
: (TI_FLASK " Pick element"))) {
|
|
if (active) {
|
|
picker_stop();
|
|
} else {
|
|
std::string err = picker_start(port, sel_id, sel_ws);
|
|
if (!err.empty()) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
|
ImGui::TextWrapped("Pick error: %s", err.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
}
|
|
}
|
|
if (active) ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("(injects functions/browser/cdp_pick_element_js.js via CDP)");
|
|
|
|
PickedElement last = picker_last();
|
|
if (last.valid) {
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Last picked:");
|
|
if (ImGui::BeginChild("##picked_card", ImVec2(0, 110), true)) {
|
|
ImGui::Text("tag: %s", last.tag.c_str());
|
|
ImGui::TextWrapped("selector: %s", last.selector.c_str());
|
|
ImGui::TextWrapped("xpath: %s", last.xpath.c_str());
|
|
std::string short_text = last.text;
|
|
if (short_text.size() > 200) short_text = short_text.substr(0, 200) + "...";
|
|
ImGui::TextWrapped("text: %s", short_text.c_str());
|
|
}
|
|
ImGui::EndChild();
|
|
if (ImGui::SmallButton("Copy selector")) {
|
|
ImGui::SetClipboardText(last.selector.c_str());
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("Save to recipe (new)")) {
|
|
// Placeholder: futura integracion para crear recipe nueva con un
|
|
// unico field a partir del selector. Por ahora se copia.
|
|
ImGui::SetClipboardText(last.selector.c_str());
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton("Clear")) picker_clear_last();
|
|
} else {
|
|
ImGui::TextDisabled("(no picked element yet — click 'Pick element' and click on the page)");
|
|
}
|
|
|
|
ImGui::Separator();
|
|
ImGui::TextWrapped(
|
|
"Tab Detail (HTML preview + screenshot + Runtime.evaluate REPL) llega "
|
|
"en v1.5 (issue 0003). El WebSocket esta vivo via Network panel — el "
|
|
"REPL re-utilizara la misma conexion en una proxima iteracion.");
|
|
ImGui::End();
|
|
}
|
|
|
|
// ===========================================================================
|
|
// Network panel (DevTools-like)
|
|
// ===========================================================================
|
|
namespace {
|
|
|
|
// Filtros chips (tipo recurso). Bitmask sobre ResourceType.
|
|
struct NetUiState {
|
|
char filter_text[256] = "";
|
|
bool invert_filter = false;
|
|
bool hide_data_urls = true;
|
|
bool only_blocked = false;
|
|
|
|
// chips mask. true = mostrar este tipo. start: All on.
|
|
bool type_doc = true;
|
|
bool type_css = true;
|
|
bool type_js = true;
|
|
bool type_xhr = true; // XHR + Fetch
|
|
bool type_img = true;
|
|
bool type_media = true;
|
|
bool type_font = true;
|
|
bool type_ws = true;
|
|
bool type_other = true;
|
|
bool all_types = true;
|
|
|
|
bool paused = false;
|
|
int selected_index = -1; // index en snapshot filtrado
|
|
std::string selected_id; // requestId estable
|
|
|
|
int detail_tab = 0; // 0 headers, 1 payload, 2 response, 3 cookies, 4 timing, 5 ws
|
|
|
|
// Histograma overview (request starts/s).
|
|
bool show_histogram = true;
|
|
int histogram_bins = 30;
|
|
bool reload_ignore_cache = false;
|
|
|
|
// data_table state for ##requests (persists between frames).
|
|
data_table::State dt_state;
|
|
};
|
|
NetUiState g_net_ui;
|
|
|
|
bool type_passes(const NetUiState& s, ResourceType t) {
|
|
if (s.all_types) return true;
|
|
switch (t) {
|
|
case ResourceType::Document: return s.type_doc;
|
|
case ResourceType::Stylesheet: return s.type_css;
|
|
case ResourceType::Script: return s.type_js;
|
|
case ResourceType::Image: return s.type_img;
|
|
case ResourceType::Media: return s.type_media;
|
|
case ResourceType::Font: return s.type_font;
|
|
case ResourceType::XHR:
|
|
case ResourceType::Fetch: return s.type_xhr;
|
|
case ResourceType::WebSocket:
|
|
case ResourceType::EventSource: return s.type_ws;
|
|
default: return s.type_other;
|
|
}
|
|
}
|
|
|
|
ImVec4 status_color(int status) {
|
|
if (status == 0) return fn_tokens::colors::text_muted;
|
|
if (status >= 500) return fn_tokens::colors::error;
|
|
if (status >= 400) return fn_tokens::colors::warning;
|
|
if (status >= 300) return fn_tokens::colors::info;
|
|
return fn_tokens::colors::success;
|
|
}
|
|
|
|
std::string short_name_from_url(const std::string& url) {
|
|
if (url.empty()) return "";
|
|
size_t scheme_end = url.find("://");
|
|
size_t path_start = (scheme_end == std::string::npos) ? 0 : scheme_end + 3;
|
|
size_t qmark = url.find('?', path_start);
|
|
std::string path = url.substr(path_start, qmark == std::string::npos ? std::string::npos : qmark - path_start);
|
|
size_t slash = path.find('/');
|
|
if (slash == std::string::npos) return path;
|
|
std::string after = path.substr(slash);
|
|
size_t last = after.find_last_of('/');
|
|
if (last == std::string::npos || last == after.size() - 1) {
|
|
// ends with "/" -> use host
|
|
return path.substr(0, slash);
|
|
}
|
|
return after.substr(last + 1);
|
|
}
|
|
|
|
std::string fmt_size(int64_t b) {
|
|
char buf[64];
|
|
if (b < 1024) std::snprintf(buf, sizeof(buf), "%lld B", (long long)b);
|
|
else if (b < 1024 * 1024) std::snprintf(buf, sizeof(buf), "%.1f kB", b / 1024.0);
|
|
else std::snprintf(buf, sizeof(buf), "%.2f MB", b / (1024.0 * 1024.0));
|
|
return buf;
|
|
}
|
|
|
|
std::string fmt_dur_ms(double s) {
|
|
char buf[32];
|
|
if (s <= 0) return "—";
|
|
if (s < 1.0) std::snprintf(buf, sizeof(buf), "%.0f ms", s * 1000.0);
|
|
else std::snprintf(buf, sizeof(buf), "%.2f s", s);
|
|
return buf;
|
|
}
|
|
|
|
void copy_to_clipboard(const std::string& s) {
|
|
ImGui::SetClipboardText(s.c_str());
|
|
}
|
|
|
|
std::string build_curl(const NetworkRequest& r) {
|
|
std::ostringstream os;
|
|
os << "curl -X " << (r.method.empty() ? "GET" : r.method) << " '" << r.url << "'";
|
|
for (const auto& h : r.request_headers) {
|
|
if (h.name.size() >= 1 && h.name[0] == ':') continue; // pseudo h2
|
|
os << " -H '" << h.name << ": " << h.value << "'";
|
|
}
|
|
if (r.has_post_data && !r.post_data.empty()) {
|
|
os << " --data-raw '" << r.post_data << "'";
|
|
}
|
|
return os.str();
|
|
}
|
|
|
|
std::string build_fetch(const NetworkRequest& r) {
|
|
std::ostringstream os;
|
|
os << "fetch('" << r.url << "', { method: '" << (r.method.empty() ? "GET" : r.method) << "', headers: {";
|
|
bool first = true;
|
|
for (const auto& h : r.request_headers) {
|
|
if (h.name.size() >= 1 && h.name[0] == ':') continue;
|
|
if (!first) os << ", ";
|
|
first = false;
|
|
os << "'" << h.name << "': '" << h.value << "'";
|
|
}
|
|
os << "}";
|
|
if (r.has_post_data && !r.post_data.empty()) {
|
|
os << ", body: '" << r.post_data << "'";
|
|
}
|
|
os << "})";
|
|
return os.str();
|
|
}
|
|
|
|
void draw_filter_chips() {
|
|
auto chip = [](const char* label, bool* state, ImVec4 color) {
|
|
if (*state) {
|
|
ImGui::PushStyleColor(ImGuiCol_Button, color);
|
|
ImGui::PushStyleColor(ImGuiCol_ButtonHovered, color);
|
|
}
|
|
if (ImGui::SmallButton(label)) *state = !*state;
|
|
if (*state) ImGui::PopStyleColor(2);
|
|
ImGui::SameLine();
|
|
};
|
|
|
|
if (ImGui::SmallButton(g_net_ui.all_types ? "All*" : "All")) {
|
|
g_net_ui.all_types = !g_net_ui.all_types;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|"); ImGui::SameLine();
|
|
|
|
chip("Doc", &g_net_ui.type_doc, fn_tokens::colors::primary);
|
|
chip("CSS", &g_net_ui.type_css, fn_tokens::colors::primary);
|
|
chip("JS", &g_net_ui.type_js, fn_tokens::colors::primary);
|
|
chip("XHR", &g_net_ui.type_xhr, fn_tokens::colors::primary);
|
|
chip("Img", &g_net_ui.type_img, fn_tokens::colors::primary);
|
|
chip("Media", &g_net_ui.type_media, fn_tokens::colors::primary);
|
|
chip("Font", &g_net_ui.type_font, fn_tokens::colors::primary);
|
|
chip("WS", &g_net_ui.type_ws, fn_tokens::colors::primary);
|
|
chip("Other", &g_net_ui.type_other, fn_tokens::colors::primary);
|
|
ImGui::NewLine();
|
|
}
|
|
|
|
void draw_request_detail(const NetworkRequest& r, NetworkSession* net) {
|
|
if (ImGui::BeginTabBar("##req_detail_tabs")) {
|
|
if (ImGui::BeginTabItem("Headers")) {
|
|
ImGui::TextDisabled("General");
|
|
ImGui::Text("URL: %s", r.url.c_str());
|
|
ImGui::Text("Method: %s", r.method.c_str());
|
|
ImGui::Text("Status: %d %s", r.status, r.status_text.c_str());
|
|
ImGui::Text("Remote: %s:%d", r.remote_ip.c_str(), r.remote_port);
|
|
ImGui::Text("Protocol: %s", r.protocol.c_str());
|
|
if (r.failed) {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
|
ImGui::Text("Error: %s%s", r.error_text.c_str(), r.canceled ? " (canceled)" : "");
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Request headers (%d)", (int)r.request_headers.size());
|
|
for (const auto& h : r.request_headers) {
|
|
ImGui::Text("%s:", h.name.c_str());
|
|
ImGui::SameLine();
|
|
ImGui::TextWrapped("%s", h.value.c_str());
|
|
}
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Response headers (%d)", (int)r.response_headers.size());
|
|
for (const auto& h : r.response_headers) {
|
|
ImGui::Text("%s:", h.name.c_str());
|
|
ImGui::SameLine();
|
|
ImGui::TextWrapped("%s", h.value.c_str());
|
|
}
|
|
ImGui::EndTabItem();
|
|
}
|
|
if (ImGui::BeginTabItem("Payload")) {
|
|
if (!r.has_post_data) {
|
|
ImGui::TextDisabled("(no request body)");
|
|
} else {
|
|
if (ImGui::SmallButton("Copy")) copy_to_clipboard(r.post_data);
|
|
ImGui::Separator();
|
|
ImGui::InputTextMultiline("##postdata", (char*)r.post_data.c_str(), r.post_data.size() + 1,
|
|
ImVec2(-1, -1), ImGuiInputTextFlags_ReadOnly);
|
|
}
|
|
ImGui::EndTabItem();
|
|
}
|
|
if (ImGui::BeginTabItem("Response")) {
|
|
// Detect JSON response (content-type: application/json).
|
|
bool is_json = false;
|
|
for (const auto& h : r.response_headers) {
|
|
std::string n = h.name; std::transform(n.begin(), n.end(), n.begin(), ::tolower);
|
|
if (n == "content-type" && h.value.find("application/json") != std::string::npos) {
|
|
is_json = true; break;
|
|
}
|
|
}
|
|
if (r.body_fetched && !r.body_text.empty()) {
|
|
if (ImGui::SmallButton("Copy")) copy_to_clipboard(r.body_text);
|
|
if (is_json) {
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton(TI_LIST_DETAILS " Parse")) {
|
|
// Llama infer_json_rows_schema via subprocess.
|
|
static std::string g_parsed; // sticky entre frames
|
|
g_parsed.clear();
|
|
const char* code = R"PY(
|
|
import sys, os, json, traceback
|
|
root = os.environ.get('FN_REGISTRY_ROOT', '')
|
|
if not root:
|
|
print(json.dumps({"error":"FN_REGISTRY_ROOT not set"})); sys.exit(2)
|
|
for sub in ('core',):
|
|
sys.path.insert(0, os.path.join(root, 'python', 'functions', sub))
|
|
try:
|
|
from infer_json_rows_schema import infer_json_rows_schema
|
|
body = sys.stdin.read()
|
|
obj = json.loads(body)
|
|
res = infer_json_rows_schema(obj)
|
|
print(json.dumps(res if isinstance(res, dict) else {"result": res}))
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e), "trace": traceback.format_exc()})); sys.exit(1)
|
|
)PY";
|
|
std::vector<std::string> argv;
|
|
argv.push_back(py_resolve_interpreter());
|
|
argv.push_back("-c");
|
|
argv.push_back(code);
|
|
// Lanza un thread y deja log en g_net_ui.* via clipboard (simple).
|
|
std::string body = r.body_text;
|
|
std::thread([argv, body]() {
|
|
(void)argv; (void)body;
|
|
// py_run no soporta stdin todavia; usamos un archivo temporal.
|
|
// Para mantener el patch minimo: escribimos body a archivo temp,
|
|
// y pasamos su path como argv extra; el script lo lee.
|
|
char tmp[256];
|
|
std::snprintf(tmp, sizeof(tmp), "%s%snav_body_%lld.json",
|
|
#ifdef _WIN32
|
|
getenv("TEMP") ? getenv("TEMP") : ".", "\\",
|
|
#else
|
|
"/tmp", "/",
|
|
#endif
|
|
(long long)std::time(nullptr));
|
|
{
|
|
std::ofstream f(tmp, std::ios::binary);
|
|
if (f) f.write(body.data(), body.size());
|
|
}
|
|
const char* code2 = R"PY(
|
|
import sys, os, json, traceback
|
|
root = os.environ.get('FN_REGISTRY_ROOT', '')
|
|
if not root:
|
|
print(json.dumps({"error":"FN_REGISTRY_ROOT not set"})); sys.exit(2)
|
|
for sub in ('core',):
|
|
sys.path.insert(0, os.path.join(root, 'python', 'functions', sub))
|
|
try:
|
|
from infer_json_rows_schema import infer_json_rows_schema
|
|
with open(sys.argv[1], 'rb') as f: body = f.read().decode('utf-8','replace')
|
|
obj = json.loads(body)
|
|
res = infer_json_rows_schema(obj)
|
|
print(json.dumps(res if isinstance(res, dict) else {"result": res}))
|
|
except Exception as e:
|
|
print(json.dumps({"error": str(e), "trace": traceback.format_exc()})); sys.exit(1)
|
|
)PY";
|
|
std::vector<std::string> a2 = {
|
|
py_resolve_interpreter(), "-c", code2, tmp
|
|
};
|
|
PyResult pr = py_run(a2, 30000);
|
|
ImGui::SetClipboardText(pr.stdout_data.c_str());
|
|
std::remove(tmp);
|
|
}).detach();
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("(result -> clipboard)");
|
|
}
|
|
ImGui::Separator();
|
|
ImGui::InputTextMultiline("##body", (char*)r.body_text.c_str(), r.body_text.size() + 1,
|
|
ImVec2(-1, -1), ImGuiInputTextFlags_ReadOnly);
|
|
} else {
|
|
if (ImGui::Button("Fetch response body")) {
|
|
if (net) net->request_body(r.id);
|
|
}
|
|
ImGui::TextDisabled("(body lazy-loaded via Network.getResponseBody)");
|
|
ImGui::TextDisabled("Limitacion conocida: matching id->requestId pendiente — body llega via WS pero no se pinta hasta v1.5.");
|
|
}
|
|
ImGui::EndTabItem();
|
|
}
|
|
if (ImGui::BeginTabItem("Cookies")) {
|
|
// Buscar Cookie / Set-Cookie en headers.
|
|
ImGui::TextDisabled("Sent (Cookie):");
|
|
for (const auto& h : r.request_headers) {
|
|
if (h.name == "Cookie" || h.name == "cookie") {
|
|
ImGui::TextWrapped("%s", h.value.c_str());
|
|
}
|
|
}
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Set (Set-Cookie):");
|
|
for (const auto& h : r.response_headers) {
|
|
if (h.name == "Set-Cookie" || h.name == "set-cookie") {
|
|
ImGui::TextWrapped("%s", h.value.c_str());
|
|
}
|
|
}
|
|
ImGui::EndTabItem();
|
|
}
|
|
if (ImGui::BeginTabItem("Timing")) {
|
|
double dur = (r.t_finished > 0 ? r.t_finished : r.t_response) - r.t_started;
|
|
ImGui::Text("Started: %.3f s", r.t_started);
|
|
ImGui::Text("Response: %.3f s", r.t_response);
|
|
ImGui::Text("Finished: %.3f s", r.t_finished);
|
|
ImGui::Text("Total: %s", fmt_dur_ms(dur).c_str());
|
|
ImGui::Separator();
|
|
ImGui::Text("CDP timestamps");
|
|
ImGui::Text(" requestWillBeSent: %.6f", r.ts_request_will_be_sent);
|
|
ImGui::Text(" responseReceived: %.6f", r.ts_response_received);
|
|
ImGui::Text(" loadingFinished: %.6f", r.ts_loading_finished);
|
|
ImGui::Text(" loadingFailed: %.6f", r.ts_loading_failed);
|
|
ImGui::EndTabItem();
|
|
}
|
|
if (r.type == ResourceType::WebSocket && ImGui::BeginTabItem("Messages")) {
|
|
ImGui::Text("Frames: %d", (int)r.ws_frames.size());
|
|
ImGui::Separator();
|
|
|
|
// --- data_table::render for ##wsframes ---
|
|
// Cols: Dir, Op, Time, Payload
|
|
static std::vector<std::string> ws_cell_backing;
|
|
static data_table::State g_dt_wsframes;
|
|
const int WS_COLS = 4;
|
|
const int ws_rows = (int)r.ws_frames.size();
|
|
ws_cell_backing.clear();
|
|
ws_cell_backing.reserve((size_t)(ws_rows * WS_COLS));
|
|
for (const auto& wf : r.ws_frames) {
|
|
ws_cell_backing.push_back(wf.outgoing ? "send" : "recv");
|
|
// Op: opcode as string label
|
|
switch (wf.opcode) {
|
|
case 1: ws_cell_backing.push_back("text"); break;
|
|
case 2: ws_cell_backing.push_back("binary"); break;
|
|
case 8: ws_cell_backing.push_back("close"); break;
|
|
case 9: ws_cell_backing.push_back("ping"); break;
|
|
case 10: ws_cell_backing.push_back("pong"); break;
|
|
default: {
|
|
char buf[16]; std::snprintf(buf, sizeof(buf), "%d", wf.opcode);
|
|
ws_cell_backing.push_back(buf);
|
|
}
|
|
}
|
|
{ char buf[32]; std::snprintf(buf, sizeof(buf), "%.3f", wf.time);
|
|
ws_cell_backing.push_back(buf); }
|
|
ws_cell_backing.push_back(wf.payload);
|
|
}
|
|
static std::vector<const char*> ws_cell_ptrs;
|
|
ws_cell_ptrs.clear();
|
|
ws_cell_ptrs.reserve(ws_cell_backing.size());
|
|
for (const auto& s : ws_cell_backing) ws_cell_ptrs.push_back(s.c_str());
|
|
|
|
data_table::TableInput ws_tbl;
|
|
ws_tbl.name = "wsframes";
|
|
ws_tbl.headers = {"Dir", "Op", "Time", "Payload"};
|
|
ws_tbl.types = {
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::Float,
|
|
data_table::ColumnType::String
|
|
};
|
|
ws_tbl.cells = ws_rows > 0 ? ws_cell_ptrs.data() : nullptr;
|
|
ws_tbl.rows = ws_rows;
|
|
ws_tbl.cols = WS_COLS;
|
|
|
|
// Declarative renderers.
|
|
ws_tbl.column_specs.resize((size_t)WS_COLS);
|
|
// Col 0: Dir -> Badge
|
|
{
|
|
data_table::ColumnSpec& cs = ws_tbl.column_specs[0];
|
|
cs.id = "dir";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
data_table::BadgeRule{"send", "#3b82f6", "send"},
|
|
data_table::BadgeRule{"recv", "#22c55e", "recv"},
|
|
};
|
|
}
|
|
// Col 1: Op -> Badge
|
|
{
|
|
data_table::ColumnSpec& cs = ws_tbl.column_specs[1];
|
|
cs.id = "op";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
data_table::BadgeRule{"text", "#3b82f6", "text"},
|
|
data_table::BadgeRule{"binary", "#8b5cf6", "binary"},
|
|
data_table::BadgeRule{"close", "#ef4444", "close"},
|
|
data_table::BadgeRule{"ping", "#6b7280", "ping"},
|
|
data_table::BadgeRule{"pong", "#6b7280", "pong"},
|
|
};
|
|
}
|
|
|
|
data_table::render("##dt_wsframes", {ws_tbl}, g_dt_wsframes, false);
|
|
ImGui::EndTabItem();
|
|
}
|
|
ImGui::EndTabBar();
|
|
}
|
|
}
|
|
|
|
void render_network_toolbar(NetworkSession* net) {
|
|
// Reload page (Page.reload via CDP). Si no hay sesion, deshabilita.
|
|
if (!net) ImGui::BeginDisabled();
|
|
if (ImGui::Button(TI_REFRESH " Reload")) {
|
|
if (net) net->reload_page(g_net_ui.reload_ignore_cache);
|
|
}
|
|
if (!net) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Bypass cache", &g_net_ui.reload_ignore_cache);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_TRASH " Clear")) {
|
|
if (net) net->clear_log();
|
|
g_net_ui.selected_id.clear();
|
|
g_net_ui.selected_index = -1;
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(g_net_ui.paused ? (TI_PLAYER_PLAY " Resume") : (TI_PLAYER_PAUSE " Pause"))) {
|
|
g_net_ui.paused = !g_net_ui.paused;
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox(TI_CHART_HISTOGRAM " Histogram", &g_net_ui.show_histogram);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
bool preserve = net ? net->preserve_log() : true;
|
|
if (ImGui::Checkbox("Preserve log", &preserve)) {
|
|
if (net) net->set_preserve_log(preserve);
|
|
}
|
|
ImGui::SameLine();
|
|
bool cache_disabled = net ? net->cache_disabled() : false;
|
|
if (ImGui::Checkbox("Disable cache", &cache_disabled)) {
|
|
if (net) net->set_cache_disabled(cache_disabled);
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Hide data:", &g_net_ui.hide_data_urls);
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Only failed", &g_net_ui.only_blocked);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
ImGui::SetNextItemWidth(220);
|
|
ImGui::InputTextWithHint("##netfilter", "filter (regex-like substring)",
|
|
g_net_ui.filter_text, sizeof(g_net_ui.filter_text));
|
|
ImGui::SameLine();
|
|
ImGui::Checkbox("Invert", &g_net_ui.invert_filter);
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("|");
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_DOWNLOAD " Export HAR")) {
|
|
if (net) {
|
|
std::string har = net->export_har_json();
|
|
// Escribir junto al exe.
|
|
char path[1024];
|
|
std::snprintf(path, sizeof(path), "navegator_har_%lld.har",
|
|
(long long)std::time(nullptr));
|
|
FILE* f = std::fopen(path, "w");
|
|
if (f) { std::fwrite(har.data(), 1, har.size(), f); std::fclose(f); }
|
|
}
|
|
}
|
|
}
|
|
|
|
} // anon
|
|
|
|
void render_network_panel(bool* p_open) {
|
|
if (!ImGui::Begin(TI_ACTIVITY " Network", p_open, ImGuiWindowFlags_MenuBar)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
NetworkSession* net = nullptr;
|
|
int port = 0;
|
|
std::string sel_tab_id;
|
|
std::string net_err;
|
|
{
|
|
std::lock_guard<std::mutex> lk(g_session().mu);
|
|
net = g_session().net.get();
|
|
port = g_session().selected_port;
|
|
sel_tab_id = g_session().selected_tab_id;
|
|
net_err = g_session().net_error;
|
|
}
|
|
if (!net) {
|
|
if (sel_tab_id.empty()) {
|
|
ImGui::TextDisabled("Select a tab in the Tabs panel to capture network.");
|
|
} else {
|
|
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
|
|
ImGui::TextWrapped("Network session not open: %s", net_err.c_str());
|
|
ImGui::PopStyleColor();
|
|
}
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Drenar eventos cada frame (a menos que pause).
|
|
if (!g_net_ui.paused) net->pump();
|
|
|
|
render_network_toolbar(net);
|
|
draw_filter_chips();
|
|
ImGui::Separator();
|
|
|
|
// Snapshot + filtrado.
|
|
auto reqs = net->snapshot();
|
|
|
|
// ---------- Histograma overview ----------
|
|
if (g_net_ui.show_histogram) {
|
|
std::vector<double> starts;
|
|
starts.reserve(reqs.size());
|
|
double t_max = 1.0;
|
|
for (const auto& r : reqs) {
|
|
if (r->t_started >= 0.0) {
|
|
starts.push_back(r->t_started);
|
|
if (r->t_started > t_max) t_max = r->t_started;
|
|
}
|
|
}
|
|
// Tamaño bin dinamico — bins fijo 30, rango 0..t_max.
|
|
if (ImPlot::BeginPlot("##req_histogram", ImVec2(-1, 100),
|
|
ImPlotFlags_NoTitle | ImPlotFlags_NoMouseText |
|
|
ImPlotFlags_NoBoxSelect | ImPlotFlags_NoMenus |
|
|
ImPlotFlags_NoFrame)) {
|
|
ImPlot::SetupAxes("t (s)", "reqs/bin",
|
|
ImPlotAxisFlags_NoMenus,
|
|
ImPlotAxisFlags_AutoFit | ImPlotAxisFlags_NoMenus);
|
|
ImPlot::SetupAxisLimits(ImAxis_X1, 0.0, t_max + 0.001, ImPlotCond_Always);
|
|
|
|
if (!starts.empty()) {
|
|
ImPlot::PlotHistogram("Requests/bin",
|
|
starts.data(), (int)starts.size(),
|
|
g_net_ui.histogram_bins, 1.0,
|
|
ImPlotRange(0.0, t_max + 0.001));
|
|
}
|
|
|
|
// Marcadores DOMContentLoaded / Load.
|
|
auto stats = net->stats();
|
|
if (stats.dom_content_loaded > 0) {
|
|
double x = stats.dom_content_loaded;
|
|
ImPlot::TagX(x, fn_tokens::colors::info, "DCL");
|
|
}
|
|
if (stats.load_event > 0) {
|
|
double x = stats.load_event;
|
|
ImPlot::TagX(x, fn_tokens::colors::success, "L");
|
|
}
|
|
ImPlot::EndPlot();
|
|
}
|
|
ImGui::Separator();
|
|
}
|
|
// ---------- /histograma ----------
|
|
|
|
std::string filt = g_net_ui.filter_text;
|
|
std::string filt_lower = filt;
|
|
std::transform(filt_lower.begin(), filt_lower.end(), filt_lower.begin(), ::tolower);
|
|
|
|
std::vector<std::shared_ptr<NetworkRequest>> filtered;
|
|
filtered.reserve(reqs.size());
|
|
for (auto& r : reqs) {
|
|
if (g_net_ui.hide_data_urls && r->url.compare(0, 5, "data:") == 0) continue;
|
|
if (g_net_ui.only_blocked && !r->failed) continue;
|
|
if (!type_passes(g_net_ui, r->type)) continue;
|
|
if (!filt_lower.empty()) {
|
|
std::string lu = r->url; std::transform(lu.begin(), lu.end(), lu.begin(), ::tolower);
|
|
bool match = (lu.find(filt_lower) != std::string::npos);
|
|
if (g_net_ui.invert_filter) match = !match;
|
|
if (!match) continue;
|
|
}
|
|
filtered.push_back(r);
|
|
}
|
|
|
|
// Layout: split top (table) / bottom (detail) cuando hay seleccion.
|
|
bool has_sel = !g_net_ui.selected_id.empty();
|
|
float avail_h = ImGui::GetContentRegionAvail().y;
|
|
float status_bar_h = ImGui::GetTextLineHeightWithSpacing() + 4.0f;
|
|
float top_h = has_sel ? std::max(80.0f, (avail_h - status_bar_h) * 0.55f)
|
|
: (avail_h - status_bar_h);
|
|
|
|
ImGui::BeginChild("##nettable", ImVec2(0, top_h), true);
|
|
{
|
|
// --- data_table::render for ##requests ---
|
|
// Cols: Name, Status, Method, Type, Initiator, Size, Time
|
|
// (Waterfall col omitted: requires custom ImDrawList rendering — not a data_table renderer)
|
|
static std::vector<std::string> req_cell_backing;
|
|
const int REQ_COLS = 7;
|
|
const int req_rows = (int)filtered.size();
|
|
req_cell_backing.clear();
|
|
req_cell_backing.reserve((size_t)(req_rows * REQ_COLS));
|
|
for (const auto& r : filtered) {
|
|
// Name
|
|
std::string name = short_name_from_url(r->url);
|
|
if (name.empty()) name = "(empty)";
|
|
req_cell_backing.push_back(std::move(name));
|
|
// Status: bucket string for Badge + numeric for display
|
|
if (r->status > 0) {
|
|
req_cell_backing.push_back(std::to_string(r->status));
|
|
} else if (r->failed) {
|
|
req_cell_backing.push_back("failed");
|
|
} else {
|
|
req_cell_backing.push_back("...");
|
|
}
|
|
// Method
|
|
req_cell_backing.push_back(r->method.empty() ? "GET" : r->method);
|
|
// Type
|
|
req_cell_backing.push_back(resource_type_label(r->type));
|
|
// Initiator
|
|
if (!r->initiator_url.empty()) {
|
|
req_cell_backing.push_back(short_name_from_url(r->initiator_url));
|
|
} else {
|
|
req_cell_backing.push_back(r->initiator_type);
|
|
}
|
|
// Size
|
|
if (r->from_cache) {
|
|
req_cell_backing.push_back("(cache)");
|
|
} else {
|
|
req_cell_backing.push_back(fmt_size(r->encoded_data_length));
|
|
}
|
|
// Time (duration_ms as float string for Duration renderer)
|
|
{
|
|
double dur = (r->t_finished > 0 ? r->t_finished : r->t_response) - r->t_started;
|
|
if (dur < 0) dur = 0;
|
|
char buf[32]; std::snprintf(buf, sizeof(buf), "%.3f", dur * 1000.0);
|
|
req_cell_backing.push_back(buf);
|
|
}
|
|
}
|
|
static std::vector<const char*> req_cell_ptrs;
|
|
req_cell_ptrs.clear();
|
|
req_cell_ptrs.reserve(req_cell_backing.size());
|
|
for (const auto& s : req_cell_backing) req_cell_ptrs.push_back(s.c_str());
|
|
|
|
data_table::TableInput req_tbl;
|
|
req_tbl.name = "requests";
|
|
req_tbl.headers = {"Name", "Status", "Method", "Type", "Initiator", "Size", "Time (ms)"};
|
|
req_tbl.types = {
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::Float,
|
|
};
|
|
req_tbl.cells = req_rows > 0 ? req_cell_ptrs.data() : nullptr;
|
|
req_tbl.rows = req_rows;
|
|
req_tbl.cols = REQ_COLS;
|
|
|
|
// Declarative renderers.
|
|
req_tbl.column_specs.resize((size_t)REQ_COLS);
|
|
// Col 1: Status -> Badge by bucket
|
|
{
|
|
data_table::ColumnSpec& cs = req_tbl.column_specs[1];
|
|
cs.id = "status";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
// 2xx — success green
|
|
data_table::BadgeRule{"200", "#22c55e", "200"},
|
|
data_table::BadgeRule{"201", "#22c55e", "201"},
|
|
data_table::BadgeRule{"204", "#22c55e", "204"},
|
|
data_table::BadgeRule{"206", "#22c55e", "206"},
|
|
// 3xx — info blue
|
|
data_table::BadgeRule{"301", "#3b82f6", "301"},
|
|
data_table::BadgeRule{"302", "#3b82f6", "302"},
|
|
data_table::BadgeRule{"304", "#3b82f6", "304"},
|
|
// 4xx — warning yellow
|
|
data_table::BadgeRule{"400", "#f59e0b", "400"},
|
|
data_table::BadgeRule{"401", "#f59e0b", "401"},
|
|
data_table::BadgeRule{"403", "#f59e0b", "403"},
|
|
data_table::BadgeRule{"404", "#f59e0b", "404"},
|
|
data_table::BadgeRule{"429", "#f59e0b", "429"},
|
|
// 5xx — error red
|
|
data_table::BadgeRule{"500", "#ef4444", "500"},
|
|
data_table::BadgeRule{"502", "#ef4444", "502"},
|
|
data_table::BadgeRule{"503", "#ef4444", "503"},
|
|
data_table::BadgeRule{"504", "#ef4444", "504"},
|
|
// special
|
|
data_table::BadgeRule{"failed", "#ef4444", "failed"},
|
|
data_table::BadgeRule{"...", "#6b7280", "..."},
|
|
};
|
|
}
|
|
// Col 2: Method -> Badge
|
|
{
|
|
data_table::ColumnSpec& cs = req_tbl.column_specs[2];
|
|
cs.id = "method";
|
|
cs.renderer = data_table::CellRenderer::Badge;
|
|
cs.badges = {
|
|
data_table::BadgeRule{"GET", "#22c55e", "GET"},
|
|
data_table::BadgeRule{"POST", "#3b82f6", "POST"},
|
|
data_table::BadgeRule{"PUT", "#f59e0b", "PUT"},
|
|
data_table::BadgeRule{"DELETE", "#ef4444", "DELETE"},
|
|
data_table::BadgeRule{"PATCH", "#8b5cf6", "PATCH"},
|
|
data_table::BadgeRule{"HEAD", "#6b7280", "HEAD"},
|
|
data_table::BadgeRule{"OPTIONS","#6b7280", "OPTIONS"},
|
|
};
|
|
}
|
|
// Col 6: Time -> Duration (value already in ms)
|
|
{
|
|
data_table::ColumnSpec& cs = req_tbl.column_specs[6];
|
|
cs.id = "time_ms";
|
|
cs.renderer = data_table::CellRenderer::Duration;
|
|
cs.duration_warn_ms = 1000.0f;
|
|
cs.duration_error_ms = 5000.0f;
|
|
}
|
|
|
|
data_table::render("##dt_requests", {req_tbl}, g_net_ui.dt_state, true);
|
|
|
|
// Context menu + selection: track clicked row by matching Name col
|
|
// (handled inside data_table via row-click; URL copy available via right-click
|
|
// on cell in data_table chrome).
|
|
}
|
|
ImGui::EndChild();
|
|
|
|
// Detail pane (selected request).
|
|
if (has_sel) {
|
|
ImGui::BeginChild("##netdetail", ImVec2(0, 0), true);
|
|
std::shared_ptr<NetworkRequest> sel;
|
|
for (auto& r : filtered) if (r->id == g_net_ui.selected_id) { sel = r; break; }
|
|
if (!sel) {
|
|
for (auto& r : reqs) if (r->id == g_net_ui.selected_id) { sel = r; break; }
|
|
}
|
|
if (sel) {
|
|
draw_request_detail(*sel, net);
|
|
} else {
|
|
ImGui::TextDisabled("(request gone — log was cleared)");
|
|
}
|
|
ImGui::EndChild();
|
|
}
|
|
|
|
// Status bar
|
|
auto stats = net->stats();
|
|
ImGui::Separator();
|
|
ImGui::Text("%d requests", stats.total_requests);
|
|
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
|
|
ImGui::Text("%s transferred", fmt_size(stats.transferred).c_str());
|
|
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
|
|
ImGui::Text("%s resources", fmt_size(stats.resources).c_str());
|
|
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
|
|
if (stats.finish_time > 0) ImGui::Text("Finish: %.2f s", stats.finish_time);
|
|
else ImGui::TextDisabled("Finish: —");
|
|
if (stats.dom_content_loaded > 0) {
|
|
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
|
|
ImGui::Text("DCL: %.2f", stats.dom_content_loaded);
|
|
}
|
|
if (stats.load_event > 0) {
|
|
ImGui::SameLine();
|
|
ImGui::Text("L: %.2f", stats.load_event);
|
|
}
|
|
ImGui::SameLine(); ImGui::TextDisabled("|"); ImGui::SameLine();
|
|
{
|
|
uint64_t fin = net->ws_frames_in();
|
|
uint64_t bin = net->ws_bytes_in();
|
|
uint64_t bout = net->ws_bytes_out();
|
|
bool alive = (fin > 0);
|
|
ImGui::PushStyleColor(ImGuiCol_Text,
|
|
alive ? fn_tokens::colors::success : fn_tokens::colors::warning);
|
|
ImGui::Text("CDP: %s", alive ? "alive" : "no events");
|
|
ImGui::PopStyleColor();
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("(%llu frames, in %s / out %s)",
|
|
(unsigned long long)fin,
|
|
fmt_size((int64_t)bin).c_str(),
|
|
fmt_size((int64_t)bout).c_str());
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
} // namespace navegator
|