Files
agents_dashboard/main.cpp
T
egutierrez b61626b759 fix(ui): parse running/enabled into status; expand e2e to 13 tests
Backend /agents response shape: {id, name, version, desc, enabled: bool,
running: bool, pid, instances, config_path}. parse_agents() was reading
nonexistent fields "status", "uptime_seconds", "messages_24h" so the
table showed every agent as "unknown".

Derive status from running + enabled:
  running=true                  → "running"
  running=false, enabled=false  → "disabled"
  running=false, enabled=true   → "stopped"

E2E suite now covers (13 tests, all passing in ~9s):
- connect happy + bad-host + missing-apikey + URL-trim
- shape contract (id/name/running/enabled present + bool)
- SSE smoke: /sse/status and /sse/agents/{id}/logs must NOT return
  "streaming unsupported" (depends on backend statusWriter.Flush fix
  shipped in agents_and_robots master 4822208)
- auth boundary: /health no-auth, /agents 401 sin Bearer + 401 con key falsa
- control: POST /agents/test-bot/start returns valid JSON
- detail: GET /agents/{id} returns id + logs[]

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 22:34:57 +02:00

942 lines
32 KiB
C++

// 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)
// Logs — SSE tail buffer for /sse/agents/{id}/logs
// Status Feed — SSE events from /sse/status (collapsible)
//
// Issue 0129. 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 <imgui.h>
#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"
// SQLite (vendored via fn_framework)
#include <sqlite3.h>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <cstdint>
#include <cstring>
#include <deque>
#include <mutex>
#include <sstream>
#include <string>
#include <thread>
#include <vector>
using json = nlohmann::json;
using clk = std::chrono::steady_clock;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
static long long now_ms() {
return std::chrono::duration_cast<std::chrono::milliseconds>(
clk::now().time_since_epoch()).count();
}
static long long now_unix() {
return std::chrono::duration_cast<std::chrono::seconds>(
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 from AGENTS_API_KEY env at startup, never via UI
bool apikey_from_env = false;
bool connected = false;
std::string connect_error;
long long last_fetch_ms = 0;
bool fetching = false;
// Agents table
std::mutex agents_mu;
std::vector<AgentRow> 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<std::string> 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<std::string> 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;
};
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);
}
// load_apikey_from_env reads AGENTS_API_KEY into s.apikey_buf. Trims trailing
// whitespace (env vars can carry \r on Windows when sourced from .bat).
static void load_apikey_from_env(AppState& s) {
const char* k = std::getenv("AGENTS_API_KEY");
if (!k || !*k) {
s.apikey_from_env = false;
s.apikey_buf[0] = '\0';
return;
}
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';
}
s.apikey_from_env = (n > 0);
}
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):
// { id, name, version, desc, enabled: bool, running: bool, pid: int,
// instances: int, config_path }
// status is derived; uptime/msg_24h not provided yet (v0.2 backend work).
static std::vector<AgentRow> parse_agents(const std::string& body) {
std::vector<AgentRow> 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";
r.uptime_s = 0;
r.msg_24h = a.value("instances", 0); // hack: show instances until backend exposes msg_24h
rows.push_back(std::move(r));
}
return rows;
}
// Fetch agents in background thread
static void fetch_agents_async(AppState& s) {
if (s.fetching) return;
s.fetching = true;
std::thread([&s]() {
fn_http::Request req;
req.method = "GET";
req.url = make_url(s, "/agents");
req.bearer_token = s.apikey_buf;
req.timeout_ms = 8000;
auto res = fn_http::request(req);
std::lock_guard<std::mutex> 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 {
s.agents = parse_agents(res.body);
s.agents_error.clear();
s.agents_fetched_ms = now_ms();
}
s.fetching = false;
}).detach();
}
// 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<std::mutex> 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
// ---------------------------------------------------------------------------
static void start_log_sse(AppState& s, const std::string& agent_id) {
s.log_sse.stop();
{
std::lock_guard<std::mutex> lk(s.log_mu);
s.log_lines.clear();
s.log_sse_agent_connected = agent_id;
}
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<std::mutex> 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;
});
}
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<std::mutex> 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 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_from_env) {
ImGui::TextColored(ImVec4(0.2f, 0.85f, 0.4f, 1.0f),
TI_CHECK " loaded from AGENTS_API_KEY env (pass agentes/api-key)");
} else {
ImGui::TextColored(ImVec4(0.9f, 0.3f, 0.3f, 1.0f),
TI_ALERT_TRIANGLE " AGENTS_API_KEY env var missing");
ImGui::TextDisabled(" Launch with: AGENTS_API_KEY=$(pass agentes/api-key) <exe>");
}
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);
// Start SSEs
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<std::mutex> 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;
}
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<std::mutex> 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();
// Table
ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
ImGuiTableFlags_SizingStretchProp;
float available_h = ImGui::GetContentRegionAvail().y;
if (ImGui::BeginTable("##agents_tbl", 6, flags, ImVec2(0, available_h))) {
ImGui::TableSetupScrollFreeze(0, 1);
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 70);
ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
ImGui::TableSetupColumn("Uptime", ImGuiTableColumnFlags_WidthFixed, 65);
ImGui::TableSetupColumn("Msg/24h",ImGuiTableColumnFlags_WidthFixed, 65);
ImGui::TableSetupColumn("Actions",ImGuiTableColumnFlags_WidthFixed, 120);
ImGui::TableHeadersRow();
std::lock_guard<std::mutex> lk(s.agents_mu);
std::string filter = s.filter_buf;
for (auto& row : s.agents) {
// Filter
if (!filter.empty() &&
row.id.find(filter) == std::string::npos &&
row.display_name.find(filter) == std::string::npos) {
continue;
}
ImGui::TableNextRow();
// Status column
ImGui::TableSetColumnIndex(0);
ImGui::PushStyleColor(ImGuiCol_Text, status_color(row.status));
ImGui::TextUnformatted(status_icon(row.status));
ImGui::SameLine();
ImGui::TextUnformatted(row.status.c_str());
ImGui::PopStyleColor();
// ID
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(row.id.c_str());
// Name
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(row.display_name.c_str());
// Uptime
ImGui::TableSetColumnIndex(3);
ImGui::TextUnformatted(format_uptime(row.uptime_s).c_str());
// Msg 24h
ImGui::TableSetColumnIndex(4);
ImGui::Text("%d", row.msg_24h);
// Actions
ImGui::TableSetColumnIndex(5);
ImGui::PushID(row.id.c_str());
bool is_running = (row.status == "running");
if (!is_running) {
if (ImGui::SmallButton(TI_PLAYER_PLAY " Start")) {
agent_action(s, row.id, "start");
}
} else {
if (ImGui::SmallButton(TI_PLAYER_STOP " Stop")) {
agent_action(s, row.id, "stop");
}
}
ImGui::SameLine();
if (ImGui::SmallButton(TI_REFRESH " Restart")) {
agent_action(s, row.id, "restart");
}
ImGui::SameLine();
if (ImGui::SmallButton(TI_TERMINAL " Logs")) {
snprintf(s.log_agent_id, sizeof(s.log_agent_id), "%s", row.id.c_str());
db_save_state(s, "log_agent_id", row.id.c_str());
start_log_sse(s, row.id);
g_show_logs = true;
}
ImGui::PopID();
}
ImGui::EndTable();
}
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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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() {
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);
}
}
}
// ---------------------------------------------------------------------------
// 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 <agents_count>" exit 0
// stderr: "FAIL <reason>" 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);
}
if (apikey.empty()) {
fprintf(stderr, "FAIL AGENTS_API_KEY env var empty/missing\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) {
// 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 (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 + read apikey from env (sourced from `pass agentes/api-key`).
db_open(g_state);
db_load_connection(g_state);
load_apikey_from_env(g_state);
if (!g_state.apikey_from_env) {
fn_log::log_warn("[startup] AGENTS_API_KEY env var missing — backend calls will fail. "
"Launch with: AGENTS_API_KEY=$(pass agentes/api-key) ...");
}
// Cleanup on exit
int ret = fn::run_app(cfg, render);
// 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;
}