feat(monitor): Monitor tab as primary landing + errors KPI + recent executions + date filter

Rebrand "Claude Usage" tab to "Monitor" and promote it to first/default tab in
the main TabBar. Monitor is the landing for the reactive loop (construir →
ejecutar → recopilar → analizar → mejorar).

UI additions:
- Local toolbar inside Monitor with date preset combo (1h / 24h / 7d / 30d /
  All), manual Refresh button, and live LED + last-event-ts indicator.
- 5 KPI cards (was 4): added "Errors" derived from COUNT(*) FROM calls WHERE
  success = 0 filtered by the active window.
- New sub-tab "Recent Executions" (first sub-tab) with columns: When,
  Function, Tool, ms, OK (check/X colored), Error class. Backed by calls
  table, sorted by ts DESC, limit 100, filtered by window.
- Violations sub-tab gains "When" column with formatted ts.

Data layer:
- data.h: RecentExecutionRow + window_secs + ws_connected/last_event_ts /
  last_seen_call_id watermark on ClaudeUsageData.
- data_http.{h,cpp}: load_claude_usage_http now takes window_secs and embeds
  ts_filter() in calls/errors/violations queries. total_errors populated.
  recent_executions populated up to 100 rows. New standalone
  load_recent_executions_http() for future WS-driven partial refetch.
- main.cpp: reload_data preserves window_secs across reloads; new
  reload_monitor() does a Monitor-only refetch when the user changes the
  window or clicks Refresh, without re-querying the full registry.

Wiring:
- views.h: draw_monitor + monitor_consume_reload_request() +
  monitor_set_ws_state() exported. draw_claude_usage removed.
- views.cpp: render() consumes monitor_consume_reload_request each frame
  and dispatches reload_monitor().

