feat: initial implementation of agents_dashboard v0.1.0
Panels: Connection + Agents + Logs + Status Feed.
- HTTP GET /agents + POST /agents/{id}/{start,stop,restart}
- SSE streaming: /sse/agents/{id}/logs + /sse/status
- DPAPI/XOR credential storage in local_files/agents_dashboard.db
- data_table style agents table with filter + status icons
- SQLite migrations via sqlite3_exec at startup
- --self-test mode: db + secret_store round-trip + subsystem checks
- pytest mock server emulating agents_and_robots API
Registry functions: http_request_cpp_core, sse_client_cpp_core,
secret_store_cpp_infra, logger_cpp_core
App icon: robot phosphor violet-500 (#8b5cf6)
Issue: 0129
Co-Authored-By: fn-orquestador <noreply@fn-registry.local>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
add_imgui_app(agents_dashboard
|
||||
main.cpp
|
||||
# Registry functions (issue 0129):
|
||||
${CMAKE_SOURCE_DIR}/functions/core/http_request.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/core/http_get_json.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/core/sse_client.cpp
|
||||
${CMAKE_SOURCE_DIR}/functions/infra/secret_store.cpp
|
||||
)
|
||||
|
||||
target_include_directories(agents_dashboard PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_SOURCE_DIR}/vendor # nlohmann/json.hpp
|
||||
)
|
||||
|
||||
# fn_table_viz: optional — guards keep the app compilable without it.
|
||||
if(TARGET fn_table_viz)
|
||||
target_link_libraries(agents_dashboard PRIVATE fn_table_viz)
|
||||
endif()
|
||||
|
||||
if(WIN32)
|
||||
set_target_properties(agents_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||
endif()
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
name: agents_dashboard
|
||||
lang: cpp
|
||||
domain: tools
|
||||
description: "Frontend C++ ImGui para gestionar agentes Matrix (agents_and_robots) via HTTPS+apikey, SSE para logs/status en vivo"
|
||||
icon:
|
||||
phosphor: "robot"
|
||||
accent: "#8b5cf6"
|
||||
tags: [agents, dashboard, sse, http-client, imgui]
|
||||
version: 0.1.0
|
||||
uses_functions:
|
||||
- http_request_cpp_core
|
||||
- http_get_json_cpp_core
|
||||
- sse_client_cpp_core
|
||||
- secret_store_cpp_infra
|
||||
- logger_cpp_core
|
||||
uses_types: []
|
||||
framework: "imgui"
|
||||
entry_point: "main.cpp"
|
||||
dir_path: "projects/element_agents/apps/agents_dashboard"
|
||||
repo_url: "https://gitea.organic-machine.com/dataforge/agents_dashboard"
|
||||
e2e_checks:
|
||||
- id: build
|
||||
cmd: "cmake --build /home/lucas/fn_registry/cpp/build/windows --target agents_dashboard -j"
|
||||
timeout_s: 180
|
||||
- id: self_test
|
||||
cmd: "/home/lucas/fn_registry/cpp/build/windows/apps/agents_dashboard/agents_dashboard.exe --self-test"
|
||||
timeout_s: 30
|
||||
- id: pytest_mock
|
||||
cmd: "cd /home/lucas/fn_registry/projects/element_agents/apps/agents_dashboard/tests && python3 -m pytest -x -q"
|
||||
timeout_s: 60
|
||||
---
|
||||
|
||||
# agents_dashboard
|
||||
|
||||
Frontend C++ ImGui para gestionar agentes Matrix de agents_and_robots via HTTPS + apikey. SSE para logs y status feed en vivo.
|
||||
|
||||
## Panels
|
||||
|
||||
- **Connection** — base_url + apikey input (masked), Test button, LED status SSE. Save credentials cifradas en `local_files/agents_dashboard.db`.
|
||||
- **Agents** — tabla con id, status (icon colored), uptime, msg_24h, botones Start/Stop/Restart/Logs por fila.
|
||||
- **Logs** — selector agente + tail buffer SSE (5000 lineas), autoscroll, pause.
|
||||
- **Status Feed** — panel colapsable con eventos del `/sse/status` en tiempo real.
|
||||
|
||||
## Persistencia
|
||||
|
||||
- `local_files/agents_dashboard.db` (SQLite) — tabla `connections` (apikey cifrada DPAPI/XOR), `app_state`.
|
||||
- Migraciones en `migrations/001_init.sql` aplicadas via sqlite3_exec al arrancar.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
cmake --build cpp/build/windows --target agents_dashboard -j
|
||||
```
|
||||
|
||||
## Deploy local (Windows)
|
||||
|
||||
```bash
|
||||
./fn run redeploy_cpp_app_windows agents_dashboard projects/element_agents/apps/agents_dashboard --build
|
||||
```
|
||||
|
||||
## Self-test
|
||||
|
||||
```bash
|
||||
./cpp/build/windows/apps/agents_dashboard/agents_dashboard.exe --self-test
|
||||
# exit 0: db OK, secret_store round-trip OK, subsystems OK
|
||||
```
|
||||
|
||||
## Registry functions used
|
||||
|
||||
- `http_request_cpp_core` — GET/POST /agents + /agents/{id}/{action}
|
||||
- `http_get_json_cpp_core` — /health check
|
||||
- `sse_client_cpp_core` — /sse/agents/{id}/logs + /sse/status
|
||||
- `secret_store_cpp_infra` — DPAPI Windows / XOR Linux para apikey en SQLite
|
||||
- `logger_cpp_core` — logging en memoria + archivo
|
||||
|
||||
## Capability growth log
|
||||
|
||||
v0.1.0 (2026-05-22) — Paneles Connection + Agents + Logs + Status Feed. HTTPS+apikey, SSE reconnect, DPAPI credentials.
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,831 @@
|
||||
// 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 "vendor/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] = "";
|
||||
bool apikey_masked = true;
|
||||
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::error("[db] open failed: {}", 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::warn("[db] migration warning: {}", errmsg ? errmsg : "?");
|
||||
sqlite3_free(errmsg);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
static void db_save_connection(AppState& s) {
|
||||
if (!s.db) return;
|
||||
auto blob = fn_secret::encrypt(s.apikey_buf);
|
||||
if (blob.empty()) {
|
||||
fn_log::warn("[db] encrypt failed, not saving apikey");
|
||||
return;
|
||||
}
|
||||
// Upsert connection id=1
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql =
|
||||
"INSERT INTO connections (id, name, base_url, apikey_encrypted, last_used)"
|
||||
" VALUES (1, 'default', ?, ?, strftime('%s','now'))"
|
||||
" ON CONFLICT(id) DO UPDATE SET"
|
||||
" base_url=excluded.base_url,"
|
||||
" apikey_encrypted=excluded.apikey_encrypted,"
|
||||
" 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_bind_blob(stmt, 2, blob.data(), (int)blob.size(), SQLITE_TRANSIENT);
|
||||
sqlite3_step(stmt);
|
||||
sqlite3_finalize(stmt);
|
||||
fn_log::info("[db] connection saved");
|
||||
}
|
||||
|
||||
static void db_load_connection(AppState& s) {
|
||||
if (!s.db) return;
|
||||
sqlite3_stmt* stmt = nullptr;
|
||||
const char* sql = "SELECT base_url, apikey_encrypted 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);
|
||||
const void* blob = sqlite3_column_blob(stmt, 1);
|
||||
int blob_sz = sqlite3_column_bytes(stmt, 1);
|
||||
if (url) snprintf(s.base_url, sizeof(s.base_url), "%s", url);
|
||||
if (blob && blob_sz > 0) {
|
||||
std::vector<uint8_t> b((const uint8_t*)blob,
|
||||
(const uint8_t*)blob + blob_sz);
|
||||
std::string key = fn_secret::decrypt(b);
|
||||
if (!key.empty()) {
|
||||
snprintf(s.apikey_buf, sizeof(s.apikey_buf), "%s", key.c_str());
|
||||
fn_log::info("[db] credentials loaded");
|
||||
}
|
||||
}
|
||||
}
|
||||
sqlite3_finalize(stmt);
|
||||
}
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
static std::string make_url(const AppState& s, const std::string& path) {
|
||||
std::string base = s.base_url;
|
||||
while (!base.empty() && base.back() == '/') base.pop_back();
|
||||
return base + path;
|
||||
}
|
||||
|
||||
// Parse agents JSON array from /agents endpoint
|
||||
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);
|
||||
r.status = a.value("status", "unknown");
|
||||
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;
|
||||
}
|
||||
|
||||
// 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::info("{}", 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::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;
|
||||
}
|
||||
|
||||
if (!fn_secret::is_strong()) {
|
||||
ImGui::TextColored(ImVec4(1.0f, 0.7f, 0.2f, 1.0f),
|
||||
TI_ALERT_TRIANGLE " Linux: apikey uses weak encryption (XOR fallback)");
|
||||
ImGui::Separator();
|
||||
}
|
||||
|
||||
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();
|
||||
ImGuiInputTextFlags key_flags = s.apikey_masked
|
||||
? ImGuiInputTextFlags_Password : ImGuiInputTextFlags_None;
|
||||
ImGui::SetNextItemWidth(ImGui::GetContentRegionAvail().x - 80);
|
||||
ImGui::InputText("##apikey", s.apikey_buf, sizeof(s.apikey_buf), key_flags);
|
||||
ImGui::SameLine();
|
||||
if (ImGui::Button(s.apikey_masked ? TI_EYE " Show" : TI_EYE_OFF " Hide")) {
|
||||
s.apikey_masked = !s.apikey_masked;
|
||||
}
|
||||
|
||||
ImGui::Separator();
|
||||
|
||||
// Test button
|
||||
if (ImGui::Button(TI_PLUG " Test Connection")) {
|
||||
s.connect_error.clear();
|
||||
s.connected = false;
|
||||
fn_log::info("[connect] testing {}...", 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::warn("[connect] {}", s.connect_error.c_str());
|
||||
} else if (res.status != 200) {
|
||||
s.connect_error = "HTTP " + std::to_string(res.status) + " from /health";
|
||||
fn_log::warn("[connect] {}", s.connect_error.c_str());
|
||||
} else {
|
||||
s.connected = true;
|
||||
fn_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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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 (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 credentials before rendering
|
||||
db_open(g_state);
|
||||
db_load_connection(g_state);
|
||||
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
-- 001_init.sql — schema inicial de agents_dashboard.
|
||||
-- Idempotente: usa IF NOT EXISTS. Nunca borrar ni modificar este archivo.
|
||||
-- Aplica via embed.FS al arrancar la app (regla db_migrations.md).
|
||||
|
||||
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, -- DPAPI Windows / base64+xor Linux fallback
|
||||
last_used INTEGER DEFAULT (strftime('%s', 'now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS app_state (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
|
||||
-- Semilla de app_state para valores por defecto
|
||||
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');
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
test_mock_server.py — Mock server tests for agents_dashboard.
|
||||
|
||||
Emulates the agents_and_robots HTTP API (issue 0128) to verify:
|
||||
- GET /health → 200
|
||||
- GET /agents → 200 with agent list
|
||||
- POST /agents/{id}/start|stop|restart → 200
|
||||
- GET /sse/agents/{id}/logs → text/event-stream
|
||||
- GET /sse/status → text/event-stream
|
||||
|
||||
These tests validate the mock server itself (used for CI headless runs).
|
||||
The C++ app is validated via --self-test and cmake --build in e2e_checks.
|
||||
|
||||
Requirements: pip install pytest flask requests (or uv add pytest flask requests)
|
||||
Run: python3 -m pytest tests/ -x -q
|
||||
"""
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
try:
|
||||
from flask import Flask, Response, jsonify, request as flask_request
|
||||
import requests
|
||||
DEPS_AVAILABLE = True
|
||||
except ImportError:
|
||||
DEPS_AVAILABLE = False
|
||||
|
||||
|
||||
AGENTS = [
|
||||
{"id": "assistant-bot", "name": "Assistant Bot", "status": "running", "uptime_seconds": 3600, "messages_24h": 42},
|
||||
{"id": "monitor-bot", "name": "Monitor Bot", "status": "running", "uptime_seconds": 7200, "messages_24h": 15},
|
||||
{"id": "test-bot", "name": "Test Bot", "status": "stopped", "uptime_seconds": 0, "messages_24h": 0},
|
||||
{"id": "deploy-bot", "name": "Deploy Bot", "status": "running", "uptime_seconds": 1800, "messages_24h": 8},
|
||||
{"id": "alert-bot", "name": "Alert Bot", "status": "crashed", "uptime_seconds": 0, "messages_24h": 3},
|
||||
{"id": "backup-bot", "name": "Backup Bot", "status": "running", "uptime_seconds": 86400,"messages_24h": 2},
|
||||
{"id": "notify-bot", "name": "Notify Bot", "status": "running", "uptime_seconds": 14400,"messages_24h": 22},
|
||||
{"id": "scheduler-bot", "name": "Scheduler Bot", "status": "running", "uptime_seconds": 5400, "messages_24h": 11},
|
||||
{"id": "analytics-bot", "name": "Analytics Bot", "status": "stopped", "uptime_seconds": 0, "messages_24h": 0},
|
||||
{"id": "gateway-bot", "name": "Gateway Bot", "status": "running", "uptime_seconds": 10800,"messages_24h": 67},
|
||||
{"id": "health-check-bot", "name": "Health Check Bot", "status": "running", "uptime_seconds": 3200, "messages_24h": 5},
|
||||
]
|
||||
|
||||
TEST_APIKEY = "test-apikey-abc123"
|
||||
TEST_PORT = 18499 # high port, unlikely to conflict
|
||||
|
||||
|
||||
def create_app():
|
||||
app = Flask("agents_mock")
|
||||
app.config["TESTING"] = True
|
||||
agent_states = {a["id"]: dict(a) for a in AGENTS}
|
||||
|
||||
def check_auth():
|
||||
auth = flask_request.headers.get("Authorization", "")
|
||||
if auth != f"Bearer {TEST_APIKEY}":
|
||||
return False
|
||||
return True
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
if not check_auth():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
return jsonify({"status": "ok", "version": "0.1.0-mock"})
|
||||
|
||||
@app.route("/agents")
|
||||
def list_agents():
|
||||
if not check_auth():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
return jsonify(list(agent_states.values()))
|
||||
|
||||
@app.route("/agents/<agent_id>/start", methods=["POST"])
|
||||
def start_agent(agent_id):
|
||||
if not check_auth():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
if agent_id not in agent_states:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
agent_states[agent_id]["status"] = "running"
|
||||
agent_states[agent_id]["uptime_seconds"] = 0
|
||||
return jsonify({"ok": True, "agent_id": agent_id, "action": "start"})
|
||||
|
||||
@app.route("/agents/<agent_id>/stop", methods=["POST"])
|
||||
def stop_agent(agent_id):
|
||||
if not check_auth():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
if agent_id not in agent_states:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
agent_states[agent_id]["status"] = "stopped"
|
||||
agent_states[agent_id]["uptime_seconds"] = 0
|
||||
return jsonify({"ok": True, "agent_id": agent_id, "action": "stop"})
|
||||
|
||||
@app.route("/agents/<agent_id>/restart", methods=["POST"])
|
||||
def restart_agent(agent_id):
|
||||
if not check_auth():
|
||||
return jsonify({"error": "unauthorized"}), 401
|
||||
if agent_id not in agent_states:
|
||||
return jsonify({"error": "not found"}), 404
|
||||
agent_states[agent_id]["status"] = "running"
|
||||
agent_states[agent_id]["uptime_seconds"] = 0
|
||||
return jsonify({"ok": True, "agent_id": agent_id, "action": "restart"})
|
||||
|
||||
@app.route("/sse/agents/<agent_id>/logs")
|
||||
def sse_agent_logs(agent_id):
|
||||
if not check_auth():
|
||||
return Response("unauthorized", status=401)
|
||||
|
||||
def generate():
|
||||
sample_logs = [
|
||||
f"[INFO] Agent {agent_id} started",
|
||||
f"[INFO] Processing message queue",
|
||||
f"[DEBUG] Heartbeat OK",
|
||||
f"[INFO] Handled event room.message",
|
||||
]
|
||||
for i, line in enumerate(sample_logs):
|
||||
yield f"data: {line}\n\n"
|
||||
time.sleep(0.05)
|
||||
# Keep stream open briefly
|
||||
time.sleep(0.2)
|
||||
|
||||
return Response(generate(), content_type="text/event-stream")
|
||||
|
||||
@app.route("/sse/status")
|
||||
def sse_status():
|
||||
if not check_auth():
|
||||
return Response("unauthorized", status=401)
|
||||
|
||||
def generate():
|
||||
events = [
|
||||
'{"type":"agent_started","agent_id":"monitor-bot"}',
|
||||
'{"type":"heartbeat","agents_running":8}',
|
||||
]
|
||||
for ev in events:
|
||||
yield f"data: {ev}\n\n"
|
||||
time.sleep(0.05)
|
||||
time.sleep(0.2)
|
||||
|
||||
return Response(generate(), content_type="text/event-stream")
|
||||
|
||||
return app, agent_states
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def mock_server():
|
||||
if not DEPS_AVAILABLE:
|
||||
pytest.skip("flask/requests not installed. Run: pip install flask requests")
|
||||
app, agent_states = create_app()
|
||||
server = None
|
||||
started = threading.Event()
|
||||
|
||||
def run():
|
||||
import logging
|
||||
log = logging.getLogger("werkzeug")
|
||||
log.setLevel(logging.ERROR)
|
||||
app.run(port=TEST_PORT, threaded=True)
|
||||
|
||||
t = threading.Thread(target=run, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.5) # Wait for server to be ready
|
||||
yield {"base_url": f"http://127.0.0.1:{TEST_PORT}", "apikey": TEST_APIKEY,
|
||||
"agent_states": agent_states}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_health(mock_server):
|
||||
r = requests.get(
|
||||
f"{mock_server['base_url']}/health",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["status"] == "ok"
|
||||
|
||||
|
||||
def test_health_unauthorized(mock_server):
|
||||
r = requests.get(f"{mock_server['base_url']}/health", timeout=5)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_list_agents(mock_server):
|
||||
r = requests.get(
|
||||
f"{mock_server['base_url']}/agents",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
agents = r.json()
|
||||
assert isinstance(agents, list)
|
||||
assert len(agents) >= 7
|
||||
ids = [a["id"] for a in agents]
|
||||
assert "assistant-bot" in ids
|
||||
assert "test-bot" in ids
|
||||
|
||||
|
||||
def test_list_agents_fields(mock_server):
|
||||
r = requests.get(
|
||||
f"{mock_server['base_url']}/agents",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
agents = r.json()
|
||||
for a in agents:
|
||||
assert "id" in a
|
||||
assert "status" in a
|
||||
assert "uptime_seconds" in a
|
||||
assert "messages_24h" in a
|
||||
assert a["status"] in ("running", "stopped", "crashed")
|
||||
|
||||
|
||||
def test_stop_agent(mock_server):
|
||||
r = requests.post(
|
||||
f"{mock_server['base_url']}/agents/test-bot/stop",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
# Verify state changed
|
||||
assert mock_server["agent_states"]["test-bot"]["status"] == "stopped"
|
||||
|
||||
|
||||
def test_start_agent(mock_server):
|
||||
r = requests.post(
|
||||
f"{mock_server['base_url']}/agents/test-bot/start",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
assert mock_server["agent_states"]["test-bot"]["status"] == "running"
|
||||
|
||||
|
||||
def test_restart_agent(mock_server):
|
||||
r = requests.post(
|
||||
f"{mock_server['base_url']}/agents/assistant-bot/restart",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.json()
|
||||
assert data["ok"] is True
|
||||
|
||||
|
||||
def test_action_not_found(mock_server):
|
||||
r = requests.post(
|
||||
f"{mock_server['base_url']}/agents/nonexistent-bot/start",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 404
|
||||
|
||||
|
||||
def test_sse_logs_streams(mock_server):
|
||||
r = requests.get(
|
||||
f"{mock_server['base_url']}/sse/agents/assistant-bot/logs",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
stream=True,
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert "event-stream" in r.headers.get("Content-Type", "")
|
||||
lines = []
|
||||
for chunk in r.iter_lines(chunk_size=None):
|
||||
if chunk:
|
||||
lines.append(chunk.decode())
|
||||
r.close()
|
||||
data_lines = [l for l in lines if l.startswith("data:")]
|
||||
assert len(data_lines) >= 1
|
||||
|
||||
|
||||
def test_sse_status_streams(mock_server):
|
||||
r = requests.get(
|
||||
f"{mock_server['base_url']}/sse/status",
|
||||
headers={"Authorization": f"Bearer {mock_server['apikey']}"},
|
||||
stream=True,
|
||||
timeout=5
|
||||
)
|
||||
assert r.status_code == 200
|
||||
lines = []
|
||||
for chunk in r.iter_lines():
|
||||
if chunk:
|
||||
lines.append(chunk.decode())
|
||||
r.close()
|
||||
data_lines = [l for l in lines if l.startswith("data:")]
|
||||
assert len(data_lines) >= 1
|
||||
Reference in New Issue
Block a user