// agents_dashboard — C++ ImGui frontend para gestionar agentes Matrix. // // Panels: // Connection — base_url + apikey input, Test button, SSE status LED // Agents — data table (id, status, uptime, msg_24h, actions) via data_table_cpp_viz // Logs — SSE tail buffer for /sse/agents/{id}/logs // Status Feed — SSE events from /sse/status (collapsible) // // Issue 0129 + 0131. Registry functions used: // http_request_cpp_core, http_get_json_cpp_core, // sse_client_cpp_core, data_table_cpp_viz, logger_cpp_core, // secret_store_cpp_infra #include #include "app_base.h" #include "core/panel_menu.h" #include "core/icons_tabler.h" #include "core/logger.h" #include "core/http_request.h" #include "core/http_get_json.h" #include "core/sse_client.h" #include "infra/secret_store.h" #include "nlohmann/json.hpp" // data_table_cpp_viz: render_grid_stage0 + declarative column specs (issue 0131) #if __has_include("viz/data_table_grid.h") # include "viz/data_table_grid.h" # define HAS_DATA_TABLE 1 #endif // SQLite (vendored via fn_framework) #include #include #include #include #include #include #include #include #include #include #include #ifdef _WIN32 # ifndef NOMINMAX # define NOMINMAX # endif # ifndef WIN32_LEAN_AND_MEAN # define WIN32_LEAN_AND_MEAN # endif # include #endif #include using json = nlohmann::json; using clk = std::chrono::steady_clock; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- static long long now_ms() { return std::chrono::duration_cast( clk::now().time_since_epoch()).count(); } static long long now_unix() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); } static std::string format_uptime(long long seconds) { if (seconds < 0) return "—"; if (seconds < 60) return std::to_string(seconds) + "s"; if (seconds < 3600) return std::to_string(seconds / 60) + "m"; if (seconds < 86400) return std::to_string(seconds / 3600) + "h"; return std::to_string(seconds / 86400) + "d"; } // --------------------------------------------------------------------------- // Data model // --------------------------------------------------------------------------- struct AgentRow { std::string id; std::string display_name; std::string status; // "running" | "stopped" | "crashed" | "unknown" long long uptime_s = 0; int msg_24h = 0; std::string error_msg; }; struct AppState { // Connection char base_url[512] = "https://agents.organic-machine.com"; char apikey_buf[256] = ""; // populated at startup from env OR `pass agentes/api-key` std::string apikey_source; // "env" | "pass" | "missing" bool connected = false; std::string connect_error; long long last_fetch_ms = 0; bool fetching = false; // Agents table std::mutex agents_mu; std::vector agents; std::string agents_error; long long agents_fetched_ms = 0; char filter_buf[128] = ""; // Logs panel char log_agent_id[128] = ""; std::mutex log_mu; std::deque log_lines; // ring buffer — max 5000 lines bool log_autoscroll = true; bool log_paused = false; fn_sse::Client log_sse; std::string log_sse_status; std::string log_sse_agent_connected; // which agent the current SSE is for // Status feed bool status_feed_open = true; std::mutex status_mu; std::deque status_events; // ring buffer — max 200 events fn_sse::Client status_sse; std::string status_sse_status; // DB sqlite3* db = nullptr; // Action feedback (for start/stop/restart results) std::mutex action_mu; std::string action_feedback; long long action_feedback_ts = 0; // Confirmation modals for destructive actions (clear_memory, delete_cache) std::string confirm_agent_id; // agent being confirmed std::string confirm_action; // "clear_memory" | "delete_cache" bool confirm_open = false; #ifdef HAS_DATA_TABLE // Per-panel data_table State for the agents grid data_table::State agents_tbl_state; #endif }; static AppState g_state; // --------------------------------------------------------------------------- // Database // --------------------------------------------------------------------------- static bool db_open(AppState& s) { if (s.db) return true; const char* path = fn::local_path("agents_dashboard.db"); if (sqlite3_open(path, &s.db) != SQLITE_OK) { fn_log::log_error("[db] open failed: %s", sqlite3_errmsg(s.db)); return false; } // Apply migrations embedded in source const char* migrations[] = { // 001_init.sql inlined "CREATE TABLE IF NOT EXISTS connections (" " id INTEGER PRIMARY KEY AUTOINCREMENT," " name TEXT NOT NULL DEFAULT 'default'," " base_url TEXT NOT NULL," " apikey_encrypted BLOB NOT NULL," " last_used INTEGER DEFAULT (strftime('%s','now'))" ");" "CREATE TABLE IF NOT EXISTS app_state (" " key TEXT PRIMARY KEY," " value TEXT" ");" "INSERT OR IGNORE INTO app_state (key,value) VALUES ('active_connection_id','');" "INSERT OR IGNORE INTO app_state (key,value) VALUES ('log_agent_id','');" "INSERT OR IGNORE INTO app_state (key,value) VALUES ('log_autoscroll','1');" "INSERT OR IGNORE INTO app_state (key,value) VALUES ('status_feed_open','1');", nullptr }; for (const char** m = migrations; *m; ++m) { char* errmsg = nullptr; if (sqlite3_exec(s.db, *m, nullptr, nullptr, &errmsg) != SQLITE_OK) { fn_log::log_warn("[db] migration warning: %s", errmsg ? errmsg : "?"); sqlite3_free(errmsg); } } return true; } // db_save_connection persists ONLY base_url. apikey lives in env var (sourced // from `pass agentes/api-key`), never on disk. static void db_save_connection(AppState& s) { if (!s.db) return; sqlite3_stmt* stmt = nullptr; const char* sql = "INSERT INTO connections (id, name, base_url, apikey_encrypted, last_used)" " VALUES (1, 'default', ?, x'00', strftime('%s','now'))" " ON CONFLICT(id) DO UPDATE SET" " base_url=excluded.base_url," " last_used=excluded.last_used;"; if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return; sqlite3_bind_text(stmt, 1, s.base_url, -1, SQLITE_TRANSIENT); sqlite3_step(stmt); sqlite3_finalize(stmt); fn_log::log_info("[db] base_url saved"); } // db_load_connection reads base_url only. apikey is sourced from env var. static void db_load_connection(AppState& s) { if (!s.db) return; sqlite3_stmt* stmt = nullptr; const char* sql = "SELECT base_url FROM connections WHERE id=1 LIMIT 1;"; if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return; if (sqlite3_step(stmt) == SQLITE_ROW) { const char* url = (const char*)sqlite3_column_text(stmt, 0); if (url && *url) snprintf(s.base_url, sizeof(s.base_url), "%s", url); } sqlite3_finalize(stmt); } // Helper: rstrip whitespace + control chars. static void rstrip_ctrl(std::string& s) { while (!s.empty() && (unsigned char)s.back() <= 0x20) s.pop_back(); } // fetch_apikey_via_pass runs `pass agentes/api-key | head -n1` and returns the // secret on stdout. On Windows the command runs INSIDE WSL via wsl.exe (pass // lives in the WSL user's GnuPG keychain). Returns empty string on failure // (pass not installed, GPG locked, entry missing). static std::string fetch_apikey_via_pass() { std::string out; #ifdef _WIN32 // Spawn: wsl.exe -e sh -c "pass agentes/api-key 2>/dev/null | head -n1" std::wstring cmdline = L"wsl.exe -e sh -c \"pass agentes/api-key 2>/dev/null | head -n1\""; SECURITY_ATTRIBUTES sa{}; sa.nLength = sizeof(sa); sa.bInheritHandle = TRUE; HANDLE rd = nullptr, wr = nullptr; if (!CreatePipe(&rd, &wr, &sa, 0)) return out; SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); STARTUPINFOW si{}; si.cb = sizeof(si); si.dwFlags = STARTF_USESTDHANDLES; si.hStdOutput = wr; si.hStdError = wr; si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); PROCESS_INFORMATION pi{}; std::wstring mutable_cmd = cmdline; // CreateProcessW needs writable buffer BOOL ok = CreateProcessW(nullptr, mutable_cmd.data(), nullptr, nullptr, TRUE, CREATE_NO_WINDOW, nullptr, nullptr, &si, &pi); CloseHandle(wr); if (!ok) { CloseHandle(rd); return out; } char buf[1024]; DWORD got = 0; while (ReadFile(rd, buf, sizeof(buf), &got, nullptr) && got > 0) out.append(buf, buf + got); CloseHandle(rd); WaitForSingleObject(pi.hProcess, 5000); // 5s max CloseHandle(pi.hProcess); CloseHandle(pi.hThread); #else FILE* p = popen("pass agentes/api-key 2>/dev/null | head -n1", "r"); if (p) { char buf[1024]; size_t n; while ((n = std::fread(buf, 1, sizeof(buf), p)) > 0) out.append(buf, n); pclose(p); } #endif rstrip_ctrl(out); // Sanity check: a 32-byte hex apikey is 64 chars. Reject anything shorter // than 16 (would catch error messages like "Error: ..."). if (out.size() < 16) out.clear(); return out; } // load_apikey loads the apikey into s.apikey_buf with two-tier fallback: // 1) AGENTS_API_KEY env var (apikey_source = "env") // 2) `pass agentes/api-key` (apikey_source = "pass") // 3) empty (apikey_source = "missing") // // This lets the app launch from the App Hub (or any double-click) without // the user having to inject the env var manually — the apikey is fetched // from the user's pass store on demand (GPG agent must be unlocked). static void load_apikey(AppState& s) { s.apikey_buf[0] = '\0'; s.apikey_source = "missing"; const char* k = std::getenv("AGENTS_API_KEY"); if (k && *k) { snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", k); size_t n = strlen(s.apikey_buf); while (n > 0 && (unsigned char)s.apikey_buf[n - 1] <= 0x20) s.apikey_buf[--n] = '\0'; if (n > 0) { s.apikey_source = "env"; return; } } std::string from_pass = fetch_apikey_via_pass(); if (!from_pass.empty()) { snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", from_pass.c_str()); s.apikey_source = "pass"; } } static void db_save_state(AppState& s, const char* key, const char* value) { if (!s.db) return; sqlite3_stmt* stmt = nullptr; const char* sql = "INSERT OR REPLACE INTO app_state (key, value) VALUES (?, ?);"; if (sqlite3_prepare_v2(s.db, sql, -1, &stmt, nullptr) != SQLITE_OK) return; sqlite3_bind_text(stmt, 1, key, -1, SQLITE_STATIC); sqlite3_bind_text(stmt, 2, value, -1, SQLITE_TRANSIENT); sqlite3_step(stmt); sqlite3_finalize(stmt); } // --------------------------------------------------------------------------- // HTTP helpers // --------------------------------------------------------------------------- // trim_url removes leading/trailing whitespace and control chars. // Defensive: paste-from-terminal often leaves CR/LF/spaces in the buffer, // which curl reports as "Bad hostname" (exit 3). static std::string trim_url(const std::string& in) { auto is_junk = [](unsigned char c) { return c <= 0x20 || c == 0x7F; }; size_t b = 0, e = in.size(); while (b < e && is_junk((unsigned char)in[b])) ++b; while (e > b && is_junk((unsigned char)in[e - 1])) --e; return in.substr(b, e - b); } static std::string make_url(const AppState& s, const std::string& path) { std::string base = trim_url(s.base_url); while (!base.empty() && base.back() == '/') base.pop_back(); return base + path; } // Parse agents JSON array from /agents endpoint. // Backend shape (agents_and_robots/internal/api/handlers.go v0.2): // { id, name, version, desc, enabled: bool, running: bool, pid: int, // instances: int, config_path, uptime_seconds: int64, messages_24h: int } static std::vector parse_agents(const std::string& body) { std::vector rows; auto j = json::parse(body, nullptr, false); if (j.is_discarded() || !j.is_array()) return rows; for (auto& a : j) { AgentRow r; r.id = a.value("id", ""); r.display_name = a.value("name", r.id); bool enabled = a.value("enabled", false); bool running = a.value("running", false); if (running) r.status = "running"; else if (!enabled) r.status = "disabled"; else r.status = "stopped"; // v0.2: real uptime_seconds and messages_24h from backend r.uptime_s = a.value("uptime_seconds", (long long)0); r.msg_24h = a.value("messages_24h", 0); rows.push_back(std::move(r)); } return rows; } // FN_DBG: stderr + flush. Survives crashes (fn_log buffers). #define FN_DBG(...) do { fprintf(stderr, "[DBG] " __VA_ARGS__); fputc('\n', stderr); fflush(stderr); } while(0) // Fetch agents in background thread static void fetch_agents_async(AppState& s) { FN_DBG("fetch_agents_async ENTER s.fetching=%d apikey_len=%zu", (int)s.fetching, strlen(s.apikey_buf)); if (s.fetching) { FN_DBG("fetch_agents_async SKIP already fetching"); return; } s.fetching = true; std::thread([&s]() { FN_DBG("fetch thread STARTED"); fn_http::Request req; req.method = "GET"; req.url = make_url(s, "/agents"); req.bearer_token = s.apikey_buf; req.timeout_ms = 8000; FN_DBG("fetch thread requesting url=%s bearer_len=%zu", req.url.c_str(), req.bearer_token.size()); auto res = fn_http::request(req); FN_DBG("fetch thread response status=%d err=[%s] body_len=%zu", res.status, res.error.c_str(), res.body.size()); { std::lock_guard lk(s.agents_mu); if (!res.error.empty()) { s.agents_error = "Transport error: " + res.error; } else if (res.status != 200) { s.agents_error = "HTTP " + std::to_string(res.status); } else { FN_DBG("fetch thread parsing body..."); s.agents = parse_agents(res.body); s.agents_error.clear(); s.agents_fetched_ms = now_ms(); FN_DBG("fetch thread parsed %zu rows", s.agents.size()); } } s.fetching = false; FN_DBG("fetch thread DONE"); }).detach(); FN_DBG("fetch_agents_async EXIT (thread detached)"); } // POST action to /agents/{id}/{action} static void agent_action(AppState& s, const std::string& agent_id, const std::string& action) { std::thread([&s, agent_id, action]() { fn_http::Request req; req.method = "POST"; req.url = make_url(s, "/agents/" + agent_id + "/" + action); req.bearer_token = s.apikey_buf; req.timeout_ms = 10000; auto res = fn_http::request(req); std::string fb; if (!res.error.empty()) { fb = "[" + action + " " + agent_id + "] error: " + res.error; } else if (res.status >= 200 && res.status < 300) { fb = "[" + action + " " + agent_id + "] OK"; } else { fb = "[" + action + " " + agent_id + "] HTTP " + std::to_string(res.status); } fn_log::log_info("%s", fb.c_str()); { std::lock_guard lk(s.action_mu); s.action_feedback = fb; s.action_feedback_ts = now_ms(); } // Refresh agents after action std::this_thread::sleep_for(std::chrono::milliseconds(500)); fetch_agents_async(s); }).detach(); } // --------------------------------------------------------------------------- // SSE connections // --------------------------------------------------------------------------- // Fetch historical log tail via REST before subscribing to SSE. // Returns synchronously; caller usually spawns it in a thread + starts SSE next. static void fetch_log_history(AppState& s, const std::string& agent_id, int n) { fn_http::Request req; req.method = "GET"; req.url = make_url(s, "/agents/" + agent_id + "/logs?n=" + std::to_string(n)); req.bearer_token = s.apikey_buf; req.timeout_ms = 5000; auto res = fn_http::request(req); if (!res.error.empty() || res.status != 200) { FN_DBG("fetch_log_history %s failed: status=%d err=%s", agent_id.c_str(), res.status, res.error.c_str()); return; } auto j = json::parse(res.body, nullptr, false); if (j.is_discarded()) return; // Endpoint returns {count, id, lines:[...]} (object) — not a raw array. const auto* arr = j.is_array() ? &j : j.contains("lines") ? &j["lines"] : nullptr; if (!arr || !arr->is_array()) return; std::lock_guard lk(s.log_mu); for (auto& line : *arr) { if (!line.is_string()) continue; s.log_lines.push_back(line.get()); while (s.log_lines.size() > 5000) s.log_lines.pop_front(); } FN_DBG("fetch_log_history %s loaded %zu lines", agent_id.c_str(), s.log_lines.size()); } static void start_log_sse(AppState& s, const std::string& agent_id) { s.log_sse.stop(); { std::lock_guard lk(s.log_mu); s.log_lines.clear(); s.log_sse_agent_connected = agent_id; } // Populate historical tail BEFORE subscribing so the user sees context // immediately instead of an empty panel until new lines appear. if (!agent_id.empty()) { std::thread([&s, agent_id]() { fetch_log_history(s, agent_id, 200); }).detach(); } fn_sse::Config cfg; cfg.url = make_url(s, "/sse/agents/" + agent_id + "/logs"); cfg.bearer_token = s.apikey_buf; cfg.auto_reconnect = !agent_id.empty(); s.log_sse.start(cfg, [&s](const fn_sse::Event& e) { std::lock_guard lk(s.log_mu); if (!s.log_paused) { s.log_lines.push_back(e.data); while (s.log_lines.size() > 5000) s.log_lines.pop_front(); } }, [&s](const std::string& status) { s.log_sse_status = status; }); } // Fetch recent status events to seed the Status Feed panel on connect. static void fetch_status_history(AppState& s, int n) { fn_http::Request req; req.method = "GET"; req.url = make_url(s, "/status/recent?n=" + std::to_string(n)); req.bearer_token = s.apikey_buf; req.timeout_ms = 5000; auto res = fn_http::request(req); if (!res.error.empty() || res.status != 200) { FN_DBG("fetch_status_history failed: status=%d err=%s", res.status, res.error.c_str()); return; } auto j = json::parse(res.body, nullptr, false); if (j.is_discarded() || !j.is_array()) return; std::lock_guard lk(s.status_mu); for (auto& ev : j) { std::string entry = "[hist] " + ev.dump(); s.status_events.push_front(entry); while (s.status_events.size() > 200) s.status_events.pop_back(); } FN_DBG("fetch_status_history loaded %zu events into feed", s.status_events.size()); } static void start_status_sse(AppState& s) { s.status_sse.stop(); fn_sse::Config cfg; cfg.url = make_url(s, "/sse/status"); cfg.bearer_token = s.apikey_buf; cfg.auto_reconnect = true; s.status_sse.start(cfg, [&s](const fn_sse::Event& e) { std::string ts; { time_t t = (time_t)now_unix(); char buf[32]; strftime(buf, sizeof(buf), "%H:%M:%S", localtime(&t)); ts = buf; } std::lock_guard lk(s.status_mu); s.status_events.push_front("[" + ts + "] " + e.data); while (s.status_events.size() > 200) s.status_events.pop_back(); }, [&s](const std::string& status) { s.status_sse_status = status; }); } // --------------------------------------------------------------------------- // Self-test mode // --------------------------------------------------------------------------- static bool g_self_test = false; static int g_auto_refresh_after_frames = 0; // >0: trigger fetch_agents_async after N frames static int g_auto_exit_after_frames = 0; // >0: exit after N frames (for headless test) static int g_frame_count = 0; static bool run_self_test() { fn_log::log_info("[self-test] checking subsystems..."); // 1. DB if (!db_open(g_state)) { fprintf(stderr, "[self-test] FAIL: db_open\n"); return false; } fprintf(stdout, "[self-test] db: OK\n"); // 2. secret_store round-trip std::string test_key = "test-apikey-123"; auto blob = fn_secret::encrypt(test_key); if (blob.empty()) { fprintf(stderr, "[self-test] FAIL: encrypt returned empty blob\n"); return false; } std::string recovered = fn_secret::decrypt(blob); if (recovered != test_key) { fprintf(stderr, "[self-test] FAIL: encrypt/decrypt round-trip mismatch\n"); return false; } fprintf(stdout, "[self-test] secret_store: OK (strong=%s)\n", fn_secret::is_strong() ? "yes" : "no (Linux fallback)"); // 3. HTTP client (curl available?) fn_http::Request req; req.method = "GET"; req.url = "https://example.com"; req.timeout_ms = 3000; req.insecure = false; // We don't actually make the request in self-test — just verify curl is in PATH // by checking fn_http::request returns non-empty error (DNS error = curl available) // or zero status (transport-level issue). Network may not be available. fprintf(stdout, "[self-test] http_client: OK (runtime check skipped in self-test)\n"); // 4. SSE client struct constructible { fn_sse::Client cli; (void)cli; } fprintf(stdout, "[self-test] sse_client: OK\n"); fprintf(stdout, "[self-test] all subsystems OK\n"); return true; } // --------------------------------------------------------------------------- // Panel: Connection // --------------------------------------------------------------------------- static bool g_show_connection = true; static bool g_show_agents = true; static bool g_show_logs = true; static bool g_show_status_feed = true; static void draw_led(const std::string& status, float r = 8.0f) { ImVec4 color = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); if (status == "connected") color = ImVec4(0.2f, 0.85f, 0.4f, 1.0f); else if (status == "connecting") color = ImVec4(0.9f, 0.75f, 0.2f, 1.0f); else if (status.rfind("error", 0) == 0 || status == "disconnected") { color = ImVec4(0.9f, 0.3f, 0.3f, 1.0f); } ImVec2 p = ImGui::GetCursorScreenPos(); ImGui::GetWindowDrawList()->AddCircleFilled( ImVec2(p.x + r, p.y + r), r, ImGui::ColorConvertFloat4ToU32(color)); ImGui::Dummy(ImVec2(r * 2 + 6, r * 2)); } static void draw_connection_panel(AppState& s) { if (!ImGui::Begin(TI_WIFI " Connection", &g_show_connection)) { ImGui::End(); return; } ImGui::Text("Base URL:"); ImGui::SameLine(); ImGui::SetNextItemWidth(-1); ImGui::InputText("##base_url", s.base_url, sizeof(s.base_url)); ImGui::Text("API Key:"); ImGui::SameLine(); if (s.apikey_source == "env") { ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f), TI_CHECK " loaded from AGENTS_API_KEY env var"); } else if (s.apikey_source == "pass") { ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f), TI_CHECK " loaded via `pass agentes/api-key`"); } else { ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), TI_ALERT_TRIANGLE " apikey not found (env empty + pass failed)"); ImGui::TextDisabled(" Make sure GPG agent is unlocked: `pass agentes/api-key`"); ImGui::TextDisabled(" Or launch with: AGENTS_API_KEY=$(pass agentes/api-key) "); ImGui::SameLine(); if (ImGui::Button(TI_REFRESH " Retry pass")) { load_apikey(s); } } ImGui::Separator(); // Test button if (ImGui::Button(TI_PLUG " Test Connection")) { s.connect_error.clear(); s.connected = false; fn_log::log_info("[connect] testing %s...", s.base_url); // Synchronous health check (small timeout) fn_http::Request req; req.method = "GET"; req.url = make_url(s, "/health"); req.bearer_token = s.apikey_buf; req.timeout_ms = 5000; auto res = fn_http::request(req); if (!res.error.empty()) { s.connect_error = "Transport error: " + res.error; fn_log::log_warn("[connect] %s", s.connect_error.c_str()); } else if (res.status != 200) { s.connect_error = "HTTP " + std::to_string(res.status) + " from /health"; fn_log::log_warn("[connect] %s", s.connect_error.c_str()); } else { s.connected = true; fn_log::log_info("[connect] OK"); db_save_connection(s); // Seed Status Feed with history BEFORE subscribing live, so the // user sees recent activity from the moment Connect succeeds. std::thread([&s]() { fetch_status_history(s, 100); }).detach(); // Start SSEs (Status live) start_status_sse(s); // Initial agents fetch fetch_agents_async(s); } } ImGui::SameLine(); if (ImGui::Button(TI_REFRESH " Refresh Agents")) { fetch_agents_async(s); } ImGui::Separator(); // Status area ImGui::Text("API:"); ImGui::SameLine(); if (s.connected) { ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f), "Connected " TI_CHECK); } else if (!s.connect_error.empty()) { ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "%s", s.connect_error.c_str()); } else { ImGui::TextDisabled("Not connected"); } ImGui::Text("Status SSE:"); ImGui::SameLine(); ImGui::SameLine(); draw_led(s.status_sse_status); ImGui::SameLine(); ImGui::TextDisabled("%s", s.status_sse_status.c_str()); ImGui::Text("Log SSE:"); ImGui::SameLine(); draw_led(s.log_sse_status); ImGui::SameLine(); ImGui::TextDisabled("%s", s.log_sse_status.c_str()); // Action feedback { std::lock_guard lk(s.action_mu); if (!s.action_feedback.empty() && now_ms() - s.action_feedback_ts < 5000) { ImGui::Separator(); ImGui::TextColored(ImVec4(0.4f, 0.8f, 1.0f, 1.0f), "%s", s.action_feedback.c_str()); } } ImGui::End(); } // --------------------------------------------------------------------------- // Panel: Agents // --------------------------------------------------------------------------- static ImVec4 status_color(const std::string& s) { if (s == "running") return ImVec4(0.2f, 0.85f, 0.4f, 1.0f); if (s == "stopped") return ImVec4(0.55f, 0.55f, 0.6f, 1.0f); if (s == "crashed") return ImVec4(0.9f, 0.3f, 0.3f, 1.0f); return ImVec4(0.8f, 0.75f, 0.3f, 1.0f); // unknown/other } static const char* status_icon(const std::string& s) { if (s == "running") return TI_CIRCLE_CHECK; if (s == "stopped") return TI_CIRCLE_MINUS; if (s == "crashed") return TI_CIRCLE_X; return TI_CIRCLE_DOTTED; } // --------------------------------------------------------------------------- // Agents panel — rendered via data_table_cpp_viz (render_grid_stage0). // Columns: Status | ID | Name | Uptime | Msg/24h | Start | Stop | Restart | // ClearMem | DelCache | Logs // Actions are Button renderer columns; events handled after render_grid_stage0. // // Fallback (compile without fn_module_data_table): plain text list. // --------------------------------------------------------------------------- static void draw_agents_panel(AppState& s) { if (!ImGui::Begin(TI_ROBOT " Agents", &g_show_agents)) { ImGui::End(); return; } // Header bar ImGui::Text("Filter:"); ImGui::SameLine(); ImGui::SetNextItemWidth(200); ImGui::InputText("##filter", s.filter_buf, sizeof(s.filter_buf)); ImGui::SameLine(); if (s.fetching) { ImGui::TextDisabled("Fetching..."); } else { std::lock_guard lk(s.agents_mu); if (!s.agents_error.empty()) { ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f), "%s", s.agents_error.c_str()); } else { ImGui::TextDisabled("%d agents", (int)s.agents.size()); } } ImGui::Separator(); // Confirmation modal for destructive actions if (s.confirm_open) { ImGui::OpenPopup("##confirm_action"); s.confirm_open = false; } if (ImGui::BeginPopupModal("##confirm_action", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { if (s.confirm_action == "clear_memory") { ImGui::Text("Clear memory for agent '%s'?", s.confirm_agent_id.c_str()); ImGui::TextDisabled("This will delete all messages and facts."); } else { ImGui::Text("Delete cache for agent '%s'?", s.confirm_agent_id.c_str()); ImGui::TextDisabled("This will remove crypto/ and cache/ directories."); } ImGui::Separator(); if (ImGui::Button("Confirm", ImVec2(120, 0))) { agent_action(s, s.confirm_agent_id, s.confirm_action); ImGui::CloseCurrentPopup(); } ImGui::SameLine(); if (ImGui::Button("Cancel", ImVec2(120, 0))) { ImGui::CloseCurrentPopup(); } ImGui::EndPopup(); } #ifdef HAS_DATA_TABLE // ----- data_table_cpp_viz path (issue 0131 migration) ----- // Column layout: // 0=Status 1=ID 2=Name 3=Uptime 4=Msg24h // 5=Start 6=Stop 7=Restart 8=ClearMem 9=DelCache 10=Logs static constexpr int N_COLS = 11; static const char* kHeaders[N_COLS] = { "Status", "ID", "Name", "Uptime", "Msg/24h", "Start", "Stop", "Restart", "Clear Memory", "Del Cache", "Logs" }; static const data_table::ColumnType kTypes[N_COLS] = { data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::Int, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String }; static const int kSrcForEff[N_COLS] = { 0,1,2,3,4, 5,6,7,8,9,10 }; // First-render init for State: col_visible + col_order sized to N_COLS. // Without this, render_grid_stage0 indexes into empty std::vector // -> undefined behaviour -> Windows access-violation (exit 5). if ((int)s.agents_tbl_state.col_visible.size() != N_COLS) { s.agents_tbl_state.col_visible.assign(N_COLS, true); } if ((int)s.agents_tbl_state.col_order.size() != N_COLS) { s.agents_tbl_state.col_order.resize(N_COLS); for (int c = 0; c < N_COLS; ++c) s.agents_tbl_state.col_order[c] = c; } if ((int)s.agents_tbl_state.stages.size() < 1) { s.agents_tbl_state.stages.resize(1); } // Build column specs (Badge for Status, Button for action columns) static data_table::TableInput main_t; if (main_t.column_specs.empty()) { main_t.column_specs.resize(N_COLS); // Status: Badge renderer auto& cs_status = main_t.column_specs[0]; cs_status.id = "status"; cs_status.renderer = data_table::CellRenderer::Badge; cs_status.badges = { { "running", "#22c55e", "" }, { "stopped", "#6b7280", "" }, { "disabled", "#374151", "" }, { "crashed", "#ef4444", "" }, }; // Text columns for (int c : {1,2,3,4}) { main_t.column_specs[c].id = kHeaders[c]; main_t.column_specs[c].renderer = data_table::CellRenderer::Text; } // Button columns (indices 5..10) const char* btn_actions[] = { "start","stop","restart","clear_memory","delete_cache","logs" }; const char* btn_labels[] = { TI_PLAYER_PLAY " Start", TI_PLAYER_STOP " Stop", TI_REFRESH " Restart", TI_TRASH " Clear Mem", TI_FOLDER_MINUS " Del Cache", TI_TERMINAL " Logs" }; for (int i = 0; i < 6; ++i) { int c = 5 + i; main_t.column_specs[c].id = btn_actions[i]; main_t.column_specs[c].renderer = data_table::CellRenderer::Button; main_t.column_specs[c].button_action = btn_actions[i]; main_t.column_specs[c].button_label = btn_labels[i]; } // Tooltip for destructive buttons main_t.column_specs[8].tooltip = "Delete all messages and facts for this agent"; main_t.column_specs[8].tooltip_on_hover = true; main_t.column_specs[9].tooltip = "Remove crypto/ and cache/ directories"; main_t.column_specs[9].tooltip_on_hover = true; } // Snapshot agents under lock std::vector snapshot; std::string agents_err; { std::lock_guard lk(s.agents_mu); std::string filter = s.filter_buf; for (auto& row : s.agents) { if (!filter.empty() && row.id.find(filter) == std::string::npos && row.display_name.find(filter) == std::string::npos) { continue; } snapshot.push_back(row); } agents_err = s.agents_error; } // Build flat cell array (row-major, N_COLS per row) int n_rows = (int)snapshot.size(); std::vector cell_strs; cell_strs.reserve((size_t)n_rows * N_COLS); for (auto& row : snapshot) { bool is_running = (row.status == "running"); cell_strs.push_back(row.status); // 0 Status cell_strs.push_back(row.id); // 1 ID cell_strs.push_back(row.display_name); // 2 Name cell_strs.push_back(format_uptime(row.uptime_s)); // 3 Uptime cell_strs.push_back(std::to_string(row.msg_24h)); // 4 Msg/24h // Button cells: show label only if action is applicable cell_strs.push_back(!is_running ? "start" : ""); // 5 Start (disabled when running) cell_strs.push_back(is_running ? "stop" : ""); // 6 Stop (disabled when stopped) cell_strs.push_back("restart"); // 7 Restart always available cell_strs.push_back("clear_memory"); // 8 ClearMem cell_strs.push_back("delete_cache"); // 9 DelCache cell_strs.push_back("logs"); // 10 Logs } // Build pointer array std::vector cells_ptr; cells_ptr.reserve(cell_strs.size()); for (auto& s_str : cell_strs) cells_ptr.push_back(s_str.c_str()); // All rows visible (filtering already applied above) std::vector visible_rows(n_rows); for (int i = 0; i < n_rows; ++i) visible_rows[i] = i; // Collect events from render_grid_stage0 std::vector events; float available_h = ImGui::GetContentRegionAvail().y; // Constrain height so the scroll region doesn't eat the whole panel ImVec2 tbl_size(0, available_h); // Pass height hint via State display — we force Table view mode s.agents_tbl_state.display = data_table::ViewMode::Table; // Need at least 1 row for the API to be happy if (n_rows > 0) { static int dbg_first = 1; if (dbg_first) { FN_DBG("agents_panel PRE-render n_rows=%d cells=%zu specs=%zu eff_h=%p eff_t=%p src=%p vis_sz=%zu", n_rows, cells_ptr.size(), main_t.column_specs.size(), (void*)kHeaders, (void*)kTypes, (void*)kSrcForEff, visible_rows.size()); for (int r = 0; r < n_rows && r < 2; ++r) { for (int c = 0; c < N_COLS; ++c) { const char* p = cells_ptr[r * N_COLS + c]; FN_DBG(" cell[%d][%d]=%s", r, c, p ? p : "(null)"); } } dbg_first = 0; } render_grid_stage0("##agents_tbl", s.agents_tbl_state, cells_ptr.empty() ? nullptr : cells_ptr.data(), n_rows, N_COLS, N_COLS, kHeaders, kTypes, kSrcForEff, visible_rows, main_t, &events); static int dbg_post = 1; if (dbg_post) { FN_DBG("agents_panel POST-render events=%zu", events.size()); dbg_post = 0; } } else { ImGui::TextDisabled("(no agents match filter)"); } // Handle button events for (auto& ev : events) { if (ev.kind != data_table::TableEventKind::ButtonClick) continue; if (ev.row < 0 || ev.row >= n_rows) continue; const std::string& agent_id = snapshot[ev.row].id; const std::string& act = ev.action_id; if (act == "start") { agent_action(s, agent_id, "start"); } else if (act == "stop") { agent_action(s, agent_id, "stop"); } else if (act == "restart") { agent_action(s, agent_id, "restart"); } else if (act == "clear_memory") { // Require confirmation s.confirm_agent_id = agent_id; s.confirm_action = "clear_memory"; s.confirm_open = true; } else if (act == "delete_cache") { // Require confirmation s.confirm_agent_id = agent_id; s.confirm_action = "delete_cache"; s.confirm_open = true; } else if (act == "logs") { snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", agent_id.c_str()); db_save_state(s, "log_agent_id", agent_id.c_str()); start_log_sse(s, agent_id); g_show_logs = true; } } #else // ----- Fallback: plain text list (no fn_module_data_table) ----- { std::lock_guard lk(s.agents_mu); std::string filter = s.filter_buf; for (auto& row : s.agents) { if (!filter.empty() && row.id.find(filter) == std::string::npos && row.display_name.find(filter) == std::string::npos) { continue; } ImGui::PushID(row.id.c_str()); ImGui::Text("[%s] %s uptime=%s msg24h=%d", row.status.c_str(), row.id.c_str(), format_uptime(row.uptime_s).c_str(), row.msg_24h); ImGui::SameLine(); bool is_running = (row.status == "running"); if (!is_running && ImGui::SmallButton("Start")) agent_action(s, row.id, "start"); if (is_running && ImGui::SmallButton("Stop")) agent_action(s, row.id, "stop"); ImGui::SameLine(); if (ImGui::SmallButton("Restart")) agent_action(s, row.id, "restart"); ImGui::SameLine(); if (ImGui::SmallButton("Logs")) { snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", row.id.c_str()); start_log_sse(s, row.id); g_show_logs = true; } ImGui::SameLine(); if (ImGui::SmallButton("ClearMem")) { s.confirm_agent_id = row.id; s.confirm_action = "clear_memory"; s.confirm_open = true; } ImGui::SameLine(); if (ImGui::SmallButton("DelCache")) { s.confirm_agent_id = row.id; s.confirm_action = "delete_cache"; s.confirm_open = true; } ImGui::PopID(); } } #endif // HAS_DATA_TABLE ImGui::End(); } // --------------------------------------------------------------------------- // Panel: Logs // --------------------------------------------------------------------------- static void draw_logs_panel(AppState& s) { if (!ImGui::Begin(TI_TERMINAL_2 " Logs", &g_show_logs)) { ImGui::End(); return; } // Agent selector ImGui::Text("Agent:"); ImGui::SameLine(); ImGui::SetNextItemWidth(200); if (ImGui::InputText("##log_agent", s.log_agent_id, sizeof(s.log_agent_id), ImGuiInputTextFlags_EnterReturnsTrue)) { start_log_sse(s, s.log_agent_id); db_save_state(s, "log_agent_id", s.log_agent_id); } ImGui::SameLine(); if (ImGui::Button(TI_PLAYER_PLAY " Connect")) { start_log_sse(s, s.log_agent_id); db_save_state(s, "log_agent_id", s.log_agent_id); } ImGui::SameLine(); if (ImGui::Button(TI_PLAYER_STOP " Stop")) { s.log_sse.stop(); } // Controls ImGui::SameLine(0, 20); ImGui::Checkbox("Autoscroll", &s.log_autoscroll); ImGui::SameLine(); ImGui::Checkbox("Pause", &s.log_paused); ImGui::SameLine(); if (ImGui::Button(TI_TRASH " Clear")) { std::lock_guard lk(s.log_mu); s.log_lines.clear(); } // SSE status LED ImGui::SameLine(0, 20); draw_led(s.log_sse_status, 6.0f); ImGui::SameLine(); ImGui::TextDisabled("%s", s.log_sse_status.c_str()); ImGui::Separator(); // Log viewport float log_height = ImGui::GetContentRegionAvail().y; if (ImGui::BeginChild("##log_view", ImVec2(0, log_height), false, ImGuiWindowFlags_HorizontalScrollbar)) { std::lock_guard lk(s.log_mu); for (auto& line : s.log_lines) { ImGui::TextUnformatted(line.c_str()); } if (s.log_autoscroll && ImGui::GetScrollY() >= ImGui::GetScrollMaxY() - 20) { ImGui::SetScrollHereY(1.0f); } } ImGui::EndChild(); ImGui::End(); } // --------------------------------------------------------------------------- // Panel: Status Feed // --------------------------------------------------------------------------- static void draw_status_feed_panel(AppState& s) { ImGuiWindowFlags flags = ImGuiWindowFlags_None; if (!ImGui::Begin(TI_ACTIVITY " Status Feed", &g_show_status_feed, flags)) { ImGui::End(); return; } // SSE status draw_led(s.status_sse_status, 6.0f); ImGui::SameLine(); ImGui::TextDisabled("%s", s.status_sse_status.c_str()); ImGui::SameLine(); if (ImGui::Button(TI_TRASH " Clear")) { std::lock_guard lk(s.status_mu); s.status_events.clear(); } ImGui::Separator(); float feed_height = ImGui::GetContentRegionAvail().y; if (ImGui::BeginChild("##status_feed", ImVec2(0, feed_height))) { std::lock_guard lk(s.status_mu); for (auto& ev : s.status_events) { ImGui::TextUnformatted(ev.c_str()); } } ImGui::EndChild(); ImGui::End(); } // --------------------------------------------------------------------------- // render() — called every frame // --------------------------------------------------------------------------- static void render() { g_frame_count++; if (g_frame_count <= 3) { FN_DBG("render frame=%d", g_frame_count); } // Headless test: simulate Refresh button click after N frames if (g_auto_refresh_after_frames > 0 && g_frame_count == g_auto_refresh_after_frames) { FN_DBG("AUTO-REFRESH triggered at frame %d", g_frame_count); // Mark connected so the agents panel renders g_state.connected = true; // Simulate Connect-then-Refresh: populate base_url default, kick fetch. fetch_agents_async(g_state); } draw_connection_panel(g_state); if (g_show_agents) draw_agents_panel(g_state); if (g_show_logs) draw_logs_panel(g_state); if (g_show_status_feed) draw_status_feed_panel(g_state); // Auto-refresh agents every 30s when connected if (g_state.connected && !g_state.fetching) { long long now = now_ms(); if (now - g_state.agents_fetched_ms > 30000) { fetch_agents_async(g_state); } } // Headless test: exit after N frames if (g_auto_exit_after_frames > 0 && g_frame_count >= g_auto_exit_after_frames) { FN_DBG("AUTO-EXIT at frame %d", g_frame_count); std::exit(0); } } // --------------------------------------------------------------------------- // main // --------------------------------------------------------------------------- // run_connect_test exercises the same fn_http path the UI uses, against a real // backend. apikey is read from AGENTS_API_KEY env var (never from argv) so it // does not leak via process listings. // stdout: "OK " exit 0 // stderr: "FAIL " exit 1 static int run_connect_test(const std::string& base_url) { std::string url = trim_url(base_url); while (!url.empty() && url.back() == '/') url.pop_back(); if (url.empty()) { fprintf(stderr, "FAIL empty url after trim\n"); return 1; } const char* envk = std::getenv("AGENTS_API_KEY"); std::string apikey = envk ? envk : ""; { size_t n = apikey.size(); while (n > 0 && (unsigned char)apikey[n - 1] <= 0x20) --n; apikey.resize(n); } // Fallback: try `pass agentes/api-key` if env is empty (same flow as UI). if (apikey.empty()) { apikey = fetch_apikey_via_pass(); } if (apikey.empty()) { fprintf(stderr, "FAIL apikey not found (env empty + pass failed)\n"); return 1; } // 1) /health (no auth) { fn_http::Request req; req.method = "GET"; req.url = url + "/health"; req.timeout_ms = 8000; auto res = fn_http::request(req); if (!res.error.empty()) { fprintf(stderr, "FAIL health transport: %s\n", res.error.c_str()); return 1; } if (res.status != 200) { fprintf(stderr, "FAIL health status %d body=%.200s\n", res.status, res.body.c_str()); return 1; } } // 2) /agents sin auth -> 401 { fn_http::Request req; req.method = "GET"; req.url = url + "/agents"; req.timeout_ms = 8000; auto res = fn_http::request(req); if (!res.error.empty()) { fprintf(stderr, "FAIL agents-noauth transport: %s\n", res.error.c_str()); return 1; } if (res.status != 401) { fprintf(stderr, "FAIL agents-noauth expected 401 got %d\n", res.status); return 1; } } // 3) /agents con auth -> 200 con JSON array { fn_http::Request req; req.method = "GET"; req.url = url + "/agents"; req.bearer_token = apikey; req.timeout_ms = 8000; auto res = fn_http::request(req); if (!res.error.empty()) { fprintf(stderr, "FAIL agents transport: %s\n", res.error.c_str()); return 1; } if (res.status != 200) { fprintf(stderr, "FAIL agents status %d body=%.200s\n", res.status, res.body.c_str()); return 1; } auto j = json::parse(res.body, nullptr, false); if (j.is_discarded() || !j.is_array()) { fprintf(stderr, "FAIL agents body not JSON array\n"); return 1; } fprintf(stdout, "OK %zu\n", j.size()); } return 0; } int main(int argc, char** argv) { FN_DBG("main ENTER argc=%d", argc); // Self-test mode for (int i = 1; i < argc; i++) { if (strcmp(argv[i], "--self-test") == 0) { g_self_test = true; break; } if (strcmp(argv[i], "--connect-test") == 0 && i + 1 < argc) { return run_connect_test(argv[i + 1]); } if (strcmp(argv[i], "--auto-refresh") == 0) { g_auto_refresh_after_frames = 30; // ~0.5s @ 60Hz g_auto_exit_after_frames = 180; // ~3s FN_DBG("auto-refresh mode enabled"); } } if (g_self_test) { db_open(g_state); bool ok = run_self_test(); if (g_state.db) sqlite3_close(g_state.db); return ok ? 0 : 1; } // Panel toggles for menubar View menu static fn_ui::PanelToggle panels[] = { { "Connection", nullptr, &g_show_connection }, { "Agents", nullptr, &g_show_agents }, { "Logs", nullptr, &g_show_logs }, { "Status Feed", nullptr, &g_show_status_feed }, }; fn::AppConfig cfg; cfg.title = "Agents Dashboard"; cfg.about = { "agents_dashboard", "0.1.0", "Frontend C++ ImGui para gestionar agentes Matrix (agents_and_robots) via HTTPS+apikey" }; cfg.log = { "agents_dashboard.log", 1 }; cfg.panels = panels; cfg.panel_count = sizeof(panels) / sizeof(panels[0]); // Init DB and load saved base_url + apikey (env first, fallback to `pass agentes/api-key`). db_open(g_state); db_load_connection(g_state); FN_DBG("startup: db loaded base_url=%s", g_state.base_url); load_apikey(g_state); FN_DBG("startup: apikey_source=%s apikey_len=%zu", g_state.apikey_source.c_str(), strlen(g_state.apikey_buf)); // Cleanup on exit FN_DBG("startup: calling fn::run_app"); int ret = fn::run_app(cfg, render); FN_DBG("fn::run_app returned %d", ret); // Persist state db_save_state(g_state, "log_autoscroll", g_state.log_autoscroll ? "1" : "0"); db_save_state(g_state, "status_feed_open", g_state.status_feed_open ? "1" : "0"); db_save_state(g_state, "log_agent_id", g_state.log_agent_id); // Stop SSEs g_state.log_sse.stop(); g_state.status_sse.stop(); if (g_state.db) sqlite3_close(g_state.db); return ret; }