e4a1c20fc2
Both panels were empty on Connect / agent select — only new events since
subscribe appeared. Backend already persists per-agent logs to
logs/<id>/YYYY-MM-DD.jsonl AND now keeps the last 100 status diffs in
a ring buffer (agents_and_robots 71b3b2b).
Frontend changes:
- fetch_log_history(s, agent_id, n) → GET /agents/{id}/logs?n=N, fills
s.log_lines BEFORE SSE subscribe so context appears instantly.
Handles the {count,id,lines:[...]} response shape from the backend.
- start_log_sse now spawns this fetch on entry; SSE adds new lines on top.
- fetch_status_history(s, n) → GET /status/recent?n=N, fills
s.status_events with [hist]-tagged entries before the live SSE attaches.
- Connect handler dispatches fetch_status_history() in a worker thread
alongside the existing start_status_sse + fetch_agents_async.
E2E (4 new, 29 total):
- test_status_recent_history_endpoint — shape contract
- test_status_recent_captures_stop_start_events — drives stop/start on
test-bot, asserts events appear in /status/recent within 5s. This is
the "send actions and observe feed" loop the user requested.
- test_agent_logs_history_endpoint — {count,id,lines} contract +
lines>0 for long-running assistant-bot
- test_status_recent_unauthorized_without_bearer — auth boundary
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1349 lines
50 KiB
C++
1349 lines
50 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) 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 <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"
|
|
|
|
// 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 <sqlite3.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cstdint>
|
|
#include <cstring>
|
|
#include <deque>
|
|
#include <mutex>
|
|
#include <sstream>
|
|
#include <string>
|
|
#include <thread>
|
|
|
|
#ifdef _WIN32
|
|
# ifndef NOMINMAX
|
|
# define NOMINMAX
|
|
# endif
|
|
# ifndef WIN32_LEAN_AND_MEAN
|
|
# define WIN32_LEAN_AND_MEAN
|
|
# endif
|
|
# include <windows.h>
|
|
#endif
|
|
#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 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<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;
|
|
|
|
// 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<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";
|
|
// 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<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 {
|
|
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<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
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// 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<std::mutex> lk(s.log_mu);
|
|
for (auto& line : *arr) {
|
|
if (!line.is_string()) continue;
|
|
s.log_lines.push_back(line.get<std::string>());
|
|
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<std::mutex> 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<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;
|
|
});
|
|
}
|
|
|
|
// 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<std::mutex> 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<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 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) <exe>");
|
|
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<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;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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<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();
|
|
|
|
// 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<bool>
|
|
// -> 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<AgentRow> snapshot;
|
|
std::string agents_err;
|
|
{
|
|
std::lock_guard<std::mutex> 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<std::string> 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<const char*> 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<int> visible_rows(n_rows);
|
|
for (int i = 0; i < n_rows; ++i) visible_rows[i] = i;
|
|
|
|
// Collect events from render_grid_stage0
|
|
std::vector<data_table::TableEvent> 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<std::mutex> 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<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() {
|
|
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 <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);
|
|
}
|
|
// 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;
|
|
}
|