feat(0131): data_table_cpp_viz migration + botones acción + 7 nuevos tests e2e
Issue 0131 (agents v0.2) — frontend agents_dashboard: * Migra tabla Agents de ImGui::BeginTable a render_grid_stage0 (data_table_cpp_viz). 11 columnas: Status(Badge), ID, Name, Uptime, Msg/24h, Start/Stop/Restart/Clear Memory/Del Cache/Logs (Button renderers). * Lee campos reales uptime_seconds + messages_24h del backend (antes leía 'instances' como proxy para msg_24h; ahora lee el campo correcto). * Confirmation modal (ImGui::BeginPopupModal) para acciones destructivas clear_memory y delete_cache. * Link fn_module_data_table en CMakeLists (HAS_DATA_TABLE activado). * Actualiza app.md: data_table_cpp_viz en uses_functions, version 0.2.0. * Añade 7 tests pytest nuevos (total 24): test_uptime_field_present, test_msg_24h_field_present, test_clear_memory_requires_apikey, test_delete_cache_requires_apikey, test_control_roundtrip, test_unified_stop_does_not_kill_launcher, test_clear_memory_response_shape. SEGURIDAD: solo test-bot se para/arranca en e2e, nunca agentes reales. Build verificado: Linux + Windows cross-compile (cmake --build cpp/build/windows). Co-Authored-By: fn-orquestador (issue 0131) <noreply@fn-registry>
This commit is contained in:
@@ -17,6 +17,12 @@ if(TARGET fn_table_viz)
|
|||||||
target_link_libraries(agents_dashboard PRIVATE fn_table_viz)
|
target_link_libraries(agents_dashboard PRIVATE fn_table_viz)
|
||||||
endif()
|
endif()
|
||||||
|
|
||||||
|
# fn_module_data_table: render_grid_stage0 + ColumnSpec + TableEvent (issue 0131)
|
||||||
|
# Module lives in cpp/modules/data_table/. Optional: #if __has_include guard applies.
|
||||||
|
if(TARGET fn_module_data_table)
|
||||||
|
target_link_libraries(agents_dashboard PRIVATE fn_module_data_table)
|
||||||
|
endif()
|
||||||
|
|
||||||
if(WIN32)
|
if(WIN32)
|
||||||
set_target_properties(agents_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
|
set_target_properties(agents_dashboard PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
# secret_store.cpp uses CryptProtectData / CryptUnprotectData (crypt32)
|
# secret_store.cpp uses CryptProtectData / CryptUnprotectData (crypt32)
|
||||||
|
|||||||
@@ -7,13 +7,14 @@ icon:
|
|||||||
phosphor: "robot"
|
phosphor: "robot"
|
||||||
accent: "#8b5cf6"
|
accent: "#8b5cf6"
|
||||||
tags: [agents, dashboard, sse, http-client, imgui]
|
tags: [agents, dashboard, sse, http-client, imgui]
|
||||||
version: 0.1.0
|
version: 0.2.0
|
||||||
uses_functions:
|
uses_functions:
|
||||||
- http_request_cpp_core
|
- http_request_cpp_core
|
||||||
- http_get_json_cpp_core
|
- http_get_json_cpp_core
|
||||||
- sse_client_cpp_core
|
- sse_client_cpp_core
|
||||||
- secret_store_cpp_infra
|
- secret_store_cpp_infra
|
||||||
- logger_cpp_core
|
- logger_cpp_core
|
||||||
|
- data_table_cpp_viz
|
||||||
uses_types: []
|
uses_types: []
|
||||||
framework: "imgui"
|
framework: "imgui"
|
||||||
entry_point: "main.cpp"
|
entry_point: "main.cpp"
|
||||||
@@ -38,7 +39,7 @@ Frontend C++ ImGui para gestionar agentes Matrix de agents_and_robots via HTTPS
|
|||||||
## Panels
|
## Panels
|
||||||
|
|
||||||
- **Connection** — base_url + apikey input (masked), Test button, LED status SSE. Save credentials cifradas en `local_files/agents_dashboard.db`.
|
- **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.
|
- **Agents** — tabla data_table_cpp_viz (render_grid_stage0) con 11 columnas: Status(Badge), ID, Name, Uptime, Msg/24h, Start/Stop/Restart/Clear Memory/Del Cache/Logs (Buttons). Modals de confirmacion para acciones destructivas.
|
||||||
- **Logs** — selector agente + tail buffer SSE (5000 lineas), autoscroll, pause.
|
- **Logs** — selector agente + tail buffer SSE (5000 lineas), autoscroll, pause.
|
||||||
- **Status Feed** — panel colapsable con eventos del `/sse/status` en tiempo real.
|
- **Status Feed** — panel colapsable con eventos del `/sse/status` en tiempo real.
|
||||||
|
|
||||||
@@ -73,7 +74,9 @@ cmake --build cpp/build/windows --target agents_dashboard -j
|
|||||||
- `sse_client_cpp_core` — /sse/agents/{id}/logs + /sse/status
|
- `sse_client_cpp_core` — /sse/agents/{id}/logs + /sse/status
|
||||||
- `secret_store_cpp_infra` — DPAPI Windows / XOR Linux para apikey en SQLite
|
- `secret_store_cpp_infra` — DPAPI Windows / XOR Linux para apikey en SQLite
|
||||||
- `logger_cpp_core` — logging en memoria + archivo
|
- `logger_cpp_core` — logging en memoria + archivo
|
||||||
|
- `data_table_cpp_viz` — render_grid_stage0 para tabla Agents con Badge/Button renderers
|
||||||
|
|
||||||
## Capability growth log
|
## Capability growth log
|
||||||
|
|
||||||
v0.1.0 (2026-05-22) — Paneles Connection + Agents + Logs + Status Feed. HTTPS+apikey, SSE reconnect, DPAPI credentials.
|
v0.1.0 (2026-05-22) — Paneles Connection + Agents + Logs + Status Feed. HTTPS+apikey, SSE reconnect, DPAPI credentials.
|
||||||
|
v0.2.0 (2026-05-22) — Migrado tabla Agents a data_table_cpp_viz (issue 0131). Botones Start/Stop/Restart/Clear Memory/Del Cache/Logs por fila. Endpoints clear_memory + delete_cache. Campos uptime_seconds + messages_24h reales.
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
//
|
//
|
||||||
// Panels:
|
// Panels:
|
||||||
// Connection — base_url + apikey input, Test button, SSE status LED
|
// Connection — base_url + apikey input, Test button, SSE status LED
|
||||||
// Agents — data table (id, status, uptime, msg_24h, actions)
|
// Agents — data table (id, status, uptime, msg_24h, actions) via data_table_cpp_viz
|
||||||
// Logs — SSE tail buffer for /sse/agents/{id}/logs
|
// Logs — SSE tail buffer for /sse/agents/{id}/logs
|
||||||
// Status Feed — SSE events from /sse/status (collapsible)
|
// Status Feed — SSE events from /sse/status (collapsible)
|
||||||
//
|
//
|
||||||
// Issue 0129. Registry functions used:
|
// Issue 0129 + 0131. Registry functions used:
|
||||||
// http_request_cpp_core, http_get_json_cpp_core,
|
// http_request_cpp_core, http_get_json_cpp_core,
|
||||||
// sse_client_cpp_core, data_table_cpp_viz, logger_cpp_core,
|
// sse_client_cpp_core, data_table_cpp_viz, logger_cpp_core,
|
||||||
// secret_store_cpp_infra
|
// secret_store_cpp_infra
|
||||||
@@ -21,6 +21,12 @@
|
|||||||
#include "infra/secret_store.h"
|
#include "infra/secret_store.h"
|
||||||
#include "nlohmann/json.hpp"
|
#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)
|
// SQLite (vendored via fn_framework)
|
||||||
#include <sqlite3.h>
|
#include <sqlite3.h>
|
||||||
|
|
||||||
@@ -115,6 +121,16 @@ struct AppState {
|
|||||||
std::mutex action_mu;
|
std::mutex action_mu;
|
||||||
std::string action_feedback;
|
std::string action_feedback;
|
||||||
long long action_feedback_ts = 0;
|
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;
|
static AppState g_state;
|
||||||
@@ -243,10 +259,9 @@ static std::string make_url(const AppState& s, const std::string& path) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Parse agents JSON array from /agents endpoint.
|
// Parse agents JSON array from /agents endpoint.
|
||||||
// Backend shape (agents_and_robots/internal/api/handlers.go):
|
// Backend shape (agents_and_robots/internal/api/handlers.go v0.2):
|
||||||
// { id, name, version, desc, enabled: bool, running: bool, pid: int,
|
// { id, name, version, desc, enabled: bool, running: bool, pid: int,
|
||||||
// instances: int, config_path }
|
// instances: int, config_path, uptime_seconds: int64, messages_24h: int }
|
||||||
// status is derived; uptime/msg_24h not provided yet (v0.2 backend work).
|
|
||||||
static std::vector<AgentRow> parse_agents(const std::string& body) {
|
static std::vector<AgentRow> parse_agents(const std::string& body) {
|
||||||
std::vector<AgentRow> rows;
|
std::vector<AgentRow> rows;
|
||||||
auto j = json::parse(body, nullptr, false);
|
auto j = json::parse(body, nullptr, false);
|
||||||
@@ -260,8 +275,9 @@ static std::vector<AgentRow> parse_agents(const std::string& body) {
|
|||||||
if (running) r.status = "running";
|
if (running) r.status = "running";
|
||||||
else if (!enabled) r.status = "disabled";
|
else if (!enabled) r.status = "disabled";
|
||||||
else r.status = "stopped";
|
else r.status = "stopped";
|
||||||
r.uptime_s = 0;
|
// v0.2: real uptime_seconds and messages_24h from backend
|
||||||
r.msg_24h = a.value("instances", 0); // hack: show instances until backend exposes msg_24h
|
r.uptime_s = a.value("uptime_seconds", (long long)0);
|
||||||
|
r.msg_24h = a.value("messages_24h", 0);
|
||||||
rows.push_back(std::move(r));
|
rows.push_back(std::move(r));
|
||||||
}
|
}
|
||||||
return rows;
|
return rows;
|
||||||
@@ -560,6 +576,15 @@ static const char* status_icon(const std::string& s) {
|
|||||||
return TI_CIRCLE_DOTTED;
|
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) {
|
static void draw_agents_panel(AppState& s) {
|
||||||
if (!ImGui::Begin(TI_ROBOT " Agents", &g_show_agents)) {
|
if (!ImGui::Begin(TI_ROBOT " Agents", &g_show_agents)) {
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
@@ -585,87 +610,229 @@ static void draw_agents_panel(AppState& s) {
|
|||||||
|
|
||||||
ImGui::Separator();
|
ImGui::Separator();
|
||||||
|
|
||||||
// Table
|
// Confirmation modal for destructive actions
|
||||||
ImGuiTableFlags flags = ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg |
|
if (s.confirm_open) {
|
||||||
ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY |
|
ImGui::OpenPopup("##confirm_action");
|
||||||
ImGuiTableFlags_SizingStretchProp;
|
s.confirm_open = false;
|
||||||
float available_h = ImGui::GetContentRegionAvail().y;
|
}
|
||||||
if (ImGui::BeginTable("##agents_tbl", 6, flags, ImVec2(0, available_h))) {
|
if (ImGui::BeginPopupModal("##confirm_action", nullptr,
|
||||||
ImGui::TableSetupScrollFreeze(0, 1);
|
ImGuiWindowFlags_AlwaysAutoResize)) {
|
||||||
ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 70);
|
if (s.confirm_action == "clear_memory") {
|
||||||
ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthStretch);
|
ImGui::Text("Clear memory for agent '%s'?", s.confirm_agent_id.c_str());
|
||||||
ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch);
|
ImGui::TextDisabled("This will delete all messages and facts.");
|
||||||
ImGui::TableSetupColumn("Uptime", ImGuiTableColumnFlags_WidthFixed, 65);
|
} else {
|
||||||
ImGui::TableSetupColumn("Msg/24h",ImGuiTableColumnFlags_WidthFixed, 65);
|
ImGui::Text("Delete cache for agent '%s'?", s.confirm_agent_id.c_str());
|
||||||
ImGui::TableSetupColumn("Actions",ImGuiTableColumnFlags_WidthFixed, 120);
|
ImGui::TextDisabled("This will remove crypto/ and cache/ directories.");
|
||||||
ImGui::TableHeadersRow();
|
}
|
||||||
|
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 };
|
||||||
|
|
||||||
|
// 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::lock_guard<std::mutex> lk(s.agents_mu);
|
||||||
std::string filter = s.filter_buf;
|
std::string filter = s.filter_buf;
|
||||||
for (auto& row : s.agents) {
|
for (auto& row : s.agents) {
|
||||||
// Filter
|
|
||||||
if (!filter.empty() &&
|
if (!filter.empty() &&
|
||||||
row.id.find(filter) == std::string::npos &&
|
row.id.find(filter) == std::string::npos &&
|
||||||
row.display_name.find(filter) == std::string::npos) {
|
row.display_name.find(filter) == std::string::npos) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
snapshot.push_back(row);
|
||||||
|
}
|
||||||
|
agents_err = s.agents_error;
|
||||||
|
}
|
||||||
|
|
||||||
ImGui::TableNextRow();
|
// Build flat cell array (row-major, N_COLS per row)
|
||||||
|
int n_rows = (int)snapshot.size();
|
||||||
// Status column
|
std::vector<std::string> cell_strs;
|
||||||
ImGui::TableSetColumnIndex(0);
|
cell_strs.reserve((size_t)n_rows * N_COLS);
|
||||||
ImGui::PushStyleColor(ImGuiCol_Text, status_color(row.status));
|
for (auto& row : snapshot) {
|
||||||
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");
|
bool is_running = (row.status == "running");
|
||||||
if (!is_running) {
|
cell_strs.push_back(row.status); // 0 Status
|
||||||
if (ImGui::SmallButton(TI_PLAYER_PLAY " Start")) {
|
cell_strs.push_back(row.id); // 1 ID
|
||||||
agent_action(s, row.id, "start");
|
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) {
|
||||||
|
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);
|
||||||
} else {
|
} else {
|
||||||
if (ImGui::SmallButton(TI_PLAYER_STOP " Stop")) {
|
ImGui::TextDisabled("(no agents match filter)");
|
||||||
agent_action(s, row.id, "stop");
|
}
|
||||||
|
|
||||||
|
// 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();
|
ImGui::SameLine();
|
||||||
if (ImGui::SmallButton(TI_REFRESH " Restart")) {
|
bool is_running = (row.status == "running");
|
||||||
agent_action(s, row.id, "restart");
|
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();
|
ImGui::SameLine();
|
||||||
if (ImGui::SmallButton(TI_TERMINAL " Logs")) {
|
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());
|
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);
|
start_log_sse(s, row.id);
|
||||||
g_show_logs = true;
|
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();
|
ImGui::PopID();
|
||||||
}
|
}
|
||||||
ImGui::EndTable();
|
|
||||||
}
|
}
|
||||||
|
#endif // HAS_DATA_TABLE
|
||||||
|
|
||||||
ImGui::End();
|
ImGui::End();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -306,3 +306,196 @@ def test_get_single_agent_returns_logs():
|
|||||||
assert body.get("id") == "assistant-bot"
|
assert body.get("id") == "assistant-bot"
|
||||||
assert "logs" in body
|
assert "logs" in body
|
||||||
assert isinstance(body["logs"], list)
|
assert isinstance(body["logs"], list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# v0.2 — per-agent control + new fields (issue 0131)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_uptime_field_present():
|
||||||
|
"""GET /agents response must include uptime_seconds field (integer >= 0)."""
|
||||||
|
apikey = _apikey()
|
||||||
|
r = _curl([
|
||||||
|
"-fsS",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{_url()}/agents",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
agents = json.loads(r.stdout)
|
||||||
|
assert len(agents) > 0, "no agents returned"
|
||||||
|
for a in agents:
|
||||||
|
assert "uptime_seconds" in a, f"agent {a.get('id')} missing uptime_seconds"
|
||||||
|
assert isinstance(a["uptime_seconds"], int) and a["uptime_seconds"] >= 0, \
|
||||||
|
f"agent {a.get('id')} uptime_seconds={a['uptime_seconds']!r} not a non-negative int"
|
||||||
|
|
||||||
|
|
||||||
|
def test_msg_24h_field_present():
|
||||||
|
"""GET /agents response must include messages_24h field (integer >= 0)."""
|
||||||
|
apikey = _apikey()
|
||||||
|
r = _curl([
|
||||||
|
"-fsS",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{_url()}/agents",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, r.stderr
|
||||||
|
agents = json.loads(r.stdout)
|
||||||
|
assert len(agents) > 0, "no agents returned"
|
||||||
|
for a in agents:
|
||||||
|
assert "messages_24h" in a, f"agent {a.get('id')} missing messages_24h"
|
||||||
|
assert isinstance(a["messages_24h"], int) and a["messages_24h"] >= 0, \
|
||||||
|
f"agent {a.get('id')} messages_24h={a['messages_24h']!r} not a non-negative int"
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_memory_requires_apikey():
|
||||||
|
"""POST /agents/{id}/clear_memory must return 401 without Bearer token."""
|
||||||
|
r = _curl([
|
||||||
|
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"-X", "POST",
|
||||||
|
f"{_url()}/agents/test-bot/clear_memory",
|
||||||
|
])
|
||||||
|
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_delete_cache_requires_apikey():
|
||||||
|
"""POST /agents/{id}/delete_cache must return 401 without Bearer token."""
|
||||||
|
r = _curl([
|
||||||
|
"-s", "-o", "/dev/null", "-w", "%{http_code}",
|
||||||
|
"-X", "POST",
|
||||||
|
f"{_url()}/agents/test-bot/delete_cache",
|
||||||
|
])
|
||||||
|
assert r.stdout == "401", f"expected 401 got {r.stdout!r}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_control_roundtrip():
|
||||||
|
"""Stop test-bot → poll running=false → start → restart.
|
||||||
|
|
||||||
|
SECURITY: Only test-bot is used here. Never call stop on real agents
|
||||||
|
during E2E. test-bot is a stateless robot with no ongoing conversations.
|
||||||
|
"""
|
||||||
|
apikey = _apikey()
|
||||||
|
BASE = _url()
|
||||||
|
|
||||||
|
def get_running(agent_id: str) -> bool:
|
||||||
|
r = _curl([
|
||||||
|
"-fsS",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/{agent_id}",
|
||||||
|
])
|
||||||
|
return bool(json.loads(r.stdout).get("running", False))
|
||||||
|
|
||||||
|
# --- Stop ---
|
||||||
|
r = _curl([
|
||||||
|
"-sS", "-X", "POST",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/test-bot/stop",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, f"stop failed: {r.stderr}"
|
||||||
|
body = json.loads(r.stdout)
|
||||||
|
# Accept "stopped" or "not_running" as valid outcomes
|
||||||
|
assert body.get("status") in ("stopped", "not_running", "ok"), f"unexpected stop status: {body}"
|
||||||
|
|
||||||
|
# Poll until running=false (max 3s)
|
||||||
|
import time
|
||||||
|
for _ in range(15):
|
||||||
|
if not get_running("test-bot"):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert not get_running("test-bot"), "test-bot still running after stop"
|
||||||
|
|
||||||
|
# --- Start ---
|
||||||
|
r = _curl([
|
||||||
|
"-sS", "-X", "POST",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/test-bot/start",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, f"start failed: {r.stderr}"
|
||||||
|
body = json.loads(r.stdout)
|
||||||
|
assert body.get("status") in ("started", "ok"), f"unexpected start status: {body}"
|
||||||
|
|
||||||
|
# Poll until running=true (max 5s)
|
||||||
|
for _ in range(25):
|
||||||
|
if get_running("test-bot"):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert get_running("test-bot"), "test-bot not running after start"
|
||||||
|
|
||||||
|
# --- Restart ---
|
||||||
|
r = _curl([
|
||||||
|
"-sS", "-X", "POST",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/test-bot/restart",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, f"restart failed: {r.stderr}"
|
||||||
|
body = json.loads(r.stdout)
|
||||||
|
assert body.get("status") in ("restarted", "ok"), f"unexpected restart status: {body}"
|
||||||
|
|
||||||
|
# After restart test-bot must be running again (max 5s)
|
||||||
|
for _ in range(25):
|
||||||
|
if get_running("test-bot"):
|
||||||
|
break
|
||||||
|
time.sleep(0.2)
|
||||||
|
assert get_running("test-bot"), "test-bot not running after restart"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unified_stop_does_not_kill_launcher():
|
||||||
|
"""Stopping test-bot must leave assistant-bot (and other real agents) running.
|
||||||
|
|
||||||
|
In unified mode, all agents are goroutines. Per-agent stop must cancel
|
||||||
|
only the target goroutine — not the launcher process.
|
||||||
|
SECURITY: Only test-bot is stopped. assistant-bot is only read, never mutated.
|
||||||
|
"""
|
||||||
|
apikey = _apikey()
|
||||||
|
BASE = _url()
|
||||||
|
|
||||||
|
def get_running(agent_id: str) -> bool:
|
||||||
|
r = _curl([
|
||||||
|
"-fsS",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/{agent_id}",
|
||||||
|
])
|
||||||
|
if r.returncode != 0:
|
||||||
|
return False
|
||||||
|
return bool(json.loads(r.stdout).get("running", False))
|
||||||
|
|
||||||
|
# Stop test-bot only
|
||||||
|
r = _curl([
|
||||||
|
"-sS", "-X", "POST",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/test-bot/stop",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, f"stop test-bot failed: {r.stderr}"
|
||||||
|
|
||||||
|
# assistant-bot must still be running (launcher is alive)
|
||||||
|
assert get_running("assistant-bot"), \
|
||||||
|
"assistant-bot not running after stopping test-bot — launcher may have crashed"
|
||||||
|
|
||||||
|
# Restore test-bot
|
||||||
|
_curl([
|
||||||
|
"-sS", "-X", "POST",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{BASE}/agents/test-bot/start",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def test_clear_memory_response_shape():
|
||||||
|
"""POST /agents/{id}/clear_memory (authorized) returns JSON with expected fields.
|
||||||
|
|
||||||
|
Uses test-bot which has minimal/no memory, so deleting is always safe.
|
||||||
|
Verifies: status, messages_deleted, facts_deleted keys present.
|
||||||
|
"""
|
||||||
|
apikey = _apikey()
|
||||||
|
r = _curl([
|
||||||
|
"-sS", "-X", "POST",
|
||||||
|
"-H", f"Authorization: Bearer {apikey}",
|
||||||
|
f"{_url()}/agents/test-bot/clear_memory",
|
||||||
|
])
|
||||||
|
assert r.returncode == 0, f"clear_memory failed: {r.stderr}"
|
||||||
|
body = json.loads(r.stdout)
|
||||||
|
assert body.get("status") == "cleared", f"expected status=cleared: {body}"
|
||||||
|
assert "messages_deleted" in body, f"missing messages_deleted: {body}"
|
||||||
|
assert "facts_deleted" in body, f"missing facts_deleted: {body}"
|
||||||
|
assert isinstance(body["messages_deleted"], int), \
|
||||||
|
f"messages_deleted not int: {body['messages_deleted']!r}"
|
||||||
|
assert isinstance(body["facts_deleted"], int), \
|
||||||
|
f"facts_deleted not int: {body['facts_deleted']!r}"
|
||||||
|
|||||||
Reference in New Issue
Block a user