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
+191
View File
@@ -536,3 +536,194 @@ bool http_post_add_vault(const std::string& api_url,
b["description"] = description;
return post_json(api_url, "/api/add/vault", b, out_body);
}
// ---- Issue 0085d: Claude usage telemetry ----
// Query against ops:call_monitor instead of registry.
static json call_monitor_query(HttpClient& cli, const char* sql) {
json body;
body["sql"] = sql;
auto res = cli.post("/api/databases/ops:call_monitor/query", body.dump(), "application/json");
if (!res.ok()) {
return nullptr;
}
return json::parse(res.body, nullptr, false);
}
static double extract_row_double(const json& row, size_t idx) {
if (idx >= row.size() || row[idx].is_null()) return 0.0;
if (row[idx].is_number()) return row[idx].get<double>();
if (row[idx].is_string()) return std::atof(row[idx].get<std::string>().c_str());
return 0.0;
}
// Construye un filtro temporal `WHERE ts >= ?` literal embebido (no prepared)
// reemplazando el placeholder. window_secs == 0 -> sin filtro.
static std::string ts_filter(int window_secs, const char* col = "ts",
const char* glue = "WHERE") {
if (window_secs <= 0) return "";
char buf[128];
std::snprintf(buf, sizeof(buf), " %s %s >= (strftime('%%s','now') - %d) ",
glue, col, window_secs);
return std::string(buf);
}
bool load_claude_usage_http(const std::string& api_url, RegistryData& out,
int window_secs) {
// Preservar window y estado WS al recargar.
bool prev_ws = out.claude.ws_connected;
long long prev_last_ev = out.claude.last_event_ts;
long long prev_max_id = out.claude.last_seen_call_id;
out.claude = ClaudeUsageData{};
out.claude.window_secs = window_secs;
out.claude.ws_connected = prev_ws;
out.claude.last_event_ts = prev_last_ev;
out.claude.last_seen_call_id = prev_max_id;
std::string host;
int port;
if (!parse_url(api_url, host, port)) return false;
HttpClient cli(host, port);
// Probe: is ops:call_monitor known?
auto probe = cli.get("/api/databases/ops:call_monitor/tables");
if (!probe.ok()) {
out.claude.available = false;
return true; // not an error: monitor not yet deployed
}
out.claude.available = true;
const std::string wf_calls = ts_filter(window_secs); // " WHERE ts >= ..."
const std::string wf_viol = ts_filter(window_secs);
const std::string wf_calls_and = wf_calls.empty()
? std::string(" WHERE success = 0 ")
: std::string(wf_calls + " AND success = 0 ");
// Totals (filtradas por ventana donde aplica)
{
const std::string sql_calls = "SELECT COUNT(*) FROM calls" + wf_calls;
out.claude.total_calls = extract_int(call_monitor_query(cli, sql_calls.c_str()));
}
{
const std::string sql_err = "SELECT COUNT(*) FROM calls" + wf_calls_and;
out.claude.total_errors = extract_int(call_monitor_query(cli, sql_err.c_str()));
}
{
const std::string sql_viol = "SELECT COUNT(*) FROM violations" + wf_viol;
out.claude.total_violations = extract_int(call_monitor_query(cli, sql_viol.c_str()));
}
out.claude.total_copies = extract_int(call_monitor_query(cli, "SELECT COUNT(*) FROM copied_code"));
out.claude.total_versions = extract_int(call_monitor_query(cli, "SELECT COUNT(*) FROM function_versions"));
// Recent executions (calls table) ordenada por ts DESC
{
std::string sql = "SELECT id, ts, function_id, tool_used, duration_ms, success, error_class, session_id "
"FROM calls" + wf_calls + " ORDER BY ts DESC LIMIT 100";
json rx = call_monitor_query(cli, sql.c_str());
if (rx.is_object() && rx.contains("rows")) {
long long mx = out.claude.last_seen_call_id;
for (const auto& r : rx["rows"]) {
RecentExecutionRow row;
row.id = (long long)extract_row_int(r, 0);
row.ts = (long long)extract_row_int(r, 1);
row.function_id = extract_str(r, 2);
row.tool_used = extract_str(r, 3);
row.duration_ms = extract_row_int(r, 4);
row.success = extract_row_int(r, 5) != 0;
row.error_class = extract_str(r, 6);
row.session_id = extract_str(r, 7);
if (row.id > mx) mx = row.id;
out.claude.recent_executions.push_back(row);
}
out.claude.last_seen_call_id = mx;
}
}
// Top functions by calls_total
json top = call_monitor_query(cli,
"SELECT function_id, calls_total, calls_7d, errors_total, error_rate, mean_duration_ms "
"FROM function_stats ORDER BY calls_total DESC LIMIT 20");
if (top.is_object() && top.contains("rows")) {
for (const auto& r : top["rows"]) {
ClaudeUsageRow row;
row.function_id = extract_str(r, 0);
row.calls_total = extract_row_int(r, 1);
row.calls_7d = extract_row_int(r, 2);
row.errors_total = extract_row_int(r, 3);
row.error_rate = extract_row_double(r, 4);
row.mean_duration_ms = extract_row_double(r, 5);
out.claude.top_functions.push_back(row);
}
}
// Recent violations (filtradas por ventana)
std::string sql_viol_list = "SELECT rule_id, function_id, command_snippet, severity, ts "
"FROM violations" + wf_viol + " ORDER BY ts DESC LIMIT 20";
json viol = call_monitor_query(cli, sql_viol_list.c_str());
if (viol.is_object() && viol.contains("rows")) {
for (const auto& r : viol["rows"]) {
ClaudeViolationRow row;
row.rule_id = extract_str(r, 0);
row.function_id = extract_str(r, 1);
row.command_snippet = extract_str(r, 2);
row.severity = extract_str(r, 3);
row.ts = (long long)extract_row_int(r, 4);
out.claude.recent_violations.push_back(row);
}
}
// Copied code matches
json cp = call_monitor_query(cli,
"SELECT app_file, app_function, registry_id, kind, similarity "
"FROM copied_code ORDER BY detected_at DESC LIMIT 50");
if (cp.is_object() && cp.contains("rows")) {
for (const auto& r : cp["rows"]) {
ClaudeCopiedRow row;
row.app_file = extract_str(r, 0);
row.app_function = extract_str(r, 1);
row.registry_id = extract_str(r, 2);
row.kind = extract_str(r, 3);
row.similarity = extract_row_double(r, 4);
out.claude.copies.push_back(row);
}
}
return true;
}
bool load_recent_executions_http(const std::string& api_url,
int window_secs, int limit,
std::vector<RecentExecutionRow>& out,
long long& out_max_id) {
out.clear();
out_max_id = 0;
std::string host;
int port;
if (!parse_url(api_url, host, port)) return false;
HttpClient cli(host, port);
const std::string wf = ts_filter(window_secs);
char lim_buf[32];
std::snprintf(lim_buf, sizeof(lim_buf), " LIMIT %d", limit > 0 ? limit : 100);
std::string sql = "SELECT id, ts, function_id, tool_used, duration_ms, success, error_class, session_id "
"FROM calls" + wf + " ORDER BY ts DESC" + lim_buf;
json rx = call_monitor_query(cli, sql.c_str());
if (!rx.is_object() || !rx.contains("rows")) return false;
for (const auto& r : rx["rows"]) {
RecentExecutionRow row;
row.id = (long long)extract_row_int(r, 0);
row.ts = (long long)extract_row_int(r, 1);
row.function_id = extract_str(r, 2);
row.tool_used = extract_str(r, 3);
row.duration_ms = extract_row_int(r, 4);
row.success = extract_row_int(r, 5) != 0;
row.error_class = extract_str(r, 6);
row.session_id = extract_str(r, 7);
if (row.id > out_max_id) out_max_id = row.id;
out.push_back(row);
}
return true;
}