This is the UI half of issue 0086. The server-side WebSocket endpoint in
sqlite_api and the C++ WS client are next; the LED is wired to
monitor_set_ws_state but stays gray until those land.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 00:26:29 +02:00
parent 33d50aacdd
commit 87b7ef45ff
6 changed files with 592 additions and 3 deletions
+280 -3
View File
@@ -31,6 +31,7 @@
#include <cstdio>
#include <cstring>
#include <cctype>
#include <ctime>
#include <string>
#include <vector>
@@ -40,6 +41,57 @@
static std::string g_api_url;
static fn_ui::ProcessRunner g_reindex_runner;
// ---------------------------------------------------------------------------
// Monitor (issue 0086) — local state
// ---------------------------------------------------------------------------
// Presets de ventana temporal: 1h, 24h, 7d, 30d, All. Indice por defecto = 24h.
static const char* kMonitorWindowLabels[] = {"1h", "24h", "7d", "30d", "All"};
static const int kMonitorWindowSecs[] = {3600, 86400, 604800, 2592000, 0};
static int g_monitor_window_idx = 1; // 24h por defecto
static bool g_monitor_reload_request = false;
static bool g_monitor_ws_connected = false;
static long long g_monitor_last_event_ts = 0;
bool monitor_consume_reload_request() {
bool r = g_monitor_reload_request;
g_monitor_reload_request = false;
return r;
}
void monitor_set_ws_state(bool connected, long long last_event_ts) {
g_monitor_ws_connected = connected;
if (last_event_ts > 0) g_monitor_last_event_ts = last_event_ts;
}
// Formatea un epoch ts en "YYYY-MM-DD HH:MM:SS" local. Si ts == 0 -> "-".
static std::string format_ts(long long ts) {
if (ts <= 0) return "-";
std::time_t t = static_cast<std::time_t>(ts);
std::tm tm_buf{};
#if defined(_WIN32)
localtime_s(&tm_buf, &t);
#else
localtime_r(&t, &tm_buf);
#endif
char buf[32];
std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_buf);
return std::string(buf);
}
// Formatea ts relativo a now: "3s", "2m", "1h", "4d". Para "live indicator".
static std::string format_ts_relative(long long ts) {
if (ts <= 0) return "never";
std::time_t now = std::time(nullptr);
long long diff = static_cast<long long>(now) - ts;
if (diff < 0) diff = 0;
char buf[32];
if (diff < 60) std::snprintf(buf, sizeof(buf), "%llds ago", (long long)diff);
else if (diff < 3600) std::snprintf(buf, sizeof(buf), "%lldm ago", (long long)(diff / 60));
else if (diff < 86400) std::snprintf(buf, sizeof(buf), "%lldh ago", (long long)(diff / 3600));
else std::snprintf(buf, sizeof(buf), "%lldd ago", (long long)(diff / 86400));
return std::string(buf);
}
static fn_ui::ProcessRunner g_add_runner;
// Add modal state
@@ -311,6 +363,226 @@ void draw_types_list(const std::vector<TypeRow>& types) {
table_view("##types", headers, cols, cells.data(), static_cast<int>(types.size()));
}
// ---------------------------------------------------------------------------
// Monitor tab (issue 0086) — reads from ops:call_monitor.
// Pestana principal del dashboard. Bucle reactivo: construir / ejecutar /
// recopilar / analizar / mejorar lo vigila desde aqui.
// ---------------------------------------------------------------------------
static void draw_monitor_toolbar(RegistryData& data) {
fn_ui::toolbar_begin();
// Window preset selector. Si cambia, marcamos reload_request para que
// main.cpp recargue solo claude (no toca registry entero).
ImGui::TextUnformatted("Window:");
ImGui::SameLine();
ImGui::SetNextItemWidth(110.0f);
if (ImGui::BeginCombo("##monitor_window", kMonitorWindowLabels[g_monitor_window_idx])) {
const int n = (int)(sizeof(kMonitorWindowLabels) / sizeof(kMonitorWindowLabels[0]));
for (int i = 0; i < n; i++) {
const bool selected = (i == g_monitor_window_idx);
if (ImGui::Selectable(kMonitorWindowLabels[i], selected)) {
if (i != g_monitor_window_idx) {
g_monitor_window_idx = i;
data.claude.window_secs = kMonitorWindowSecs[i];
g_monitor_reload_request = true;
}
}
if (selected) ImGui::SetItemDefaultFocus();
}
ImGui::EndCombo();
}
ImGui::SameLine();
if (fn_ui::button("Refresh", fn_ui::ButtonVariant::Subtle)) {
g_monitor_reload_request = true;
}
// Live LED: verde si WS conectado, gris si caido. Ts ultimo evento.
ImGui::SameLine();
ImGui::Dummy(ImVec2(fn_tokens::spacing::lg, 0));
ImGui::SameLine();
const ImVec4 dot_col = g_monitor_ws_connected
? ImVec4(0.30f, 0.85f, 0.40f, 1.0f)
: ImVec4(0.55f, 0.55f, 0.55f, 1.0f);
ImGui::TextColored(dot_col, "%s", g_monitor_ws_connected ? TI_POINT : TI_CIRCLE_DOTTED);
ImGui::SameLine();
ImGui::TextUnformatted(g_monitor_ws_connected ? "live" : "offline");
if (g_monitor_last_event_ts > 0) {
ImGui::SameLine();
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
const std::string rel = format_ts_relative(g_monitor_last_event_ts);
ImGui::Text("(last event: %s)", rel.c_str());
ImGui::PopStyleColor();
}
fn_ui::toolbar_end();
}
void draw_monitor(RegistryData& data) {
auto& cu = data.claude;
// Toolbar siempre visible (date filter + LED) incluso si call_monitor caido.
draw_monitor_toolbar(data);
ImGui::Dummy(ImVec2(0, fn_tokens::spacing::sm));
if (!cu.available) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextWrapped("%s call_monitor.operations.db no esta accesible.", TI_ALERT_CIRCLE);
ImGui::TextWrapped("Inicializa con: ./projects/fn_monitoring/apps/call_monitor/call_monitor init");
ImGui::TextWrapped("Despues `systemctl --user restart sqlite_api` para que el datasource ops:call_monitor sea descubierto.");
ImGui::PopStyleColor();
return;
}
// 5 KPI cards: Calls / Errors / Violations / Copies / Versions
const ImGuiTableFlags flags = ImGuiTableFlags_SizingStretchSame | ImGuiTableFlags_NoPadOuterX;
if (ImGui::BeginTable("##monitor_kpi", 5, flags)) {
struct KPI { const char* label; float value; const char* icon; };
const KPI cards[5] = {
{"Calls", static_cast<float>(cu.total_calls), TI_ACTIVITY},
{"Errors", static_cast<float>(cu.total_errors), TI_ALERT_TRIANGLE},
{"Violations", static_cast<float>(cu.total_violations), TI_ALERT_CIRCLE},
{"Copies", static_cast<float>(cu.total_copies), TI_COPY},
{"Versions", static_cast<float>(cu.total_versions), TI_HISTORY},
};
ImGui::TableNextRow();
for (int i = 0; i < 5; i++) {
ImGui::TableSetColumnIndex(i);
kpi_card(cards[i].label, cards[i].value, 0.0f, nullptr, 0, "%.0f", cards[i].icon);
}
ImGui::EndTable();
}
ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md));
// Sub-tabs: Recent Executions (primera) / Top Functions / Violations / Copies
if (ImGui::BeginTabBar("##monitor_sub_tabs")) {
if (ImGui::BeginTabItem("Recent Executions")) {
if (cu.recent_executions.empty()) {
ImGui::TextDisabled("No executions in this window. Try widening (7d/30d/All) or wait for the next call.");
} else {
const ImGuiTableFlags tf = ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders
| ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY;
if (ImGui::BeginTable("##monitor_recent", 6, tf, ImVec2(0, 0))) {
ImGui::TableSetupColumn("When");
ImGui::TableSetupColumn("Function");
ImGui::TableSetupColumn("Tool");
ImGui::TableSetupColumn("ms");
ImGui::TableSetupColumn("OK");
ImGui::TableSetupColumn("Error");
ImGui::TableHeadersRow();
for (const auto& r : cu.recent_executions) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0);
ImGui::TextUnformatted(format_ts(r.ts).c_str());
ImGui::TableSetColumnIndex(1);
ImGui::TextUnformatted(r.function_id.empty() ? "-" : r.function_id.c_str());
ImGui::TableSetColumnIndex(2);
ImGui::TextUnformatted(r.tool_used.c_str());
ImGui::TableSetColumnIndex(3);
ImGui::Text("%d", r.duration_ms);
ImGui::TableSetColumnIndex(4);
if (r.success) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::success);
ImGui::TextUnformatted(TI_CHECK);
ImGui::PopStyleColor();
} else {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::TextUnformatted(TI_X);
ImGui::PopStyleColor();
}
ImGui::TableSetColumnIndex(5);
ImGui::TextUnformatted(r.error_class.c_str());
}
ImGui::EndTable();
}
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Top Functions")) {
if (cu.top_functions.empty()) {
ImGui::TextDisabled("No function calls recorded yet. Hook fires on next session.");
} else {
const ImGuiTableFlags tf = ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders
| ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY;
if (ImGui::BeginTable("##monitor_top_fn", 6, tf, ImVec2(0, 0))) {
ImGui::TableSetupColumn("Function ID");
ImGui::TableSetupColumn("Calls");
ImGui::TableSetupColumn("7d");
ImGui::TableSetupColumn("Errors");
ImGui::TableSetupColumn("Error %");
ImGui::TableSetupColumn("Mean ms");
ImGui::TableHeadersRow();
for (const auto& r : cu.top_functions) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r.function_id.c_str());
ImGui::TableSetColumnIndex(1); ImGui::Text("%d", r.calls_total);
ImGui::TableSetColumnIndex(2); ImGui::Text("%d", r.calls_7d);
ImGui::TableSetColumnIndex(3); ImGui::Text("%d", r.errors_total);
ImGui::TableSetColumnIndex(4); ImGui::Text("%.1f%%", r.error_rate * 100.0);
ImGui::TableSetColumnIndex(5); ImGui::Text("%.0f", r.mean_duration_ms);
}
ImGui::EndTable();
}
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Violations")) {
if (cu.recent_violations.empty()) {
ImGui::TextDisabled("No antipattern violations detected.");
} else {
const ImGuiTableFlags tf = ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders
| ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY;
if (ImGui::BeginTable("##monitor_viol", 5, tf, ImVec2(0, 0))) {
ImGui::TableSetupColumn("When");
ImGui::TableSetupColumn("Rule");
ImGui::TableSetupColumn("Severity");
ImGui::TableSetupColumn("Function");
ImGui::TableSetupColumn("Snippet");
ImGui::TableHeadersRow();
for (const auto& r : cu.recent_violations) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(format_ts(r.ts).c_str());
ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(r.rule_id.c_str());
ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(r.severity.c_str());
ImGui::TableSetColumnIndex(3); ImGui::TextUnformatted(r.function_id.c_str());
ImGui::TableSetColumnIndex(4); ImGui::TextUnformatted(r.command_snippet.c_str());
}
ImGui::EndTable();
}
}
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Copied Code")) {
if (cu.copies.empty()) {
ImGui::TextDisabled("No copied code detected. Run `fn doctor copied-code` or `call_monitor copied-code`.");
} else {
const ImGuiTableFlags tf = ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders
| ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY;
if (ImGui::BeginTable("##monitor_copies", 5, tf, ImVec2(0, 0))) {
ImGui::TableSetupColumn("Kind");
ImGui::TableSetupColumn("Sim");
ImGui::TableSetupColumn("App File");
ImGui::TableSetupColumn("App Function");
ImGui::TableSetupColumn("Registry ID");
ImGui::TableHeadersRow();
for (const auto& r : cu.copies) {
ImGui::TableNextRow();
ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(r.kind.c_str());
ImGui::TableSetColumnIndex(1); ImGui::Text("%.2f", r.similarity);
ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(r.app_file.c_str());
ImGui::TableSetColumnIndex(3); ImGui::TextUnformatted(r.app_function.c_str());
ImGui::TableSetColumnIndex(4); ImGui::TextUnformatted(r.registry_id.c_str());
}
ImGui::EndTable();
}
}
ImGui::EndTabItem();
}
ImGui::EndTabBar();
}
}
// ---------------------------------------------------------------------------
// Projects view
// ---------------------------------------------------------------------------
@@ -887,10 +1159,15 @@ void draw_dashboard(RegistryData& data) {
draw_actions_bar();
ImGui::Dummy(ImVec2(0, fn_tokens::spacing::sm));
// Navegacion top-level: cada tab ocupa toda la zona de contenido. El
// primero ("Dashboard") incluye los KPIs + charts + tabla de recientes;
// los demas son vistas dedicadas a su entidad.
// Navegacion top-level: "Monitor" es la primera y por defecto (issue 0086).
// El bucle reactivo (construir / ejecutar / recopilar / analizar / mejorar)
// se vigila desde alli, asi que pega como landing. Las demas son vistas
// dedicadas a entidades del registry.
if (ImGui::BeginTabBar("##main_tabs", ImGuiTabBarFlags_FittingPolicyScroll)) {
if (ImGui::BeginTabItem("Monitor")) {
draw_monitor(data);
ImGui::EndTabItem();
}
if (ImGui::BeginTabItem("Dashboard")) {
draw_kpi_row(data);
ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md));