diff --git a/CMakeLists.txt b/CMakeLists.txt index 0cd459d..d52419e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,4 +1,5 @@ -# SQLite3: use system library on Linux, vendored amalgamation on Windows cross-compile +# SQLite3: prefer the target created by the top-level CMakeLists. If running +# stand-alone (without the parent project), fall back to building the amalgamation. find_package(SQLite3 QUIET) if(NOT SQLite3_FOUND AND NOT TARGET sqlite3_vendored) # Build from amalgamation @@ -10,7 +11,6 @@ if(NOT SQLite3_FOUND AND NOT TARGET sqlite3_vendored) SQLITE_ENABLE_FTS5 SQLITE_ENABLE_JSON1 ) - # Alias so we can use the same target name add_library(SQLite::SQLite3 ALIAS sqlite3_vendored) endif() @@ -27,13 +27,21 @@ add_imgui_app(registry_dashboard ${CMAKE_SOURCE_DIR}/functions/viz/sparkline.cpp ${CMAKE_SOURCE_DIR}/functions/core/dashboard_panel.cpp ${CMAKE_SOURCE_DIR}/functions/core/dashboard_grid.cpp - ${CMAKE_SOURCE_DIR}/functions/core/fps_overlay.cpp + # fps_overlay y tokens viven en fn_framework ${CMAKE_SOURCE_DIR}/functions/core/fullscreen_window.cpp - # Design tokens + primitives (fase 1 y 2 del plan del dashboard) - ${CMAKE_SOURCE_DIR}/functions/core/tokens.cpp ${CMAKE_SOURCE_DIR}/functions/core/badge.cpp ${CMAKE_SOURCE_DIR}/functions/core/empty_state.cpp ${CMAKE_SOURCE_DIR}/functions/core/page_header.cpp + # Fase 3: UI primitives (projects + reindex + add modals) + ${CMAKE_SOURCE_DIR}/functions/core/button.cpp + ${CMAKE_SOURCE_DIR}/functions/core/icon_button.cpp + ${CMAKE_SOURCE_DIR}/functions/core/toolbar.cpp + ${CMAKE_SOURCE_DIR}/functions/core/modal_dialog.cpp + ${CMAKE_SOURCE_DIR}/functions/core/text_input.cpp + ${CMAKE_SOURCE_DIR}/functions/core/select.cpp + ${CMAKE_SOURCE_DIR}/functions/core/toast.cpp + ${CMAKE_SOURCE_DIR}/functions/core/process_runner.cpp + ${CMAKE_SOURCE_DIR}/functions/core/tree_view.cpp ) target_include_directories(registry_dashboard PRIVATE diff --git a/app.md b/app.md index a09804e..a3256ef 100644 --- a/app.md +++ b/app.md @@ -85,3 +85,43 @@ cd cpp && cmake -B build/windows -S . -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw-w6 - Por defecto intenta conectar a sqlite_api en `http://127.0.0.1:8484`. Si falla, usa SQLite directo. - SQLite compilado estaticamente en Windows via amalgamation vendoreada. En Linux usa libsqlite3 del sistema. - cpp-httplib usa sockets nativos (no OpenSSL) — solo HTTP, no HTTPS. + +## Estado actual + +### Fase — actions bar + projects tab + Add modal `[done 2026-04-25]` + +Cambios estructurales (requieren `sqlite_api` v0.2 con endpoints de mutacion): + +- **Actions bar** en el page header (`fn_ui::toolbar`): `Reindex` (Primary) → `http_post_reindex` via `process_runner`; `+ Add` (Secondary) → abre modal; `Reload` (Subtle) → re-fetch via `UserData` flag; `toast_inbox_button` con badge. +- **Modal Add** (`modal_dialog`): `select` para Kind (App/Analysis/Vault), `select` de proyecto (obligatorio para Vault), `text_input` Name + Description + campos especificos. `process_runner` para el POST. Toast de exito/error + reload al completar. +- **Tab Projects** (`tree_view` + tabs): columna izquierda con proyectos + entrada "(orphans)"; columna derecha con detalle nested (Apps/Analysis/Vaults). Click dispara `load_project_detail_http`. + +`RegistryData` gana `projects[]`, `orphan_apps`, `orphan_analyses`, `orphan_vaults`. Tipos nuevos `ProjectRow`, `VaultRow`, `ProjectDetail`. Layer `data_http.cpp` gana `load_projects_http`, `load_project_detail_http`, `http_post_{reindex,add_app,add_analysis,add_vault}`. + +`AnalysisRow` gana campo `lang` para coherencia con la tabla `analysis` (la query SQL pasa de `SELECT id,name,domain,description` a `SELECT id,name,lang,domain,description`). + +`views_set_api_url(url)` invocado desde `main.cpp` para que las vistas puedan disparar mutaciones. + +### Bug fixes operativos `[done 2026-04-25]` + +- **Vibracion al redimensionar**: `fullscreen_window` v0.2 (NoScrollbar), altura de charts fija 260 px, `kpi_card` v1.2 (78 px + scale 1.4 + NoScrollbar). Ver `project.md` para detalle. +- **HTTP POST timeout 5 ms en Windows**: `http_client.cpp::request()` usaba `struct timeval` en `setsockopt(SO_RCVTIMEO)`, que Windows interpreta como `DWORD` ms → 5 ms efectivos. Fix: rama `_WIN32` con `DWORD timeout_ms = timeout_sec * 1000`. `wsa_init` envuelto en `std::call_once`. +- **Mensajes de toast vacios**: `post_json` ahora siempre escribe en `out_body` (extrae `output` del JSON en exito; sintetiza `"connect() failed to host:port (err=N)"` con codigo Winsock en error de conexion). Em dash sustituido por ASCII `:` para evitar render como `?` en fuentes sin ese codepoint. +- **Inbox popup en otra pantalla**: `toast_inbox_button` antes usaba posicion calculada con `btn_pos.x - 332` que podia caer fuera del `WorkRect` del viewport principal; con `viewports = true` ImGui lo movia a otra ventana del OS. Fix: clamp al `WorkRect`, anclar con `ImGuiCond_Appearing` (no `Always`), `SetNextWindowViewport(vp->ID)`. + +### Toolchain MinGW para Windows `[importante]` + +El cross-compile pasa de thread model `win32` a `posix` (`x86_64-w64-mingw32-g++-posix`) para que `std::mutex`/`std::thread` funcionen — necesario para `process_runner` y `toast`. Linker: `-static-libgcc -static-libstdc++ -static -lwinpthread`. Configurado en `cpp/toolchains/mingw-w64.cmake`. + +### Lo siguiente que pega + +- Filtros del Roadmap: el `select` de proyecto del modal Add ya prueba que filtrar por proyecto es trivial; aplicar mismo patron a las tablas Apps/Analysis/Types. +- Detalles al click: cuando un row de la tabla Apps (o Functions) se selecciona, abrir un panel lateral con metadata + boton "Open in editor" (segun OS, `xdg-open`/`explorer`). +- Integracion FTS5: el endpoint `/api/databases/registry/fts` ya existe; falta cablearlo desde la actions bar como `text_input` con resultados live. + +## Notas — Settings menubar (sesion 2026-04-25) + +- `render()` ahora llama `fn_ui::app_menubar(nullptr, 0, nullptr)` al inicio para exponer el item `Settings...` en la MainMenuBar. La app no tiene paneles toggleables ni layouts propios, asi que solo aparece Settings. +- El usuario puede cambiar fuente (DroidSans/Karla/Roboto/Cousine) y tamaño (10..32 px) en runtime, y togglear el FPS overlay. Persistencia en `app_settings.ini` junto al `registry_dashboard.exe`. +- `CMakeLists.txt` limpiado: `fps_overlay.cpp` y `tokens.cpp` ya viven en `fn_framework` — no listarlos explicitamente o el linker da multiple-definition. +- 5 TTFs (Karla/Roboto/DroidSans/Cousine/Tabler) copiadas junto al exe via `add_imgui_app` post-build. diff --git a/data.cpp b/data.cpp index 6c3ca29..d2a0345 100644 --- a/data.cpp +++ b/data.cpp @@ -192,7 +192,7 @@ bool load_registry_data(const char* db_path, RegistryData& out) { // --- Apps --- out.apps.clear(); query(db, - "SELECT id, name, lang, domain, description, framework FROM apps ORDER BY name", + "SELECT id, name, lang, domain, description, framework, repo_url, dir_path FROM apps ORDER BY name", [&](sqlite3_stmt* s) { AppRow r; r.id = col_str(s, 0); @@ -201,19 +201,22 @@ bool load_registry_data(const char* db_path, RegistryData& out) { r.domain = col_str(s, 3); r.description = col_str(s, 4); r.framework = col_str(s, 5); + r.repo_url = col_str(s, 6); + r.dir_path = col_str(s, 7); out.apps.push_back(std::move(r)); }); // --- Analysis --- out.analyses.clear(); query(db, - "SELECT id, name, domain, description FROM analysis ORDER BY name", + "SELECT id, name, lang, domain, description FROM analysis ORDER BY name", [&](sqlite3_stmt* s) { AnalysisRow r; r.id = col_str(s, 0); r.name = col_str(s, 1); - r.domain = col_str(s, 2); - r.description = col_str(s, 3); + r.lang = col_str(s, 2); + r.domain = col_str(s, 3); + r.description = col_str(s, 4); out.analyses.push_back(std::move(r)); }); diff --git a/data.h b/data.h index fc86959..abb9a72 100644 --- a/data.h +++ b/data.h @@ -55,11 +55,14 @@ struct AppRow { std::string domain; std::string description; std::string framework; + std::string repo_url; + std::string dir_path; }; struct AnalysisRow { std::string id; std::string name; + std::string lang; std::string domain; std::string description; }; @@ -73,6 +76,32 @@ struct TypeRow { std::string description; }; +struct VaultRow { + std::string id; + std::string name; + std::string path; + std::string description; + bool symlink = false; +}; + +struct ProjectRow { + std::string id; + std::string name; + std::string description; + int apps_count = 0; + int analyses_count = 0; + int vaults_count = 0; +}; + +struct ProjectDetail { + std::string id; // "" si no hay seleccion; "orphans" para huerfanas + std::string name; + std::string description; + std::vector apps; + std::vector analyses; + std::vector vaults; +}; + // All data loaded from registry.db in one shot struct RegistryData { RegistryStats stats; @@ -84,6 +113,10 @@ struct RegistryData { std::vector apps; std::vector analyses; std::vector types; + std::vector projects; + int orphan_apps = 0; + int orphan_analyses = 0; + int orphan_vaults = 0; // For chart data (populated by prepare_chart_data) std::vector lang_labels; diff --git a/data_http.cpp b/data_http.cpp index 4a74c70..5df87c6 100644 --- a/data_http.cpp +++ b/data_http.cpp @@ -135,25 +135,27 @@ bool load_registry_data_http(const std::string& api_url, RegistryData& out) { // --- Apps --- out.apps.clear(); - j = api_query(cli, "SELECT id, name, lang, domain, description, framework FROM apps ORDER BY name"); + j = api_query(cli, "SELECT id, name, lang, domain, description, framework, repo_url, dir_path FROM apps ORDER BY name"); if (!j.is_null() && j.contains("rows")) { for (auto& row : j["rows"]) { AppRow r; r.id = extract_str(row, 0); r.name = extract_str(row, 1); r.lang = extract_str(row, 2); r.domain = extract_str(row, 3); r.description = extract_str(row, 4); r.framework = extract_str(row, 5); + r.repo_url = extract_str(row, 6); r.dir_path = extract_str(row, 7); out.apps.push_back(std::move(r)); } } // --- Analysis --- out.analyses.clear(); - j = api_query(cli, "SELECT id, name, domain, description FROM analysis ORDER BY name"); + j = api_query(cli, "SELECT id, name, lang, domain, description FROM analysis ORDER BY name"); if (!j.is_null() && j.contains("rows")) { for (auto& row : j["rows"]) { AnalysisRow r; r.id = extract_str(row, 0); r.name = extract_str(row, 1); - r.domain = extract_str(row, 2); r.description = extract_str(row, 3); + r.lang = extract_str(row, 2); r.domain = extract_str(row, 3); + r.description = extract_str(row, 4); out.analyses.push_back(std::move(r)); } } @@ -172,5 +174,241 @@ bool load_registry_data_http(const std::string& api_url, RegistryData& out) { } out.prepare_chart_data(); + + // Best-effort: projects (no fatal si falla) + load_projects_http(api_url, out); + return true; } + +// --------------------------------------------------------------------------- +// Projects endpoints +// --------------------------------------------------------------------------- + +bool load_projects_http(const std::string& api_url, RegistryData& out) { + std::string host; int port; + if (!parse_url(api_url, host, port)) return false; + + HttpClient cli(host, port); + auto res = cli.get("/api/projects"); + if (!res.ok()) { + fprintf(stderr, "[http] GET /api/projects failed: %d\n", res.status); + return false; + } + auto j = json::parse(res.body, nullptr, false); + if (j.is_null()) return false; + + out.projects.clear(); + if (j.contains("projects") && j["projects"].is_array()) { + for (auto& p : j["projects"]) { + ProjectRow r; + if (p.contains("id") && p["id"].is_string()) r.id = p["id"]; + if (p.contains("name") && p["name"].is_string()) r.name = p["name"]; + if (p.contains("description") && p["description"].is_string()) r.description = p["description"]; + if (p.contains("apps_count") && p["apps_count"].is_number()) r.apps_count = p["apps_count"].get(); + if (p.contains("analyses_count") && p["analyses_count"].is_number()) r.analyses_count = p["analyses_count"].get(); + if (p.contains("vaults_count") && p["vaults_count"].is_number()) r.vaults_count = p["vaults_count"].get(); + out.projects.push_back(std::move(r)); + } + } + if (j.contains("orphans") && j["orphans"].is_object()) { + auto& o = j["orphans"]; + if (o.contains("apps") && o["apps"].is_number()) out.orphan_apps = o["apps"].get(); + if (o.contains("analyses") && o["analyses"].is_number()) out.orphan_analyses = o["analyses"].get(); + if (o.contains("vaults") && o["vaults"].is_number()) out.orphan_vaults = o["vaults"].get(); + } + return true; +} + +bool load_project_detail_http(const std::string& api_url, + const std::string& id, + ProjectDetail& out) { + std::string host; int port; + if (!parse_url(api_url, host, port)) return false; + + HttpClient cli(host, port); + auto res = cli.get("/api/projects/" + id); + if (!res.ok()) { + fprintf(stderr, "[http] GET /api/projects/%s failed: %d\n", id.c_str(), res.status); + return false; + } + auto j = json::parse(res.body, nullptr, false); + if (j.is_null()) return false; + + out = ProjectDetail{}; + out.id = id; + if (j.contains("project") && j["project"].is_object()) { + auto& p = j["project"]; + if (p.contains("name") && p["name"].is_string()) out.name = p["name"]; + if (p.contains("description") && p["description"].is_string()) out.description = p["description"]; + } + + auto read_rows = [](const json& section) -> std::pair, std::vector>> { + std::vector cols; + std::vector> rows; + if (!section.is_object()) return {cols, rows}; + if (section.contains("columns") && section["columns"].is_array()) + for (auto& c : section["columns"]) + cols.push_back(c.is_string() ? c.get() : ""); + if (section.contains("rows") && section["rows"].is_array()) { + for (auto& row : section["rows"]) { + std::vector cells; + for (auto& v : row) { + if (v.is_string()) cells.push_back(v.get()); + else if (v.is_number_integer()) cells.push_back(std::to_string(v.get())); + else if (v.is_null()) cells.push_back(""); + else cells.push_back(v.dump()); + } + rows.push_back(std::move(cells)); + } + } + return {cols, rows}; + }; + + // apps: [id, name, lang, domain, framework, description, dir_path] + if (j.contains("apps")) { + auto [cols, rows] = read_rows(j["apps"]); + for (auto& r : rows) { + AppRow a; + if (r.size() > 0) a.id = r[0]; + if (r.size() > 1) a.name = r[1]; + if (r.size() > 2) a.lang = r[2]; + if (r.size() > 3) a.domain = r[3]; + if (r.size() > 4) a.framework = r[4]; + if (r.size() > 5) a.description = r[5]; + out.apps.push_back(std::move(a)); + } + } + // analyses: [id, name, lang, domain, description, dir_path] + if (j.contains("analyses")) { + auto [cols, rows] = read_rows(j["analyses"]); + for (auto& r : rows) { + AnalysisRow a; + if (r.size() > 0) a.id = r[0]; + if (r.size() > 1) a.name = r[1]; + if (r.size() > 2) a.lang = r[2]; + if (r.size() > 3) a.domain = r[3]; + if (r.size() > 4) a.description = r[4]; + out.analyses.push_back(std::move(a)); + } + } + // vaults: [id, name, path, symlink, description, tags] + if (j.contains("vaults")) { + auto [cols, rows] = read_rows(j["vaults"]); + for (auto& r : rows) { + VaultRow v; + if (r.size() > 0) v.id = r[0]; + if (r.size() > 1) v.name = r[1]; + if (r.size() > 2) v.path = r[2]; + if (r.size() > 3) v.symlink = (r[3] == "1"); + if (r.size() > 4) v.description = r[4]; + out.vaults.push_back(std::move(v)); + } + } + return true; +} + +// --------------------------------------------------------------------------- +// Mutation endpoints +// --------------------------------------------------------------------------- + +static bool post_json(const std::string& api_url, const std::string& path, + const json& body, std::string& out_body) { + std::string host; int port; + if (!parse_url(api_url, host, port)) { + out_body = "invalid API URL: " + api_url; + return false; + } + HttpClient cli(host, port); + auto res = cli.post(path, body.dump(), "application/json"); + + // Mensaje util para el toast: si OK, intenta sacar "output" del JSON. + // Si error, incluye status + error del body si existe. + if (res.ok()) { + auto j = json::parse(res.body, nullptr, false); + if (!j.is_null()) { + if (j.contains("output") && j["output"].is_string()) + out_body = j["output"].get(); + else if (j.contains("ok") && j["ok"].is_boolean()) + out_body = j["ok"].get() ? "OK" : "failed"; + else + out_body = res.body; + } else { + out_body = res.body.empty() ? "OK" : res.body; + } + return true; + } + + // Error path con ASCII (la fuente puede no tener em dash). Para status=0 + // el http_client ya ha escrito un diagnostico de connect() en res.body. + if (res.status == 0) { + out_body = res.body.empty() + ? "connection failed (is sqlite_api running?)" + : res.body; + return false; + } + + auto j = json::parse(res.body, nullptr, false); + std::string detail; + if (!j.is_null() && j.contains("error") && j["error"].is_string()) + detail = j["error"].get(); + else if (!res.body.empty()) + detail = res.body; + + char buf[64]; + std::snprintf(buf, sizeof(buf), "HTTP %d: ", res.status); + out_body = std::string(buf) + detail; + return false; +} + +bool http_post_reindex(const std::string& api_url, std::string& out_body) { + return post_json(api_url, "/api/reindex", json::object(), out_body); +} + +bool http_post_add_app(const std::string& api_url, + const std::string& name, const std::string& lang, + const std::string& domain, const std::string& project, + const std::string& description, + std::string& out_body) { + json b; + b["name"] = name; + b["lang"] = lang; + b["domain"] = domain; + b["project"] = project; + b["description"] = description; + return post_json(api_url, "/api/add/app", b, out_body); +} + +bool http_post_add_analysis(const std::string& api_url, + const std::string& name, const std::string& project, + const std::string& packages_csv, + const std::string& description, + std::string& out_body) { + json b; + b["name"] = name; + b["project"] = project; + b["description"] = description; + // Packages como array. Split CSV. + json pkgs = json::array(); + std::string cur; + for (char c : packages_csv) { + if (c == ',' || c == ' ') { + if (!cur.empty()) { pkgs.push_back(cur); cur.clear(); } + } else cur.push_back(c); + } + if (!cur.empty()) pkgs.push_back(cur); + b["packages"] = pkgs; + return post_json(api_url, "/api/add/analysis", b, out_body); +} + +bool http_post_add_vault(const std::string& api_url, + const std::string& name, const std::string& project, + const std::string& path, const std::string& description, + std::string& out_body) { + json b; + b["name"] = name; + b["project"] = project; + b["path"] = path; + b["description"] = description; + return post_json(api_url, "/api/add/vault", b, out_body); +} diff --git a/data_http.h b/data_http.h index 4f9d022..a96043d 100644 --- a/data_http.h +++ b/data_http.h @@ -7,3 +7,31 @@ // api_url should be like "http://127.0.0.1:8484". // Returns true on success. bool load_registry_data_http(const std::string& api_url, RegistryData& out); + +// Load projects list (con conteos) y huerfanas. No destruye el resto de +// campos de out, solo setea projects + orphan_*. +bool load_projects_http(const std::string& api_url, RegistryData& out); + +// Load detalle de un proyecto (apps/analyses/vaults). Si id=="orphans" +// devuelve las entidades sin project_id. +bool load_project_detail_http(const std::string& api_url, + const std::string& id, + ProjectDetail& out); + +// Operaciones de mutacion (thread-safe porque http_post ya lo es). +// Devuelven el body de respuesta en `out_body`. true si HTTP status 2xx. +bool http_post_reindex(const std::string& api_url, std::string& out_body); +bool http_post_add_app(const std::string& api_url, + const std::string& name, const std::string& lang, + const std::string& domain, const std::string& project, + const std::string& description, + std::string& out_body); +bool http_post_add_analysis(const std::string& api_url, + const std::string& name, const std::string& project, + const std::string& packages_csv, + const std::string& description, + std::string& out_body); +bool http_post_add_vault(const std::string& api_url, + const std::string& name, const std::string& project, + const std::string& path, const std::string& description, + std::string& out_body); diff --git a/http_client.cpp b/http_client.cpp index 1e12dc3..5d7facc 100644 --- a/http_client.cpp +++ b/http_client.cpp @@ -8,15 +8,19 @@ #ifdef _WIN32 #include #include +#include #pragma comment(lib, "ws2_32.lib") +// std::call_once para evitar race condition si hay peticiones simultaneas +// desde multiples threads (main + runners). +static std::once_flag g_wsa_once; +static bool g_wsa_ok = false; static bool wsa_init() { - static bool done = false; - if (!done) { + std::call_once(g_wsa_once, []() { WSADATA wsa; - done = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0); - } - return done; + g_wsa_ok = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0); + }); + return g_wsa_ok; } typedef SOCKET sock_t; #define SOCK_INVALID INVALID_SOCKET @@ -64,12 +68,19 @@ HttpResponse HttpClient::request(const std::string& method, const std::string& p return resp; } - // Set timeout + // Timeout — Windows y POSIX usan formatos distintos para SO_{RCV,SND}TIMEO. + // Windows: DWORD milisegundos. POSIX: struct timeval. +#ifdef _WIN32 + DWORD timeout_ms = static_cast(timeout_sec_ * 1000); + setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms)); + setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms)); +#else struct timeval tv; tv.tv_sec = timeout_sec_; tv.tv_usec = 0; setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv)); setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv)); +#endif // Connect struct sockaddr_in addr; @@ -79,7 +90,14 @@ HttpResponse HttpClient::request(const std::string& method, const std::string& p addr.sin_addr.s_addr = inet_addr(host_.c_str()); if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) { + int err = SOCK_ERR; SOCK_CLOSE(sock); + // Reportamos el errno/WSAError en el body para que el toast sea util. + char buf[128]; + std::snprintf(buf, sizeof(buf), + "connect() failed to %s:%d (err=%d)", + host_.c_str(), port_, err); + resp.body = buf; return resp; } diff --git a/main.cpp b/main.cpp index 12c308d..c6a88ca 100644 --- a/main.cpp +++ b/main.cpp @@ -1,6 +1,8 @@ #include "app_base.h" #include "imgui.h" #include "core/fullscreen_window.h" +#include "core/app_menubar.h" +#include "core/app_about.h" #include "data.h" #include "data_http.h" #include "views.h" @@ -41,6 +43,9 @@ static void reload_data() { } static void render() { + // MainMenuBar (solo Settings — el dashboard no expone paneles toggleables) + fn_ui::app_menubar(nullptr, 0, nullptr); + if (ImGui::GetIO().UserData != nullptr) { ImGui::GetIO().UserData = nullptr; reload_data(); @@ -107,6 +112,18 @@ int main(int argc, char** argv) { if (g_db_path.empty()) g_db_path = db_candidates.back(); } + // Compartir el API URL con las vistas (para reindex/add desde la toolbar) + views_set_api_url(g_api_url); + + // Info de la ventana About (submenu Settings → About...) + fn_ui::about_window_set_info( + "fn_registry Dashboard", + "0.2.0", + "Dashboard ImGui para visualizar el estado del fn_registry. " + "Consume datos via sqlite_api HTTP (fallback a SQLite directo). " + "KPIs, charts, tablas, desglose por lenguaje/dominio/pureza." + ); + reload_data(); return fn::run_app( diff --git a/views.cpp b/views.cpp index 4fc2ec7..a955f5a 100644 --- a/views.cpp +++ b/views.cpp @@ -1,4 +1,6 @@ #include "views.h" +#include "data_http.h" +#include #include "imgui.h" #include "implot.h" @@ -15,10 +17,42 @@ #include "core/page_header.h" #include "core/empty_state.h" #include "core/badge.h" +#include "core/button.h" +#include "core/icon_button.h" +#include "core/toolbar.h" +#include "core/modal_dialog.h" +#include "core/text_input.h" +#include "core/select.h" +#include "core/toast.h" +#include "core/process_runner.h" +#include "core/tree_view.h" #include +#include #include +// --------------------------------------------------------------------------- +// Shared state +// --------------------------------------------------------------------------- + +static std::string g_api_url; +static fn_ui::ProcessRunner g_reindex_runner; +static fn_ui::ProcessRunner g_add_runner; + +// Add modal state +enum class AddKind : int { App = 0, Analysis = 1, Vault = 2 }; +static bool g_show_add = false; +static int g_add_kind_idx = static_cast(AddKind::App); +static int g_add_project_idx = -1; // -1 = (none / sin project) +static int g_add_lang_idx = 0; +static int g_add_domain_idx = 0; +static char g_add_name[128] = {}; +static char g_add_desc[256] = {}; +static char g_add_packages[256] = {}; +static char g_add_vault_path[512] = {}; + +void views_set_api_url(const std::string& url) { g_api_url = url; } + static std::vector to_cstr(const std::vector& v) { std::vector out; out.reserve(v.size()); @@ -26,15 +60,20 @@ static std::vector to_cstr(const std::vector& v) { return out; } +static void trigger_reload() { + ImGui::GetIO().UserData = reinterpret_cast(1); +} + +// --------------------------------------------------------------------------- +// KPI row +// --------------------------------------------------------------------------- + void draw_kpi_row(const RegistryStats& stats) { float tested_pct = stats.total_functions > 0 ? 100.0f * stats.tested_functions / stats.total_functions : 0.0f; float pure_pct = stats.total_functions > 0 ? 100.0f * stats.pure_functions / stats.total_functions : 0.0f; - // ImGui::BeginTable da celdas con ancho constrained, algo que BeginGroup - // (dashboard_grid) no hace — necesario para que el BeginChild dentro de - // kpi_card ocupe exactamente la celda y no desborde. const ImGuiTableFlags flags = ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX; @@ -59,10 +98,10 @@ void draw_kpi_row(const RegistryStats& stats) { } } -// Chart panel con tamano FIJO (no AutoResizeY) para evitar el feedback loop -// con ImPlot que provocaba deslizamiento lateral de las barras y scrollbar -// intermitente. Usa los mismos tokens que dashboard_panel para consistencia -// visual, pero con tamano determinista. +// --------------------------------------------------------------------------- +// Charts +// --------------------------------------------------------------------------- + static bool chart_panel_begin(const char* title, const ImVec2& size) { using namespace fn_tokens; ImGui::PushStyleColor(ImGuiCol_ChildBg, colors::surface); @@ -88,13 +127,8 @@ static void chart_panel_end() { } void draw_charts(RegistryData& data, float height) { - // ImGui::BeginTable para reparto equitativo de ancho en 4 columnas y que - // cada chart_panel tenga ancho constrained via GetContentRegionAvail. const ImGuiTableFlags flags = ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX; - - // Altura util dentro del panel (height total - title row - separator - padding). - // El plot recibe exactamente esto, asi que no hay redimensionado recursivo. const float plot_h = height - 48.0f; if (ImGui::BeginTable("##chart_grid", 4, flags)) { @@ -110,7 +144,6 @@ void draw_charts(RegistryData& data, float height) { static_cast(labels.size()), 0.67f, plot_h); chart_panel_end(); } - ImGui::TableSetColumnIndex(1); { ImVec2 sz(ImGui::GetContentRegionAvail().x, height); @@ -121,7 +154,6 @@ void draw_charts(RegistryData& data, float height) { static_cast(labels.size()), 0.67f, plot_h); chart_panel_end(); } - ImGui::TableSetColumnIndex(2); { ImVec2 sz(ImGui::GetContentRegionAvail().x, height); @@ -132,7 +164,6 @@ void draw_charts(RegistryData& data, float height) { pie_chart("##purity", labels, values, 2); chart_panel_end(); } - ImGui::TableSetColumnIndex(3); { ImVec2 sz(ImGui::GetContentRegionAvail().x, height); @@ -143,11 +174,14 @@ void draw_charts(RegistryData& data, float height) { static_cast(labels.size())); chart_panel_end(); } - ImGui::EndTable(); } } +// --------------------------------------------------------------------------- +// Tables +// --------------------------------------------------------------------------- + void draw_recent_functions(const std::vector& funcs) { if (funcs.empty()) { empty_state("( no data )", "No functions yet", @@ -174,11 +208,11 @@ void draw_recent_functions(const std::vector& funcs) { void draw_apps_list(const std::vector& apps) { if (apps.empty()) { empty_state("( no data )", "No apps registered", - "Clone apps with 'fn app clone ' or run 'fn sync'"); + "Use the + Add button above or run 'fn sync'"); return; } - const char* headers[] = {"Name", "Lang", "Domain", "Framework", "Description"}; - constexpr int cols = 5; + const char* headers[] = {"Name", "Lang", "Domain", "Framework", "Git", "Description"}; + constexpr int cols = 6; std::vector cell_strings; cell_strings.reserve(apps.size() * cols); for (auto& a : apps) { @@ -186,6 +220,24 @@ void draw_apps_list(const std::vector& apps) { cell_strings.push_back(a.lang); cell_strings.push_back(a.domain); cell_strings.push_back(a.framework); + // Indicador de git: tiene repo remoto (gitea), solo local, o ninguno. + // - "remote": repo_url poblado en el frontmatter del app.md + // - "local": hay .git/ en dir_path pero sin repo_url + // - "-": ni .git ni repo_url + std::string git_status; + if (!a.repo_url.empty()) { + git_status = "remote"; + } else if (!a.dir_path.empty()) { + std::error_code ec; + if (std::filesystem::exists(std::filesystem::path(a.dir_path) / ".git", ec)) { + git_status = "local"; + } else { + git_status = "-"; + } + } else { + git_status = "-"; + } + cell_strings.push_back(git_status); cell_strings.push_back(a.description); } auto cells = to_cstr(cell_strings); @@ -195,15 +247,16 @@ void draw_apps_list(const std::vector& apps) { void draw_analysis_list(const std::vector& analyses) { if (analyses.empty()) { empty_state("( no data )", "No analysis yet", - "Create one with 'fn run init_jupyter_analysis '"); + "Use the + Add button above with kind = Analysis"); return; } - const char* headers[] = {"Name", "Domain", "Description"}; - constexpr int cols = 3; + const char* headers[] = {"Name", "Lang", "Domain", "Description"}; + constexpr int cols = 4; std::vector cell_strings; cell_strings.reserve(analyses.size() * cols); for (auto& a : analyses) { cell_strings.push_back(a.name); + cell_strings.push_back(a.lang); cell_strings.push_back(a.domain); cell_strings.push_back(a.description); } @@ -232,45 +285,337 @@ void draw_types_list(const std::vector& types) { table_view("##types", headers, cols, cells.data(), static_cast(types.size())); } -void draw_dashboard(RegistryData& data) { - // Aplicar tema una sola vez por vida de la app. - static bool theme_applied = false; - if (!theme_applied) { - fn_tokens::apply_dark_theme(); - theme_applied = true; +// --------------------------------------------------------------------------- +// Projects view +// --------------------------------------------------------------------------- + +static std::string g_selected_project_id = ""; +static ProjectDetail g_project_detail; + +static void refresh_project_detail() { + g_project_detail = ProjectDetail{}; + if (g_selected_project_id.empty() || g_api_url.empty()) return; + load_project_detail_http(g_api_url, g_selected_project_id, g_project_detail); +} + +void draw_projects_list(RegistryData& data) { + if (data.projects.empty() && data.orphan_apps == 0 + && data.orphan_analyses == 0 && data.orphan_vaults == 0) { + empty_state("( no data )", "No projects yet", + "Create a project under projects/{name}/ with project.md and reindex"); + return; } + // Dos columnas: izquierda arbol, derecha detalle. + const ImGuiTableFlags flags = ImGuiTableFlags_Resizable + | ImGuiTableFlags_SizingStretchProp; + if (!ImGui::BeginTable("##proj_layout", 2, flags)) return; + + ImGui::TableSetupColumn("tree", ImGuiTableColumnFlags_WidthStretch, 1.0f); + ImGui::TableSetupColumn("detail", ImGuiTableColumnFlags_WidthStretch, 2.5f); + ImGui::TableNextRow(); + + // --- Left column: tree --- + ImGui::TableSetColumnIndex(0); + ImGui::BeginChild("##proj_tree", ImVec2(0, 0), ImGuiChildFlags_Borders); + for (const auto& p : data.projects) { + bool sel = (g_selected_project_id == p.id); + char label[256]; + std::snprintf(label, sizeof(label), "%s [%d/%d/%d]", + p.name.c_str(), p.apps_count, p.analyses_count, p.vaults_count); + fn_ui::tree_leaf(p.id.c_str(), label, sel); + if (fn_ui::tree_node_clicked()) { + g_selected_project_id = p.id; + refresh_project_detail(); + } + } + // Orphans + if (data.orphan_apps + data.orphan_analyses + data.orphan_vaults > 0) { + bool sel = (g_selected_project_id == "orphans"); + char label[128]; + std::snprintf(label, sizeof(label), "(orphans) [%d/%d/%d]", + data.orphan_apps, data.orphan_analyses, data.orphan_vaults); + fn_ui::tree_leaf("orphans", label, sel); + if (fn_ui::tree_node_clicked()) { + g_selected_project_id = "orphans"; + refresh_project_detail(); + } + } + ImGui::EndChild(); + + // --- Right column: detail --- + ImGui::TableSetColumnIndex(1); + ImGui::BeginChild("##proj_detail", ImVec2(0, 0), ImGuiChildFlags_Borders); + if (g_selected_project_id.empty()) { + empty_state("\xe2\x86\x90", "Select a project", + "Click a project on the left to see its apps, analyses and vaults"); + } else { + const auto& d = g_project_detail; + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text); + if (!d.name.empty()) + ImGui::TextUnformatted(d.name.c_str()); + else + ImGui::TextUnformatted(g_selected_project_id.c_str()); + ImGui::PopStyleColor(); + if (!d.description.empty()) { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::TextWrapped("%s", d.description.c_str()); + ImGui::PopStyleColor(); + } + ImGui::Separator(); + + if (ImGui::BeginTabBar("##proj_tabs")) { + char tab_apps[64]; std::snprintf(tab_apps, sizeof(tab_apps), "Apps (%zu)", d.apps.size()); + char tab_analyses[64]; std::snprintf(tab_analyses, sizeof(tab_analyses), "Analysis (%zu)", d.analyses.size()); + char tab_vaults[64]; std::snprintf(tab_vaults, sizeof(tab_vaults), "Vaults (%zu)", d.vaults.size()); + + if (ImGui::BeginTabItem(tab_apps)) { + draw_apps_list(d.apps); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem(tab_analyses)) { + draw_analysis_list(d.analyses); + ImGui::EndTabItem(); + } + if (ImGui::BeginTabItem(tab_vaults)) { + if (d.vaults.empty()) { + empty_state("( no data )", "No vaults in this project", + "Use the + Add button with kind = Vault (requires a project)"); + } else { + const char* headers[] = {"Name", "Path", "Symlink", "Description"}; + std::vector cells; + cells.reserve(d.vaults.size() * 4); + for (auto& v : d.vaults) { + cells.push_back(v.name); + cells.push_back(v.path); + cells.push_back(v.symlink ? "yes" : "no"); + cells.push_back(v.description); + } + auto cp = to_cstr(cells); + table_view("##vaults", headers, 4, cp.data(), + static_cast(d.vaults.size())); + } + ImGui::EndTabItem(); + } + ImGui::EndTabBar(); + } + } + ImGui::EndChild(); + + ImGui::EndTable(); +} + +// --------------------------------------------------------------------------- +// Actions bar (Reindex + Add button) + Add modal +// --------------------------------------------------------------------------- + +static const char* kLangs[] = {"go", "py", "ts", "sh", "cpp"}; +static const char* kDomains[] = {"core", "infra", "finance", "datascience", + "cybersecurity", "shell", "tui", "pipelines", + "browser", "viz", "gfx", "notebook"}; + +static void submit_add() { + if (g_api_url.empty()) { + fn_ui::toast_push(fn_ui::ToastKind::Error, "API URL not set"); + return; + } + std::string name = g_add_name; + std::string desc = g_add_desc; + std::string project; + if (g_add_project_idx >= 0) { + // g_add_project_idx indexa la lista live de projects (resolved en el modal) + // guardada en g_project_ids_cache — ver draw_add_modal + } + + // Resolvemos project en draw_add_modal y lo pasamos via g_add_project_resolved + extern std::string g_add_project_resolved; + project = g_add_project_resolved; + + AddKind kind = static_cast(g_add_kind_idx); + std::string url = g_api_url; + + fn_ui::runner_trigger(g_add_runner, + [kind, url, name, desc, project, + lang_idx = g_add_lang_idx, domain_idx = g_add_domain_idx, + packages = std::string(g_add_packages), + vault_path = std::string(g_add_vault_path) + ](std::string& out) -> bool { + std::string body; + bool ok = false; + switch (kind) { + case AddKind::App: + ok = http_post_add_app(url, name, kLangs[lang_idx], + kDomains[domain_idx], project, desc, body); + break; + case AddKind::Analysis: + ok = http_post_add_analysis(url, name, project, packages, desc, body); + break; + case AddKind::Vault: + ok = http_post_add_vault(url, name, project, vault_path, desc, body); + break; + } + out = body; + return ok; + }); +} + +std::string g_add_project_resolved = ""; + +static void draw_add_modal(RegistryData& data) { + if (!fn_ui::modal_dialog_begin("Add...", &g_show_add, ImVec2(460, 0))) { + fn_ui::modal_dialog_end(); + return; + } + + // Kind selector + const char* kinds[] = {"App", "Analysis", "Vault"}; + fn_ui::select("Kind", &g_add_kind_idx, kinds, 3); + + // Project selector (del registro vivo) + std::vector proj_labels; + std::vector proj_ids; + for (auto& p : data.projects) { + proj_labels.push_back(p.name); + proj_ids.push_back(p.id); + } + std::vector proj_cstr; + for (auto& s : proj_labels) proj_cstr.push_back(s.c_str()); + + AddKind kind = static_cast(g_add_kind_idx); + fn_ui::select("Project", &g_add_project_idx, proj_cstr.data(), + static_cast(proj_cstr.size()), + kind != AddKind::Vault /* vault obliga a project */); + g_add_project_resolved = (g_add_project_idx >= 0 && g_add_project_idx < (int)proj_ids.size()) + ? proj_ids[g_add_project_idx] : ""; + + fn_ui::text_input("Name", g_add_name, sizeof(g_add_name), + "snake_case, a-z0-9_"); + fn_ui::text_input("Description", g_add_desc, sizeof(g_add_desc)); + + // Campos especificos segun kind + if (kind == AddKind::App) { + fn_ui::select("Lang", &g_add_lang_idx, kLangs, 5); + fn_ui::select("Domain", &g_add_domain_idx, kDomains, 12); + } else if (kind == AddKind::Analysis) { + fn_ui::text_input("Packages (CSV)", g_add_packages, sizeof(g_add_packages), + "polars,scikit-learn,torch"); + } else if (kind == AddKind::Vault) { + fn_ui::text_input("Path (abs, opcional)", g_add_vault_path, sizeof(g_add_vault_path), + "/home/lucas/vaults/my_data"); + } + + ImGui::Dummy(ImVec2(0, fn_tokens::spacing::sm)); + + // Status del runner en curso + fn_ui::runner_status(g_add_runner, "Creating..."); + + // Detectar transicion running->done para cerrar y notificar + static fn_ui::RunnerState last_state = fn_ui::RunnerState::Idle; + fn_ui::RunnerState now = g_add_runner.state(); + if (last_state == fn_ui::RunnerState::Running + && (now == fn_ui::RunnerState::Success || now == fn_ui::RunnerState::Error)) { + const bool ok = (now == fn_ui::RunnerState::Success); + fn_ui::toast_push(ok ? fn_ui::ToastKind::Success : fn_ui::ToastKind::Error, + ok ? "Created OK — reloading" : g_add_runner.message().c_str()); + if (ok) { + g_show_add = false; + g_add_name[0] = '\0'; + g_add_desc[0] = '\0'; + g_add_packages[0] = '\0'; + g_add_vault_path[0] = '\0'; + trigger_reload(); + } + } + last_state = now; + + ImGui::Separator(); + if (fn_ui::button("Cancel", fn_ui::ButtonVariant::Subtle)) g_show_add = false; + ImGui::SameLine(); + const bool disabled = g_add_runner.is_busy() || g_add_name[0] == '\0'; + if (disabled) ImGui::BeginDisabled(); + if (fn_ui::button("Create", fn_ui::ButtonVariant::Primary)) { + submit_add(); + } + if (disabled) ImGui::EndDisabled(); + + fn_ui::modal_dialog_end(); +} + +static void draw_actions_bar() { + if (g_api_url.empty()) return; + + fn_ui::toolbar_begin(); + if (fn_ui::button("Reindex", fn_ui::ButtonVariant::Primary) + && !g_reindex_runner.is_busy()) { + const std::string url = g_api_url; + fn_ui::runner_trigger(g_reindex_runner, [url](std::string& out) -> bool { + return http_post_reindex(url, out); + }); + } + ImGui::SameLine(); + if (fn_ui::button("+ Add", fn_ui::ButtonVariant::Secondary)) { + g_show_add = true; + g_add_runner.reset(); + } + ImGui::SameLine(); + if (fn_ui::button("Reload", fn_ui::ButtonVariant::Subtle)) { + trigger_reload(); + } + ImGui::SameLine(); + fn_ui::toast_inbox_button("##inbox"); + fn_ui::toolbar_end(); + + // Status del reindex debajo del toolbar + static fn_ui::RunnerState last_reindex_state = fn_ui::RunnerState::Idle; + fn_ui::RunnerState now = g_reindex_runner.state(); + if (now != fn_ui::RunnerState::Idle) { + fn_ui::runner_status(g_reindex_runner, "Reindexing..."); + } + if (last_reindex_state == fn_ui::RunnerState::Running + && (now == fn_ui::RunnerState::Success || now == fn_ui::RunnerState::Error)) { + const bool ok = (now == fn_ui::RunnerState::Success); + fn_ui::toast_push(ok ? fn_ui::ToastKind::Success : fn_ui::ToastKind::Error, + g_reindex_runner.message().c_str()); + if (ok) trigger_reload(); + } + last_reindex_state = now; +} + +// --------------------------------------------------------------------------- +// Main draw +// --------------------------------------------------------------------------- + +void draw_dashboard(RegistryData& data) { + // Tema aplicado por fn::run_app() (app_base.h, ThemeMode::FnDark default). + fps_overlay(); fullscreen_window_begin("##dashboard"); - // Subtitle con conteos — contexto rápido para el usuario char subtitle[128]; std::snprintf(subtitle, sizeof(subtitle), - "%d functions · %d types · %d apps · %d analyses", + "%d functions · %d types · %d apps · %d analyses · %zu projects", data.stats.total_functions, data.stats.total_types, - data.stats.total_apps, data.stats.total_analysis); + data.stats.total_apps, data.stats.total_analysis, + data.projects.size()); - // Header con acción Reload a la derecha page_header_begin("fn_registry Dashboard", subtitle); - ImGui::SameLine(ImGui::GetWindowWidth() - 120.0f); - if (ImGui::Button("Reload")) { - ImGui::GetIO().UserData = reinterpret_cast(1); - } page_header_end(); - // KPIs + draw_actions_bar(); + ImGui::Dummy(ImVec2(0, fn_tokens::spacing::sm)); + draw_kpi_row(data.stats); ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md)); - // Charts — altura FIJA en pixeles (no depende del resize de la ventana). - // Antes usabamos remaining*0.35, pero eso recalculaba todo el layout al - // redimensionar, provocando vibracion visible en los plots. constexpr float chart_h = 260.0f; draw_charts(data, chart_h); ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md)); - // Tables if (ImGui::BeginTabBar("##tables")) { + if (ImGui::BeginTabItem("Projects")) { + draw_projects_list(data); + ImGui::EndTabItem(); + } if (ImGui::BeginTabItem("Recent Functions")) { draw_recent_functions(data.recent_funcs); ImGui::EndTabItem(); @@ -290,5 +635,10 @@ void draw_dashboard(RegistryData& data) { ImGui::EndTabBar(); } + draw_add_modal(data); + fullscreen_window_end(); + + // Toasts encima de todo + fn_ui::toast_render(); } diff --git a/views.h b/views.h index 7347e54..572e800 100644 --- a/views.h +++ b/views.h @@ -1,10 +1,15 @@ #pragma once #include "data.h" +#include // Draw the full dashboard. Call every frame. void draw_dashboard(RegistryData& data); +// Called once from main.cpp after parsing --api to share the URL with the +// views (for triggering reindex/add mutations). +void views_set_api_url(const std::string& url); + // Individual views (called by draw_dashboard) void draw_kpi_row(const RegistryStats& stats); void draw_charts(RegistryData& data, float height = 250.0f); @@ -12,3 +17,4 @@ void draw_recent_functions(const std::vector& funcs); void draw_apps_list(const std::vector& apps); void draw_analysis_list(const std::vector& analyses); void draw_types_list(const std::vector& types); +void draw_projects_list(RegistryData& data);