From a36530bb6feb9e44ee89a5a68da51d763580c714 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Thu, 30 Apr 2026 22:55:30 +0200 Subject: [PATCH] feat: docking host + add-node toolbar + node context menu - Dockspace host (PassthruCentralNode) bajo la toolbar para que las ventanas Viewport/Legend/Inspector/Stats puedan dockearse dentro de la app principal. - Toolbar: input "Add node" con auto-deteccion de tipo (text/email/ ip_address/url/domain/phone). Insert en operations.db + reload. - Context menu (right-click sobre nodo): Change type, Duplicate, Delete, submenu "Run enricher" (placeholder hasta issues 0001-0003). - Inspector: vecinos ahora muestran etiqueta de relacion ("-> employs", "<- owns") usando rel_types[].name como label de arista. - Default relation label k_default_relation_name="RELATED_TO" para relaciones creadas sin nombre semantico explicito. - Indice EntityIndex (FNV1a hash -> sql id) reconstruido tras cada load para resolver mutaciones desde el grafo en memoria. Issues planteadas para iteraciones siguientes: - 0001: chat con Claude sobre el grafo (HTTP + tool-use) - 0002: enricher GLiNER+GLiREL desde nodo texto - 0003: enricher web (fetch URL/dominio + extract text) - 0004: vista tabla por tipo de entidad --- CMakeLists.txt | 1 + entity_ops.cpp | 322 ++++++++++++++++++++++++++ entity_ops.h | 66 ++++++ issues/0001-claude-chat-agent.md | 48 ++++ issues/0002-enricher-gliner-glirel.md | 54 +++++ issues/0003-enricher-web-extract.md | 38 +++ issues/0004-table-view.md | 26 +++ main.cpp | 286 +++++++++++++++++++---- views.cpp | 30 ++- views.h | 21 ++ 10 files changed, 844 insertions(+), 48 deletions(-) create mode 100644 entity_ops.cpp create mode 100644 entity_ops.h create mode 100644 issues/0001-claude-chat-agent.md create mode 100644 issues/0002-enricher-gliner-glirel.md create mode 100644 issues/0003-enricher-web-extract.md create mode 100644 issues/0004-table-view.md diff --git a/CMakeLists.txt b/CMakeLists.txt index 9a01302..2756bc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -20,6 +20,7 @@ add_imgui_app(graph_explorer views.cpp types_registry.cpp layout_store.cpp + entity_ops.cpp # --- viz --- ${FN_CPP_ROOT_DIR}/functions/viz/graph_renderer.cpp ${FN_CPP_ROOT_DIR}/functions/viz/graph_force_layout.cpp diff --git a/entity_ops.cpp b/entity_ops.cpp new file mode 100644 index 0000000..1c0c6eb --- /dev/null +++ b/entity_ops.cpp @@ -0,0 +1,322 @@ +#include "entity_ops.h" + +#include "../../../../cpp/vendor/sqlite3/sqlite3.h" + +#include +#include +#include +#include +#include + +namespace ge { + +// ---------------------------------------------------------------------------- +// FNV1a-64 — debe coincidir con graph_sources.cpp +// ---------------------------------------------------------------------------- +static uint64_t fnv1a64(const char* s) { + uint64_t h = 1469598103934665603ULL; + for (; s && *s; ++s) { + h ^= (uint8_t)*s; + h *= 1099511628211ULL; + } + return h; +} + +// ---------------------------------------------------------------------------- +// Heuristicas — sin para evitar peso. Inspeccion lineal del string. +// ---------------------------------------------------------------------------- + +static bool is_email(const char* s) { + if (!s || !*s) return false; + const char* at = std::strchr(s, '@'); + if (!at || at == s) return false; + if (std::strchr(at + 1, '@')) return false; // dos @ + const char* dot = std::strchr(at + 1, '.'); + if (!dot || dot[1] == 0) return false; + if (std::strchr(s, ' ')) return false; + return true; +} + +static bool is_ipv4(const char* s) { + if (!s || !*s) return false; + int parts = 0, digits = 0, n = 0; + for (const char* p = s; ; ++p) { + if (*p >= '0' && *p <= '9') { + n = n * 10 + (*p - '0'); + if (++digits > 3 || n > 255) return false; + } else if (*p == '.' || *p == 0) { + if (digits == 0) return false; + ++parts; digits = 0; n = 0; + if (*p == 0) break; + } else { + return false; + } + } + return parts == 4; +} + +static bool is_url(const char* s) { + if (!s) return false; + return std::strncmp(s, "http://", 7) == 0 || std::strncmp(s, "https://", 8) == 0; +} + +static bool is_domain(const char* s) { + if (!s || !*s) return false; + if (std::strchr(s, ' ') || std::strchr(s, '@') || std::strchr(s, '/')) return false; + const char* dot = std::strchr(s, '.'); + if (!dot || dot == s || dot[1] == 0) return false; + // El TLD debe ser al menos 2 caracteres alfabeticos + int tld = 0; + for (const char* p = std::strrchr(s, '.') + 1; *p; ++p) { + if (!std::isalpha((unsigned char)*p)) return false; + ++tld; + } + return tld >= 2; +} + +static bool is_phone(const char* s) { + if (!s || !*s) return false; + int digits = 0; + for (const char* p = s; *p; ++p) { + if (*p >= '0' && *p <= '9') ++digits; + else if (*p == '+' || *p == ' ' || *p == '-' || *p == '(' || *p == ')' || *p == '.') {} + else return false; + } + return digits >= 7 && digits <= 15; +} + +DetectedType detect_type(const char* text) { + if (!text || !*text) return DT_TEXT; + if (is_email(text)) return DT_EMAIL; + if (is_ipv4(text)) return DT_IP_ADDRESS; + if (is_url(text)) return DT_URL; + if (is_phone(text)) return DT_PHONE; // antes que domain (numeros con puntos) + if (is_domain(text)) return DT_DOMAIN; + return DT_TEXT; +} + +const char* detected_type_name(DetectedType dt) { + switch (dt) { + case DT_EMAIL: return "email"; + case DT_IP_ADDRESS: return "ip_address"; + case DT_URL: return "url"; + case DT_DOMAIN: return "domain"; + case DT_PHONE: return "phone"; + case DT_TEXT: + default: return "text"; + } +} + +// ---------------------------------------------------------------------------- +// SQLite helpers +// ---------------------------------------------------------------------------- + +static bool exec_one(sqlite3* db, const char* sql, + const char** params, int n_params) +{ + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, sql, -1, &st, nullptr) != SQLITE_OK) return false; + for (int i = 0; i < n_params; ++i) { + sqlite3_bind_text(st, i + 1, params[i] ? params[i] : "", -1, SQLITE_TRANSIENT); + } + int rc = sqlite3_step(st); + sqlite3_finalize(st); + return rc == SQLITE_DONE; +} + +static std::string now_iso() { + using namespace std::chrono; + auto t = system_clock::to_time_t(system_clock::now()); + char buf[32]; + std::strftime(buf, sizeof(buf), "%Y-%m-%dT%H:%M:%S", std::gmtime(&t)); + return std::string(buf) + "Z"; +} + +static long long now_ms() { + using namespace std::chrono; + return duration_cast(system_clock::now().time_since_epoch()).count(); +} + +// ---------------------------------------------------------------------------- +// CRUD +// ---------------------------------------------------------------------------- + +bool entity_insert(const char* db_path, const char* name, const char* type_ref, + char* out_id, size_t out_id_n) +{ + if (!db_path || !name || !*name || !out_id || out_id_n < 32) return false; + + std::string tref; + if (type_ref && *type_ref) { + tref = type_ref; + } else { + tref = detected_type_name(detect_type(name)); + } + + std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms()); + std::string ts = now_iso(); + + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* sql = + "INSERT INTO entities (id, name, type_ref, source, created_at, updated_at) " + "VALUES (?, ?, ?, 'manual', ?, ?)"; + const char* params[5] = { out_id, name, tref.c_str(), ts.c_str(), ts.c_str() }; + bool ok = exec_one(db, sql, params, 5); + sqlite3_close(db); + return ok; +} + +bool entity_delete(const char* db_path, const char* id) { + if (!db_path || !id) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + const char* p_rel[2] = { id, id }; + const char* p_ent[1] = { id }; + exec_one(db, "DELETE FROM relations WHERE from_entity = ? OR to_entity = ?", p_rel, 2); + bool ok = exec_one(db, "DELETE FROM entities WHERE id = ?", p_ent, 1); + sqlite3_close(db); + return ok; +} + +bool entity_update_type(const char* db_path, const char* id, const char* new_type) { + if (!db_path || !id || !new_type) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + std::string ts = now_iso(); + const char* p[3] = { new_type, ts.c_str(), id }; + bool ok = exec_one(db, "UPDATE entities SET type_ref = ?, updated_at = ? WHERE id = ?", p, 3); + sqlite3_close(db); + return ok; +} + +bool entity_duplicate(const char* db_path, const char* id, + char* out_id, size_t out_id_n) +{ + if (!db_path || !id || !out_id || out_id_n < 32) return false; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + sqlite3_stmt* st = nullptr; + const char* sel = "SELECT name, type_ref, description, domain, tags, source, metadata, notes " + "FROM entities WHERE id = ?"; + if (sqlite3_prepare_v2(db, sel, -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(st, 1, id, -1, SQLITE_TRANSIENT); + if (sqlite3_step(st) != SQLITE_ROW) { + sqlite3_finalize(st); + sqlite3_close(db); + return false; + } + auto col = [&](int i) { + const unsigned char* p = sqlite3_column_text(st, i); + return std::string(p ? (const char*)p : ""); + }; + std::string name = col(0) + " (copia)"; + std::string tref = col(1); + std::string desc = col(2); + std::string dom = col(3); + std::string tags = col(4); + std::string src = col(5); + std::string meta = col(6); + std::string notes = col(7); + sqlite3_finalize(st); + + std::snprintf(out_id, out_id_n, "%s_%lld", tref.c_str(), now_ms()); + std::string ts = now_iso(); + + const char* ins = + "INSERT INTO entities (id, name, type_ref, description, domain, tags, source, " + "metadata, notes, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + sqlite3_stmt* ist = nullptr; + if (sqlite3_prepare_v2(db, ins, -1, &ist, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + sqlite3_bind_text(ist, 1, out_id, -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 2, name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 3, tref.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 4, desc.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 5, dom.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 6, tags.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 7, src.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 8, meta.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 9, notes.c_str(),-1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 10, ts.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(ist, 11, ts.c_str(), -1, SQLITE_TRANSIENT); + bool ok = sqlite3_step(ist) == SQLITE_DONE; + sqlite3_finalize(ist); + sqlite3_close(db); + return ok; +} + +bool relation_insert(const char* db_path, const char* from_id, const char* to_id, + const char* name) +{ + if (!db_path || !from_id || !to_id) return false; + const char* rel = (name && *name) ? name : k_default_relation_name; + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READWRITE, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + char id[64]; + std::snprintf(id, sizeof(id), "rel_%lld", now_ms()); + std::string ts = now_iso(); + const char* sql = + "INSERT INTO relations (id, name, from_entity, to_entity, created_at, updated_at) " + "VALUES (?, ?, ?, ?, ?, ?)"; + const char* params[6] = { id, rel, from_id, to_id, ts.c_str(), ts.c_str() }; + bool ok = exec_one(db, sql, params, 6); + sqlite3_close(db); + return ok; +} + +// ---------------------------------------------------------------------------- +// Index user_data -> sql id +// ---------------------------------------------------------------------------- + +bool entity_index_build(const char* db_path, EntityIndex* idx) { + if (!db_path || !idx) return false; + idx->by_hash.clear(); + sqlite3* db = nullptr; + if (sqlite3_open_v2(db_path, &db, SQLITE_OPEN_READONLY, nullptr) != SQLITE_OK) { + if (db) sqlite3_close(db); + return false; + } + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db, "SELECT id FROM entities", -1, &st, nullptr) != SQLITE_OK) { + sqlite3_close(db); + return false; + } + while (sqlite3_step(st) == SQLITE_ROW) { + const unsigned char* p = sqlite3_column_text(st, 0); + if (!p) continue; + const char* id = (const char*)p; + idx->by_hash.emplace(fnv1a64(id), std::string(id)); + } + sqlite3_finalize(st); + sqlite3_close(db); + return true; +} + +const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data) { + auto it = idx.by_hash.find(user_data); + return (it == idx.by_hash.end()) ? nullptr : it->second.c_str(); +} + +} // namespace ge diff --git a/entity_ops.h b/entity_ops.h new file mode 100644 index 0000000..fb9dbce --- /dev/null +++ b/entity_ops.h @@ -0,0 +1,66 @@ +#pragma once +#include +#include +#include +#include + +// Operaciones CRUD sobre operations.db (entities + relations) y deteccion +// heuristica de tipo a partir de texto libre. Pensado para que la toolbar y +// el menu contextual del viewport puedan modificar el grafo y luego pedir +// reload (issue 0049g flow). +// +// Convencion edge labels: si el caller no pasa nombre de relacion, se usa +// k_default_relation_name = "RELATED_TO". Los enrichers deben pasar siempre +// un nombre semantico (ej: "EXTRACTED_FROM", "RESOLVES_TO", ...). + +namespace ge { + +constexpr const char* k_default_relation_name = "RELATED_TO"; + +enum DetectedType { + DT_TEXT = 0, + DT_EMAIL, + DT_IP_ADDRESS, + DT_URL, + DT_DOMAIN, + DT_PHONE, +}; + +DetectedType detect_type(const char* text); +const char* detected_type_name(DetectedType dt); + +// Inserta una entidad nueva. Si type_ref es NULL/vacio se infiere via +// detect_type(name). Genera un id unico ("_"). Devuelve el id +// en out_id (caller-owned buffer >= 64). Retorna false si SQLite falla. +bool entity_insert(const char* db_path, + const char* name, + const char* type_ref, + char* out_id, size_t out_id_n); + +bool entity_delete(const char* db_path, const char* id); + +bool entity_update_type(const char* db_path, const char* id, const char* new_type); + +// Duplica una entidad existente. Mismo type/metadata, sufijo "_copy" en id +// y "(copia)" en name. Devuelve el nuevo id en out_id. +bool entity_duplicate(const char* db_path, const char* id, + char* out_id, size_t out_id_n); + +// Inserta una relacion. Si name es NULL/vacio usa k_default_relation_name. +bool relation_insert(const char* db_path, + const char* from_id, const char* to_id, + const char* name); + +// Mapa user_data (FNV1a hash) -> sql id. Se reconstruye despues de cada +// carga del grafo (graph_sources usa FNV1a sobre id como user_data). +struct EntityIndex { + std::unordered_map by_hash; +}; + +// Escanea operations.db y rellena el indice. Reentrante (clear+repoblar). +bool entity_index_build(const char* db_path, EntityIndex* idx); + +// Resuelve user_data a sql id. NULL si no existe. +const char* entity_index_lookup(const EntityIndex& idx, uint64_t user_data); + +} // namespace ge diff --git a/issues/0001-claude-chat-agent.md b/issues/0001-claude-chat-agent.md new file mode 100644 index 0000000..88fb17c --- /dev/null +++ b/issues/0001-claude-chat-agent.md @@ -0,0 +1,48 @@ +--- +id: 0001 +title: Chat con Claude sobre el grafo +status: pending +priority: high +created: 2026-04-30 +--- + +## Objetivo + +Panel "Chat" dentro de graph_explorer que permita conversar con Claude +(Anthropic API) usando el grafo activo como contexto. El agente debe poder: + +- Leer el grafo (entidades, relaciones, metadata) via tool-use sobre operations.db. +- Responder preguntas tipo: "muestrame los nodos relacionados con X", "que + patrones ves en estas conexiones", "que falta investigar". +- Proponer mutaciones (crear nodo, etiquetar relacion) que el usuario aprueba + con un click antes de aplicarse. + +## Alcance tecnico + +- Cliente HTTP minimo (libcurl o WinHTTP) → POST a `https://api.anthropic.com/v1/messages`. +- Modelo por defecto: `claude-sonnet-4-6` (revisar al implementar). +- API key desde env var `ANTHROPIC_API_KEY` o `~/.fn_anthropic_key`. +- Tool-use: definir tools `query_entities`, `query_relations`, `propose_node`, + `propose_relation`. Las "propose_*" no mutan: insertan en una cola que el + usuario revisa antes de aplicar. +- Estado de conversacion en memoria (lista de messages). Persistencia opcional + en `graph_explorer.db` tabla `chat_sessions`. +- Streaming SSE para feedback en vivo (puede dejarse para v2 — primer hit + bloqueante esta bien). + +## Decisiones a tomar + +- Renderizado de markdown en ImGui (TextWrapped basico vs lib externa). +- Threading: bloqueante en hilo aparte → cola de mensajes → main thread lee. + +## Trabajo previo + +Ya existe en el registry `python/functions/agents/anthropic_chat_py_agents.py` +para inspiracion (usa el SDK Python). En C++ usaremos HTTP directo — sin SDK. + +## Definicion de hecho + +- Panel "Chat" dockeable. +- Conversacion con tool-use sobre operations.db funciona. +- Las mutaciones propuestas por el agente se confirman desde la UI antes de + llegar a la BD. diff --git a/issues/0002-enricher-gliner-glirel.md b/issues/0002-enricher-gliner-glirel.md new file mode 100644 index 0000000..bba3ffb --- /dev/null +++ b/issues/0002-enricher-gliner-glirel.md @@ -0,0 +1,54 @@ +--- +id: 0002 +title: Enricher GLiNER + GLiREL — emitir entidades/relaciones desde un nodo texto +status: pending +priority: high +created: 2026-04-30 +--- + +## Objetivo + +Right-click sobre un nodo de tipo `text` → "Run enricher → Extract entities +(GLiNER+GLiREL)". El enricher procesa el texto del nodo y crea: + +- Nuevas entidades (person, org, email, location, ...) con tipos detectados. +- Relaciones entre el nodo origen y las nuevas, etiquetadas con `EXTRACTED_FROM`. +- Relaciones entre las nuevas entidades cuando GLiREL las detecte, etiquetadas + con el tipo predicho por el modelo (`employed_by`, `located_in`, ...). + +## Trabajo previo en el registry + +Ya existen las funciones Python: +- `python/functions/extraction/gliner_extract_*` (varios) +- `python/functions/extraction/glirel_extract_*` +- Pipeline `extract_graph_hybrid_py_pipelines` (issue 0040 cerrado, ver commit + 1a353878) que ya hace exactamente esto sobre un texto y devuelve un grafo + estructurado. + +El analysis `analysis/retrieving_graphs/` lo usa en notebooks. + +## Alcance tecnico (C++ side) + +- Definir interfaz de enricher: `enricher_run(node_id, db_path) -> int n_added`. +- Implementacion `enricher_gliner_glirel`: + - Spawn `python/.venv/bin/python3` con un script wrapper que recibe el texto + por stdin (JSON `{"text": "..."}`) y devuelve por stdout el grafo + estructurado (JSON `{"entities": [...], "relations": [...]}`). + - Wrapper Python en `projects/osint_graph/apps/graph_explorer/enrichers/gliner_glirel.py`. + - C++ usa `popen` o `CreateProcess` segun plataforma. +- Insertar las entidades nuevas (entity_insert) y relaciones (relation_insert) + con etiquetas semanticas. +- UI: spinner en el menu mientras corre (cold start del modelo ~5s). + +## Riesgos / decisiones + +- Modelos pesados → cold start lento. Considerar pre-cargar al primer uso. +- Streaming de progreso desde Python via stderr line-by-line. +- Fallback si el venv no existe: mostrar mensaje en el menu en vez de fallar. + +## Definicion de hecho + +- Click derecho en nodo `text` → "Extract entities" muestra opcion. +- Tras correr, el grafo se recarga con las nuevas entidades visibles y + conectadas con `EXTRACTED_FROM`. +- Las relaciones entre entidades extraidas llevan el tipo que GLiREL predice. diff --git a/issues/0003-enricher-web-extract.md b/issues/0003-enricher-web-extract.md new file mode 100644 index 0000000..55173cb --- /dev/null +++ b/issues/0003-enricher-web-extract.md @@ -0,0 +1,38 @@ +--- +id: 0003 +title: Enricher web — descargar URL/dominio y extraer texto +status: pending +priority: medium +created: 2026-04-30 +--- + +## Objetivo + +Right-click sobre un nodo `url` o `domain` → "Run enricher → Fetch & extract +text". Descarga el HTML, extrae el texto principal, crea un nodo `text` +conectado al origen con relacion `FETCHED_FROM`. + +Despues el usuario puede encadenar: sobre ese nodo `text`, ejecutar el enricher +GLiNER+GLiREL (issue 0002) para extraer entidades. + +## Alcance + +- HTTP GET con timeout (libcurl o sys WinHTTP). +- Extraccion de texto: regex/strip de tags simple en v1; v2 usa una lib + (htmlparser2 / lexbor / boost.url + algo de heuristica). +- User-agent identificativo, respeto de robots.txt opcional (out-of-scope v1). +- Limite de tamaño descargable (1 MB) para evitar bloqueos. + +## Modelo de etiquetado + +- Nodo origen (url/domain) → arista `FETCHED_FROM` → nodo nuevo (text con + metadata={fetched_at, status_code, content_type, length}). +- Nombre del nodo text: titulo de la pagina (si existe) o primeros + 120 caracteres del cuerpo. + +## Definicion de hecho + +- Funciona contra una URL real (https con TLS). +- Maneja errores (404, timeout, redirects basicos) sin tumbar la app. +- El nodo creado es visible y el texto se puede consumir por el enricher + GLiNER+GLiREL del issue 0002. diff --git a/issues/0004-table-view.md b/issues/0004-table-view.md new file mode 100644 index 0000000..2497bdf --- /dev/null +++ b/issues/0004-table-view.md @@ -0,0 +1,26 @@ +--- +id: 0004 +title: Vista tabla — entidades agrupadas por tipo +status: pending +priority: medium +created: 2026-04-30 +--- + +## Objetivo + +Ventana "Table" dockeable con una tabla por cada tipo de entidad presente en el +grafo. Filas = entidades. Columnas: id, name, status, updated_at, neighbors +count. Clickar una fila selecciona el nodo en el viewport. + +## UI + +- ImGui::BeginTable con sorting + clipper para >10k filas. +- Tabs en la cabecera de la ventana, una tab por type_ref ordenado alfabetico. +- Selector global "show all types" que apila todos en una sola tabla. + +## Definicion de hecho + +- Tabla escala a 10k entidades sin lag perceptible. +- Click en fila selecciona nodo en viewport (mismo flujo que Inspector + Selectable). +- Filtro de busqueda por substring sobre name/id. diff --git a/main.cpp b/main.cpp index 159d9f8..4fe8eec 100644 --- a/main.cpp +++ b/main.cpp @@ -22,6 +22,7 @@ #include "views.h" #include "types_registry.h" #include "layout_store.h" +#include "entity_ops.h" #include <cstdio> #include <cstdlib> @@ -63,6 +64,9 @@ static auto g_fps_timer = std::chrono::steady_clock::now(); // Label policy static graph::LabelPolicy g_label_policy; +// Indice user_data -> sql id (rebuild en cada load). +static ge::EntityIndex g_idx; + // ---------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------- @@ -145,6 +149,10 @@ static bool load_input() { } g_graph.update_bounds(); + // Indice user_data -> sql id (para CRUD desde menu contextual). + ge::entity_index_build(g_input.uri, &g_idx); + g_app.input_db_path = g_input.uri ? g_input.uri : ""; + // Cargar posiciones guardadas para este graph_hash g_graph_hash = ge::compute_graph_hash(g_input.uri); int restored = ge::layout_store_load(g_graph_hash, g_graph); @@ -234,6 +242,88 @@ static void update_fps() { g_last_frame = now; } +// ---------------------------------------------------------------------------- +// Context menu callback (right-click sobre nodo) +// ---------------------------------------------------------------------------- + +static void on_context_menu_cb(int node_idx, ImVec2 /*screen_pos*/, void* /*user*/) { + g_app.ctx_node = node_idx; + g_app.ctx_open_request = true; + if (node_idx >= 0 && node_idx < g_graph.node_count) { + const GraphNode& n = g_graph.nodes[node_idx]; + if (n.type_id < (uint16_t)g_graph.type_count && g_graph.types[n.type_id].name) { + std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", + g_graph.types[n.type_id].name); + } else { + g_app.ctx_new_type[0] = 0; + } + } +} + +// Lista de tipos disponibles para "Change type" — se construye desde el grafo +// activo. Si esta vacia, se usa una lista por defecto. +static const char* k_default_types[] = { + "text", "person", "organization", "email", "ip_address", "domain", + "url", "phone", "crypto_wallet", "malware", "vulnerability", +}; +constexpr int k_default_types_n = (int)(sizeof(k_default_types) / sizeof(k_default_types[0])); + +static void render_context_menu() { + if (g_app.ctx_open_request) { + ImGui::OpenPopup("##node_ctx"); + g_app.ctx_open_request = false; + } + if (!ImGui::BeginPopup("##node_ctx")) return; + + int idx = g_app.ctx_node; + if (idx < 0 || idx >= g_graph.node_count) { + ImGui::TextDisabled("(no node)"); + ImGui::EndPopup(); + return; + } + const GraphNode& n = g_graph.nodes[idx]; + const char* lbl = graph::graph_label(&g_graph, n.label_idx); + ImGui::TextDisabled("%s", lbl && *lbl ? lbl : "(unnamed)"); + ImGui::Separator(); + + if (ImGui::BeginMenu("Change type")) { + // Tipos del grafo actual + for (int i = 0; i < g_graph.type_count; ++i) { + const char* name = g_graph.types[i].name; + if (!name) continue; + if (ImGui::MenuItem(name)) { + std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", name); + g_app.want_change_type = true; + } + } + ImGui::Separator(); + // Defaults extra (por si no estan presentes en el grafo cargado) + for (int i = 0; i < k_default_types_n; ++i) { + if (ImGui::MenuItem(k_default_types[i])) { + std::snprintf(g_app.ctx_new_type, sizeof(g_app.ctx_new_type), "%s", + k_default_types[i]); + g_app.want_change_type = true; + } + } + ImGui::EndMenu(); + } + + if (ImGui::MenuItem("Duplicate")) { + g_app.want_duplicate_node = true; + } + if (ImGui::MenuItem("Delete")) { + g_app.want_delete_node = true; + } + + ImGui::Separator(); + if (ImGui::BeginMenu("Run enricher")) { + ImGui::TextDisabled("(coming soon — issues 0001/0002/0003)"); + ImGui::EndMenu(); + } + + ImGui::EndPopup(); +} + // ---------------------------------------------------------------------------- // Label callback // ---------------------------------------------------------------------------- @@ -249,6 +339,7 @@ static const char* get_label_cb(int node_idx, void* /*user*/) { // ---------------------------------------------------------------------------- static fn_ui::PanelToggle g_panels[] = { + {"Viewport", nullptr, &g_app.panel_viewport}, {"Legend", nullptr, &g_app.panel_legend}, {"Inspector", nullptr, &g_app.panel_inspector}, {"Stats", nullptr, &g_app.panel_stats}, @@ -281,8 +372,29 @@ static void render() { return; } - // Toolbar superior — usa una ventana sin scroll y sin titulo + // Dockspace host: ocupa el area de trabajo bajo la menubar y permite + // que cualquier ventana (Viewport, Legend, Inspector, Stats, Table) se + // arrastre a un lateral o pestañas dentro de la app. ImGuiViewport* vp = ImGui::GetMainViewport(); + { + ImGui::SetNextWindowPos (vp->WorkPos); + ImGui::SetNextWindowSize(vp->WorkSize); + ImGui::SetNextWindowViewport(vp->ID); + ImGuiWindowFlags hostFlags = + ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | + ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoBringToFrontOnFocus | ImGuiWindowFlags_NoNavFocus | + ImGuiWindowFlags_NoBackground | ImGuiWindowFlags_NoDocking | + ImGuiWindowFlags_NoSavedSettings; + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::Begin("##dock_host", nullptr, hostFlags); + ImGui::PopStyleVar(); + ImGui::DockSpace(ImGui::GetID("##dockspace"), ImVec2(0, 0), + ImGuiDockNodeFlags_PassthruCentralNode); + ImGui::End(); + } + + // Toolbar superior — usa una ventana sin scroll y sin titulo ImGui::SetNextWindowPos(vp->WorkPos); ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, 44.0f)); ImGui::Begin("##toolbar", nullptr, @@ -335,56 +447,130 @@ static void render() { load_input(); } - // Main work area — viewport central, paneles laterales - ImGui::SetNextWindowPos(ImVec2(vp->WorkPos.x, vp->WorkPos.y + 44.0f)); - ImGui::SetNextWindowSize(ImVec2(vp->WorkSize.x, vp->WorkSize.y - 44.0f)); - ImGui::Begin("##main", nullptr, - ImGuiWindowFlags_NoTitleBar | ImGuiWindowFlags_NoResize | - ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoCollapse | - ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoSavedSettings); - - ImGui::Columns(3, "##cols", true); - static bool s_cols_initialized = false; - if (!s_cols_initialized) { - ImGui::SetColumnWidth(0, 220.0f); - ImGui::SetColumnWidth(1, vp->WorkSize.x - 220.0f - 320.0f); - s_cols_initialized = true; - } - - // Col izq: Legend - ge::views_legend(g_app); - ImGui::NextColumn(); - - // Col centro: Viewport + force step + labels overlay - run_force_step(); - - graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0)); - - // La primera vez que el viewport se dibuja, el renderer existe — bind - // del atlas (si tenemos uno). - if (!g_atlas_bound && g_viewport.renderer) { - if (g_atlas) { - graph_renderer_set_icon_atlas(g_viewport.renderer, - graph_icons_texture(g_atlas), - graph_icons_uv_table(g_atlas), - graph_icons_count(g_atlas)); + // ---- Mutaciones (add/delete/duplicate/change_type) ---- + auto reload_after_mutation = [&]() { + graph::GraphLoadStats stats{}; + if (ge::reload_graph(g_input, &g_graph, &stats)) { + ge::entity_index_build(g_input.uri, &g_idx); + ge::views_reset_visibility(g_app); + ge::views_apply_visibility(g_app); + g_graph.update_bounds(); + int restored = ge::layout_store_load(g_graph_hash, g_graph); + if (restored > 0) g_graph.update_bounds(); + g_atlas_bound = false; + g_gpu_dirty = true; } - g_atlas_bound = true; + }; + + if (g_app.want_add_node && g_app.add_buf[0]) { + char new_id[80]; + if (ge::entity_insert(g_app.input_db_path.c_str(), g_app.add_buf, + /*type_ref=*/nullptr, new_id, sizeof(new_id))) { + std::fprintf(stdout, "[graph_explorer] added entity %s\n", new_id); + g_app.add_buf[0] = 0; + reload_after_mutation(); + } else { + std::fprintf(stderr, "[graph_explorer] add_entity failed\n"); + } + g_app.want_add_node = false; } - if (g_app.labels_enabled) { - graph::graph_labels_draw(g_graph, g_viewport, g_label_policy, - &get_label_cb, nullptr); - } - ImGui::NextColumn(); + auto ctx_id = [&]() -> const char* { + if (g_app.ctx_node < 0 || g_app.ctx_node >= g_graph.node_count) return nullptr; + return ge::entity_index_lookup(g_idx, g_graph.nodes[g_app.ctx_node].user_data); + }; - // Col der: Inspector + Stats + if (g_app.want_delete_node) { + if (const char* id = ctx_id()) { + if (ge::entity_delete(g_app.input_db_path.c_str(), id)) { + std::fprintf(stdout, "[graph_explorer] deleted entity %s\n", id); + reload_after_mutation(); + } + } + g_app.want_delete_node = false; + g_app.ctx_node = -1; + } + + if (g_app.want_duplicate_node) { + if (const char* id = ctx_id()) { + char new_id[80]; + if (ge::entity_duplicate(g_app.input_db_path.c_str(), id, + new_id, sizeof(new_id))) { + std::fprintf(stdout, "[graph_explorer] duplicated %s -> %s\n", id, new_id); + reload_after_mutation(); + } + } + g_app.want_duplicate_node = false; + } + + if (g_app.want_change_type && g_app.ctx_new_type[0]) { + if (const char* id = ctx_id()) { + if (ge::entity_update_type(g_app.input_db_path.c_str(), id, g_app.ctx_new_type)) { + std::fprintf(stdout, "[graph_explorer] %s -> type %s\n", id, g_app.ctx_new_type); + reload_after_mutation(); + } + } + g_app.want_change_type = false; + } + + // Posiciones iniciales razonables; el usuario puede moverlas y se + // persiste via imgui.ini. + const float top = vp->WorkPos.y + 44.0f; + const float W = vp->WorkSize.x; + const float H = vp->WorkSize.y - 44.0f; + const float lw = 240.0f; // Legend + const float rw = 320.0f; // Inspector / Stats + const float sh = H * 0.55f; // Inspector altura + + // Viewport — ventana central + if (g_app.panel_viewport) { + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + lw, top), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(W - lw - rw, H), ImGuiCond_FirstUseEver); + if (ImGui::Begin("Viewport", &g_app.panel_viewport)) { + run_force_step(); + + GraphViewportCallbacks vp_cb{}; + vp_cb.on_context_menu = &on_context_menu_cb; + graph_viewport("##gv", g_graph, g_viewport, ImVec2(0, 0), vp_cb); + render_context_menu(); + + // La primera vez que el viewport se dibuja, el renderer existe — + // bind del atlas (si tenemos uno). + if (!g_atlas_bound && g_viewport.renderer) { + if (g_atlas) { + graph_renderer_set_icon_atlas(g_viewport.renderer, + graph_icons_texture(g_atlas), + graph_icons_uv_table(g_atlas), + graph_icons_count(g_atlas)); + } + g_atlas_bound = true; + } + + if (g_app.labels_enabled) { + graph::graph_labels_draw(g_graph, g_viewport, g_label_policy, + &get_label_cb, nullptr); + } + } + ImGui::End(); + } else { + // Sin ventana visible, igual avanzamos la simulacion para que al + // reabrirla el grafo este actualizado. + run_force_step(); + } + + // Legend — izquierda + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x, top), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(lw, H), ImGuiCond_FirstUseEver); + ge::views_legend(g_app); + + // Inspector / Stats — derecha (apilados) + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(rw, sh), ImGuiCond_FirstUseEver); ge::views_inspector(g_app); - ge::views_stats(g_app); - ImGui::NextColumn(); - ImGui::Columns(1); - ImGui::End(); + ImGui::SetNextWindowPos (ImVec2(vp->WorkPos.x + W - rw, top + sh), ImGuiCond_FirstUseEver); + ImGui::SetNextWindowSize(ImVec2(rw, H - sh), ImGuiCond_FirstUseEver); + ge::views_stats(g_app); g_first_render = false; } @@ -433,6 +619,16 @@ int main(int argc, char** argv) { // SQLite store junto al exe. ge::layout_store_open("graph_explorer.db"); + // Si no llego --input/positional, intentar operations.db en el cwd + // (mismo criterio que graph_explorer.db: relativo al directorio de ejecucion). + if (g_input_path.empty()) { + if (FILE* f = std::fopen("operations.db", "rb")) { + std::fclose(f); + g_input_path = "operations.db"; + std::fprintf(stdout, "[graph_explorer] using default ./operations.db\n"); + } + } + if (!g_input_path.empty()) { load_input(); } diff --git a/views.cpp b/views.cpp index c41e284..b7838c0 100644 --- a/views.cpp +++ b/views.cpp @@ -1,4 +1,5 @@ #include "views.h" +#include "entity_ops.h" #include "viz/graph_types.h" #include "viz/graph_viewport.h" @@ -93,6 +94,24 @@ void views_toolbar(AppState& app) { if (button(TI_FOLDER " Open file...", ButtonVariant::Secondary)) { app.show_open_modal = true; } + ImGui::SameLine(); + + // Add node — input + auto-deteccion de tipo. Enter o boton "Add" lo + // confirman; main.cpp inserta en operations.db y dispara reload. + ImGui::SetNextItemWidth(220); + DetectedType dt = detect_type(app.add_buf); + ImGuiInputTextFlags flags = ImGuiInputTextFlags_EnterReturnsTrue; + char hint[64]; + std::snprintf(hint, sizeof(hint), "Add node (%s)...", detected_type_name(dt)); + if (ImGui::InputTextWithHint("##addnode", hint, app.add_buf, sizeof(app.add_buf), flags)) { + app.want_add_node = true; + } + ImGui::SameLine(); + if (button(TI_PLUS " Add", ButtonVariant::Primary)) { + app.want_add_node = true; + } + ImGui::SameLine(); + ImGui::TextDisabled("[%s]", detected_type_name(dt)); toolbar_separator(); ImGui::TextUnformatted("Layout:"); @@ -268,12 +287,17 @@ void views_inspector(AppState& app) { for (int e = 0; e < g.edge_count && neighbor_count < 64; ++e) { const GraphEdge& edge = g.edges[e]; int other = -1; - if (edge.source == (uint32_t)idx) other = (int)edge.target; - else if (edge.target == (uint32_t)idx) other = (int)edge.source; + const char* arrow = " "; + if (edge.source == (uint32_t)idx) { other = (int)edge.target; arrow = "->"; } + else if (edge.target == (uint32_t)idx) { other = (int)edge.source; arrow = "<-"; } if (other < 0 || other >= g.node_count) continue; const char* olbl = graph::graph_label(&g, g.nodes[other].label_idx); + const char* rname = (edge.type_id < (uint16_t)g.rel_type_count && + g.rel_types[edge.type_id].name) + ? g.rel_types[edge.type_id].name + : k_default_relation_name; char buf[256]; - std::snprintf(buf, sizeof(buf), "[%d] %s", other, + std::snprintf(buf, sizeof(buf), "%s %s [%d] %s", arrow, rname, other, olbl && *olbl ? olbl : "(unnamed)"); if (ImGui::Selectable(buf)) { graph_viewport_clear_selection(g, *app.viewport); diff --git a/views.h b/views.h index 9f9aedb..6b310c4 100644 --- a/views.h +++ b/views.h @@ -1,5 +1,7 @@ #pragma once +#include <string> + struct GraphData; struct GraphViewportState; @@ -35,6 +37,7 @@ struct AppState { bool panel_legend = true; bool panel_inspector = true; bool panel_stats = true; + bool panel_viewport = true; bool show_filters_modal = false; bool show_open_modal = false; @@ -47,6 +50,24 @@ struct AppState { // Labels overlay bool labels_enabled = true; + + // Path activo de operations.db (para CRUD desde toolbar / contextmenu). + // main.cpp lo escribe tras cargar y los handlers lo leen. + std::string input_db_path; + + // Add-node toolbar input. + char add_buf[256] = {}; + + // Triggers de mutacion — main.cpp los procesa y dispara reload. + bool want_add_node = false; // commit del input add_buf + bool want_delete_node = false; // delete del nodo en ctx_node + bool want_duplicate_node = false; + bool want_change_type = false; // a ctx_new_type + int ctx_node = -1; // node_idx objetivo + char ctx_new_type[64] = {}; + + // Context menu state — popup global identificado por nombre. + bool ctx_open_request = false; // se setea en on_context_menu }; // Toolbar superior (Open file, Layout selector, Filters..., Fit, Save layout).