16 Commits

Author SHA1 Message Date
egutierrez 8aa1146b45 feat(launcher): launch_linux.sh arranca en modo completo (HTTP+WS+Work)
Antes forzaba --api "" (binding SQLite directo), lo que desactivaba el
WebSocket del Monitor tab. Ademas no exportaba FN_REGISTRY_ROOT, por lo que la
tab Work no encontraba el binario dev_console y caia a un placeholder.

Ahora pasa --api http://127.0.0.1:8484 con registry.db como segundo argumento
de fallback (el binario cae a SQLite directo si el servicio no responde) y
exporta FN_REGISTRY_ROOT. El resultado es la app completa con un solo comando,
con degradacion elegante cuando sqlite_api no esta arriba.
2026-06-03 19:13:03 +02:00
egutierrez ca67f343df docs: documentar dependencias de runtime (sqlite_api, call_monitor, dev_console)
La tab Work (issue 0102) y su backend dev_console no estaban documentados.
Se anade una seccion que mapea las tres apps de las que depende el dashboard
en tiempo de ejecucion, su tipo de acoplamiento y el comportamiento degradado
cuando cada una falta.
2026-06-03 17:37:36 +02:00
egutierrez 371266c52b Merge quick/linux-launcher: launcher Linux + binding directo 2026-06-02 23:00:23 +02:00
egutierrez 0ac03fe5b0 feat(linux): launcher de escritorio con binding SQLite directo
Anade launch_linux.sh y appicon.png para correr el dashboard en Linux nativo:
- Stagea el ejecutable + assets a ~/fn_apps/registry_dashboard/ y lanza desde
  alli, de modo que local_files/ (imgui.ini, app_settings.ini, layouts.db, logs)
  queda contenido en esa carpeta en lugar del arbol de build o el escritorio.
- Fuerza --api "" para leer registry.db directo y prescindir de sqlite_api
  (el puente HTTP solo hacia falta cuando el binario corria en Windows con los
  datos en WSL).
- appicon.png: rasterizado del appicon.ico para el icono del .desktop.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 23:00:23 +02:00
egutierrez ff67e4e069 chore: auto-commit (4 archivos)
- app.md
- appicon.ico
- views.cpp
- work_tab.cpp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:31:34 +02:00
egutierrez ca436aa6cc feat(dev): issues 0100-0104 — dev_console binary + work_tab + DoD user-facing + frontmatter migration de 146 issues + taxonomia canonica
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 02:44:05 +02:00
egutierrez 092d03ba53 docs(flows): DoD obligatorio con user-facing surface + abrir issues 0100-0103 (taxonomia, frontmatter migration, dev_console, work dashboard)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 00:07:04 +02:00
egutierrez 04cfa2aba0 chore: auto-commit (4 archivos)
- data.h
- data_http.cpp
- views.cpp
- appicon.ico

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-16 16:33:25 +02:00
egutierrez 9cbf2f7492 merge Monitor renderers migration (issue 0081-J) 2026-05-15 16:43:54 +02:00
egutierrez 3f8c12db89 migrate Monitor Recent Executions + Failed Functions to data_table::render with Badge/Duration renderers (issue 0081-J)
- Replace ##monitor_recent BeginTable (6 cols) with data_table::render:
  - Duration renderer on duration_ms col (warn=500ms, error=2000ms)
  - Badge renderer on Status col: ok=#22c55e, error=#ef4444, running=#3b82f6, timeout=#f59e0b
  - success bool encoded as "ok"/"error" string for badge matching
- Replace ##monitor_failed_fns BeginTable (5 cols) with data_table::render:
  - Badge renderer on Error Class col: sqlite/network/timeout/not_found/auth/parse/io/permission/unknown
  - empty error_class normalized to "unknown" for badge match
- Add g_dt_monitor_recent + g_dt_monitor_failed persistent State members
- Remove all inline coloring helpers for these two tables (covered by renderers)
- fn doctor cpp-apps: registry_dashboard BeginTable inline count 7 -> 5
  (5 remaining are layout splitters: kpi_grid/chart_grid/monitor_kpi/proj_layout/explorer_layout)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 16:43:51 +02:00
egutierrez 55a9c07e46 merge data_table migration (issue 0081-J)
Migrates registry_dashboard tables to data_table::render() via fn_table_viz.
2026-05-15 14:39:55 +02:00
egutierrez 3d7c5bc0a1 migrate function/type/app tables to data_table_cpp_viz (issue 0081-J)
- Replace table_view() calls in draw_recent_functions, draw_apps_list,
  draw_analysis_list, draw_types_list, and vaults panel with
  data_table::render() via fn_table_viz static lib.
- Migrate Monitor sub-tabs Top Functions, Violations, Copied Code to
  data_table::render() with persistent State per panel.
- Keep Recent Executions and Failed Functions as custom ImGui tables
  (per-cell coloring + tooltips not supported by data_table).
- Layout-splitter tables (kpi_grid, chart_grid, monitor_kpi,
  proj_layout, explorer_layout) intentionally not migrated.
- CMakeLists: target_link_libraries(registry_dashboard PRIVATE fn_table_viz).
- app.md uses_functions += data_table_cpp_viz + full fn_table_viz stack.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 14:39:51 +02:00
egutierrez 00ee6a93e3 chore: auto-commit (4 archivos)
- data.h
- data_http.cpp
- main.cpp
- views.cpp

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 02:06:46 +02:00
egutierrez 5c53cfcb68 merge: issue/0086-monitor-tab — Monitor tab + WS live stream
UI:
- Tab Monitor first/default in TabBar
- 5 KPIs (Calls/Errors/Violations/Copies/Versions)
- Recent Executions table with timestamps
- Date filter (1h/24h/7d/30d/All)
- Live LED + last-event indicator

Backend:
- Hand-rolled RFC6455 WS client (ws_client.{h,cpp})
- Connects to sqlite_api /api/events/call_monitor
- Snapshot + delta application to g_data.claude
- Reconnect with exponential backoff

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:35:38 +02:00
egutierrez f7109a8ca0 docs(monitor): bump to v0.4.0, update app.md with Monitor + WS section
- About info updated to reflect Monitor as primary tab and the WS feature set.
- app.md gains a dedicated "Fase — Monitor tab + WebSocket live stream" section
  documenting the architecture, KPIs, WS hub design, fallback semantics, and
  the scope decisions (no TLS, skip Accept verification, WS client not yet
  extracted to cpp/functions/).
- Roadmap items added: extract ws_client when a second consumer arrives,
  Sec-WebSocket-Accept verification, migrate to coder/websocket on the server.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:35:26 +02:00
egutierrez 9205567d5f feat(monitor): WS client live stream + apply snapshot/delta to UI
Hand-rolled minimal RFC6455 WebSocket client (~330 LOC) tailored to the
Monitor tab's needs. Single endpoint, text frames, masked client→server,
exponential reconnect backoff (0.5s → 8s cap), thread-safe in/out queues.
TLS is intentionally out of scope (localhost-only). Sec-WebSocket-Accept
verification is skipped — the server is controlled, 101 status is enough.

Files:
- ws_client.{h,cpp}: WsClient with start(host,port,path), drain(), send_text(),
  is_connected(), last_event_ts(). Worker thread does connect + handshake +
  read_loop + reconnect.
- CMakeLists.txt: pulls ws_client.cpp into the dashboard target. ws2_32 was
  already linked for http_client.cpp.
- main.cpp: parses host:port from --api URL, spawns a global WsClient, and
  drains its queue once per render frame via poll_ws(). apply_ws_message()
  routes JSON to g_data.claude:
    snapshot → replace KPIs + recent_executions
    delta    → append rows, increment total_calls / total_errors
  monitor_set_ws_state() forwards connection state + last_event_ts to the
  Monitor toolbar LED.

End-to-end smoke test passed against sqlite_api on localhost:8484:
- Snapshot received with KPIs + 100 recent rows.
- After INSERT INTO calls, delta arrives within ~250ms (server ticker).
- Errors (success=0) propagate correctly and bump the Errors KPI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 00:34:03 +02:00
13 changed files with 2023 additions and 194 deletions
+7
View File
@@ -19,7 +19,9 @@ add_imgui_app(registry_dashboard
data.cpp
data_http.cpp
http_client.cpp
ws_client.cpp
views.cpp
work_tab.cpp
${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp
${CMAKE_SOURCE_DIR}/functions/viz/bar_chart.cpp
${CMAKE_SOURCE_DIR}/functions/viz/pie_chart.cpp
@@ -52,6 +54,11 @@ target_include_directories(registry_dashboard PRIVATE
target_link_libraries(registry_dashboard PRIVATE SQLite::SQLite3)
# Issue 0081-J: data_table::render via fn_module_data_table static lib
if(TARGET fn_module_data_table)
target_link_libraries(registry_dashboard PRIVATE fn_module_data_table)
endif()
# Sockets: ws2_32 on Windows, nothing extra on Linux
if(WIN32)
target_link_libraries(registry_dashboard PRIVATE ws2_32)
+76
View File
@@ -2,6 +2,7 @@
name: registry_dashboard
lang: cpp
domain: tui
version: 0.1.0
description: "Dashboard ImGui para visualizar el estado del fn_registry. Consume datos via sqlite_api HTTP (fallback a SQLite directo). KPIs, charts, tablas, desglose por lenguaje/dominio/pureza."
tags: [dashboard, imgui, visualization, registry, http]
uses_functions:
@@ -31,10 +32,14 @@ uses_functions:
- process_runner_cpp_core
- process_state_machine_cpp_core
uses_types: []
uses_modules: [data_table_cpp]
framework: "imgui"
entry_point: "main.cpp"
dir_path: "projects/fn_monitoring/apps/registry_dashboard"
repo_url: "https://gitea-dgg044oo04woo4ggcsws4gk0.organic-machine.com/dataforge/registry_dashboard"
icon:
phosphor: "gauge"
accent: "#059669"
---
## Arquitectura
@@ -54,6 +59,16 @@ Dashboard C++ con dos modos de acceso a datos:
- Pie charts: pureza (pure/impure), kind (function/pipeline/component)
- Tablas: ultimas 20 funciones, apps, analysis, tipos
## Dependencias de runtime (otras apps)
Ademas de las funciones, tipos y modulos del registry declarados en el frontmatter (`uses_functions`, `uses_modules`), el dashboard depende en tiempo de ejecucion de tres apps del ecosistema `fn_monitoring`. Estas dependencias no se expresan en el frontmatter porque el schema de la tabla `apps` no tiene un campo para dependencias entre apps; se documentan aqui.
| App | Acoplamiento | Para que | Si falta |
|-----|--------------|----------|----------|
| `sqlite_api` (`projects/fn_monitoring/apps/sqlite_api`, servicio HTTP en `:8484`) | HTTP `POST /api/databases/...` + WebSocket `/api/events/call_monitor` | Fuente primaria de datos (`registry.db`) y stream en vivo del Monitor tab | El dashboard cae al modo SQLite directo (`--api ""` mas un path a `registry.db`). El Monitor tab pierde el stream en vivo y solo muestra el snapshot inicial. |
| `call_monitor` (`projects/fn_monitoring/apps/call_monitor`, telemetria) | Indirecto: escribe su `operations.db`, que `sqlite_api` lee y reenvia por WebSocket | Eventos del Monitor tab (`calls`, `violations`, ejecuciones recientes) | El Monitor tab queda vacio. El WebSocket de `sqlite_api` se cierra con `snapshot failed` si la `operations.db` de `call_monitor` no esta registrada en el pool. El registro ocurre al arrancar `sqlite_api`, por lo que un reinicio del servicio la recoge si la base de datos se creo despues. |
| `dev_console` (`apps/dev_console`, CLI Go) | Subproceso: `dev_console work dashboard --json` invocado via `popen()` cada 30 s (con cache) | Backend de la tab Work (issue 0102): issues, flows y KPIs de DoD | La tab Work muestra un placeholder con el comando de build, sin crash. `work_tab.cpp::find_dev_console()` busca el binario en `$FN_REGISTRY_ROOT/apps/dev_console/dev_console`, asi que el dashboard debe lanzarse con `FN_REGISTRY_ROOT` apuntando a la raiz del repo para que la tab cobre vida. |
## Build
```bash
@@ -95,6 +110,9 @@ cd cpp && cmake -B build/windows -S . -DCMAKE_TOOLCHAIN_FILE=toolchains/mingw-w6
- [ ] Filtros interactivos por lenguaje/dominio en sidebar
- [ ] Busqueda FTS5 integrada via API
- [ ] Detalles de funcion al hacer click en tabla
- [ ] Extraer ws_client a `cpp/functions/network/` cuando un segundo app C++ necesite WS
- [ ] SHA1 + Sec-WebSocket-Accept verification (hoy se confia en el handshake 101)
- [ ] Migrar `nhooyr.io/websocket``github.com/coder/websocket` (mismo paquete, deprecation upstream)
## Notas
@@ -142,8 +160,66 @@ El cross-compile pasa de thread model `win32` a `posix` (`x86_64-w64-mingw32-g++
- `CMakeLists.txt` limpiado: `fps_overlay.cpp` y `tokens.cpp` ya viven en `fn_framework` — no listarlos explicitamente o el linker da multiple-definition.
- 5 TTFs (Karla/Roboto/DroidSans/Cousine/Tabler) copiadas junto al exe via `add_imgui_app` post-build.
## Fase — Monitor tab + WebSocket live stream `[done 2026-05-14, issue 0086]`
Rebranding "Claude Usage" → **Monitor**, ahora primera y por defecto en el TabBar.
Es el landing del bucke reactivo (construir → ejecutar → recopilar → analizar → mejorar).
Nuevos elementos UI:
- **Toolbar interna** del Monitor con preset de ventana temporal (1h / 24h / 7d / 30d / All),
boton Refresh manual, LED `live`/`offline` con timestamp del ultimo evento WS.
- **5 KPI cards** (era 4): añadido "Errors" derivado de `COUNT(*) FROM calls WHERE success = 0`
filtrado por la ventana activa.
- **Sub-tab "Recent Executions"** (la primera) con columnas: When, Function, Tool, ms, OK, Error.
Backed by `calls` table, sorted by ts DESC, limit 100, filtrada por ventana.
- Violations sub-tab gana columna "When" con ts formateado.
Pipeline en vivo (low-latency, push-based):
```
Hook PostToolUse ──► call_monitor CLI ──► INSERT calls (siempre, sincrono)
ops:call_monitor.db
│ (ticker 250ms cuando subs>0)
sqlite_api /api/events/call_monitor ──► WS hub ──► subscribers (dashboards)
```
- **sqlite_api**: nuevo endpoint `GET /api/events/call_monitor`. Hub gestiona subscribers,
ticker arranca solo con >=1 sub (cero overhead si no hay dashboards). Cliente recibe
snapshot inicial (KPIs + 100 ultimas) y luego deltas (`id > watermark`). Intervalo
adaptativo: 250ms activo → 2s idle (tras 30s sin eventos).
- **registry_dashboard**: cliente WS minimal RFC6455 en `ws_client.{h,cpp}` (no TLS,
thread propio, reconnect exponencial 0.5s → 8s). `main.cpp` consume deltas y los aplica
a `g_data.claude` (incrementa KPIs, anade filas, dedup por id).
- **Fallback**: si WS cae, los datos siguen registrandose en SQLite via call_monitor CLI.
Al reconectar, el cliente puede mandar `{"watermark": N}` para reanudar sin perder eventos.
Cambios en `data.{h,cpp}` y `data_http.{h,cpp}`:
- `RecentExecutionRow`, `window_secs`, `ws_connected`, `last_event_ts`, `last_seen_call_id`
en `ClaudeUsageData`.
- `load_claude_usage_http(api, out, window_secs)` filtra `calls` y `violations` por ventana.
- `load_recent_executions_http()` standalone para refetch parcial (preparado para WS,
no esta cableado todavia — actualmente las deltas WS bastan).
Decisiones de scope:
- Sec-WebSocket-Accept verification se omite (server controlado, localhost only).
- TLS fuera (ws://, no wss://).
- WS client no extraido al registry todavia: hasta que un segundo app C++ lo necesite.
## Notas — Settings submenu + Git column (sesion 2026-04-28)
- `fn_ui::app_menubar` reemplaza el item plano `Settings...` por un `BeginMenu("Settings")` con dos subitems: `Settings...` (existente) y `About...` (nuevo modulo `app_about_cpp_core`). El registry_dashboard cablea la info via `fn_ui::about_window_set_info("fn_registry Dashboard", "0.2.0", "Dashboard ImGui...")` antes de `fn::run_app`.
- Tabla `Apps` gana columna **Git**: `remote` si `repo_url` esta poblado en `apps.repo_url`, `local` si existe `<dir_path>/.git/`, `-` si nada. `AppRow` extendido con `repo_url` y `dir_path`; SELECT en `data.cpp` y `data_http.cpp` ampliado a 8 columnas.
- Build OK: `cmake --build build --target registry_dashboard` (Linux). La columna "Git" se ve sin reindexar.
## Capability growth log
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
- `patch`: bugfix sin cambio observable.
- v0.1.0 (2026-05-18) — baseline.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

+12
View File
@@ -46,6 +46,10 @@ struct FunctionRow {
std::string description;
std::string created_at;
bool tested = false;
// JSON array string ("[\"a\",\"b\"]") con IDs de funciones consumidas.
// Se carga junto con la lista para soportar reverse lookup "Used by" en
// el tab Dependencies del Explorer sin endpoint extra.
std::string uses_functions;
};
struct AppRow {
@@ -176,6 +180,8 @@ struct RecentExecutionRow {
int duration_ms = 0;
bool success = true;
std::string error_class;
std::string error_snippet; // texto de error si success=false
std::string command_snippet; // raw command (truncado/redactado) cuando function_id vacio
std::string session_id;
};
@@ -186,6 +192,12 @@ struct ClaudeUsageData {
int total_violations = 0;
int total_copies = 0;
int total_versions = 0;
// Calls que Claude lanza via tools registry-aware: MCP del registry
// (mcp), `fn run` (fn_cli_run) o heredocs python. Excluye bash plano.
int total_mcp = 0;
// % de filas en la ventana con function_id != '' — es decir, calls que
// golpean una funcion registrada. Indicador clave de adopcion del registry.
double registry_pct = 0.0;
std::vector<ClaudeUsageRow> top_functions; // top 20 by calls_total
std::vector<ClaudeViolationRow> recent_violations; // last 20
std::vector<ClaudeCopiedRow> copies;
+33 -4
View File
@@ -373,7 +373,7 @@ bool load_all_functions_http(const std::string& api_url,
HttpClient cli(host, port);
auto j = api_query(cli,
"SELECT id, name, lang, domain, kind, purity, description, "
"created_at, tested FROM functions ORDER BY name");
"created_at, tested, uses_functions FROM functions ORDER BY name");
if (j.is_null() || !j.contains("rows")) return false;
out.clear();
@@ -389,6 +389,7 @@ bool load_all_functions_http(const std::string& api_url,
r.description = extract_str(row, 6);
r.created_at = extract_str(row, 7);
r.tested = extract_row_int(row, 8) != 0;
r.uses_functions = extract_str(row, 9);
out.push_back(std::move(r));
}
return true;
@@ -612,12 +613,38 @@ bool load_claude_usage_http(const std::string& api_url, RegistryData& out,
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()));
}
// MCP / fn run / heredoc — herramientas registry-aware. Cubre las
// variantes vistas en produccion: mcp, mcp_fn_search, mcp_fn_run,
// fn_cli_run, fn_run_cli, heredoc, heredoc_py.
static const char* kRegistryAwareToolCond =
"(tool_used LIKE 'mcp%' OR tool_used LIKE 'heredoc%' "
"OR tool_used IN ('fn_cli_run','fn_run_cli'))";
{
const std::string wf_and = wf_calls.empty()
? std::string(" WHERE ") + kRegistryAwareToolCond
: std::string(wf_calls + " AND " + kRegistryAwareToolCond);
const std::string sql = "SELECT COUNT(*) FROM calls" + wf_and;
out.claude.total_mcp = extract_int(call_monitor_query(cli, sql.c_str()));
}
// % calls que llamaron a una funcion del registry (function_id no vacio).
{
const std::string wf_and = wf_calls.empty()
? std::string(" WHERE function_id != '' ")
: std::string(wf_calls + " AND function_id != '' ");
const std::string sql = "SELECT COUNT(*) FROM calls" + wf_and;
int reg_hits = extract_int(call_monitor_query(cli, sql.c_str()));
out.claude.registry_pct = (out.claude.total_calls > 0)
? 100.0 * static_cast<double>(reg_hits) / static_cast<double>(out.claude.total_calls)
: 0.0;
}
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 "
std::string sql = "SELECT id, ts, function_id, tool_used, duration_ms, success, error_class, session_id, "
"COALESCE(command_snippet,'') AS command_snippet, "
"COALESCE(error_snippet,'') AS error_snippet "
"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")) {
@@ -630,8 +657,10 @@ bool load_claude_usage_http(const std::string& api_url, RegistryData& out,
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);
row.error_class = extract_str(r, 6);
row.session_id = extract_str(r, 7);
row.command_snippet = extract_str(r, 8);
row.error_snippet = extract_str(r, 9);
if (row.id > mx) mx = row.id;
out.claude.recent_executions.push_back(row);
}
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Lanza registry_dashboard en Linux en modo completo, conectando a sqlite_api
# (HTTP + WebSocket), stageando el ejecutable a ~/fn_apps/registry_dashboard/ para que todos los
# artefactos de runtime (imgui.ini, app_settings.ini, layouts.db, logs) queden
# contenidos en esa carpeta y no se mezclen con el arbol de build ni con el
# escritorio.
#
# Por que stagear: fn::run_app guarda local_files/ JUNTO al ejecutable
# (fn::exe_dir() resuelve /proc/self/exe, asi que un symlink no basta — apunta
# al binario real en cpp/build). Copiando el binario a su propia carpeta de
# despliegue, local_files/ se crea alli.
#
# Modo de datos: se pasa --api http://127.0.0.1:8484 (datos por HTTP mas el
# stream en vivo del Monitor tab por WebSocket) y, como segundo argumento, el
# path a registry.db. Si sqlite_api no responde, el binario cae automaticamente
# a ese SQLite local, asi que el lanzamiento funciona con o sin el servicio
# arriba: con servicio se obtiene el Monitor en tiempo real; sin el, datos
# estaticos del registry.db.
#
# Ademas se exporta FN_REGISTRY_ROOT para que la tab Work localice el binario
# dev_console (apps/dev_console/dev_console), que alimenta su panel de issues,
# flows y KPIs de DoD. Sin esa variable la tab Work cae a un placeholder.
set -euo pipefail
# Raiz del repo: este script vive en projects/fn_monitoring/apps/registry_dashboard/,
# cuatro niveles por debajo de la raiz de fn_registry.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="$(cd "$SCRIPT_DIR/../../../.." && pwd)"
BIN_SRC="$ROOT/cpp/build/apps/registry_dashboard/registry_dashboard"
ASSETS_SRC="$ROOT/cpp/build/apps/registry_dashboard/assets"
DB="$ROOT/registry.db"
APP_HOME="$HOME/fn_apps/registry_dashboard"
if [ ! -x "$BIN_SRC" ]; then
echo "Binario no encontrado: $BIN_SRC" >&2
echo "Compila primero:" >&2
echo " cmake -S $ROOT/cpp -B $ROOT/cpp/build -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF" >&2
echo " cmake --build $ROOT/cpp/build --target registry_dashboard -j\$(nproc)" >&2
exit 1
fi
if [ ! -f "$DB" ]; then
echo "registry.db no encontrado: $DB" >&2
echo "Regenera el indice: (cd $ROOT && ./fn index)" >&2
exit 1
fi
# Stage del ejecutable + assets a su carpeta de despliegue. -u copia solo si la
# fuente es mas nueva, asi que tras un rebuild se actualiza automaticamente.
# local_files/ NO se toca: persiste config y layouts entre lanzamientos.
mkdir -p "$APP_HOME"
cp -u "$BIN_SRC" "$APP_HOME/registry_dashboard"
if [ -d "$ASSETS_SRC" ]; then
mkdir -p "$APP_HOME/assets"
cp -ru "$ASSETS_SRC/." "$APP_HOME/assets/"
fi
# Lanzar desde APP_HOME → fn::exe_dir() apunta aqui → local_files/ vive aqui.
# --api http://127.0.0.1:8484 activa HTTP + WebSocket (Monitor en vivo); el path
# a registry.db queda como fallback si sqlite_api no responde. FN_REGISTRY_ROOT
# permite que la tab Work localice el binario dev_console.
export FN_REGISTRY_ROOT="$ROOT"
cd "$APP_HOME"
exec "$APP_HOME/registry_dashboard" --api "http://127.0.0.1:8484" "$DB"
+165 -5
View File
@@ -8,18 +8,161 @@
#include "data.h"
#include "data_http.h"
#include "views.h"
#include "ws_client.h"
#include "nlohmann/json.hpp"
#include <cstdio>
#include <cstdlib>
#include <cstring>
#include <string>
#include <fstream>
#include <vector>
static RegistryData g_data;
static std::string g_db_path;
static std::string g_api_url;
static bool g_loaded = false;
static bool g_using_http = false;
static WsClient g_ws;
// Parse "http://host:port" → host, port. Devuelve false si no encaja.
static bool parse_http_url(const std::string& url, std::string& host, int& port) {
static const char* kPrefix = "http://";
if (url.rfind(kPrefix, 0) != 0) return false;
std::string rest = url.substr(std::strlen(kPrefix));
auto colon = rest.find(':');
if (colon == std::string::npos) {
host = rest;
port = 80;
return true;
}
host = rest.substr(0, colon);
auto slash = rest.find('/', colon + 1);
std::string port_str = (slash == std::string::npos)
? rest.substr(colon + 1)
: rest.substr(colon + 1, slash - colon - 1);
port = std::atoi(port_str.c_str());
return port > 0;
}
// Aplica un mensaje JSON recibido por WS a g_data.claude. Tipos:
// - "snapshot": reemplaza KPIs y la lista entera de recent_executions.
// - "delta": append rows (front), dedup por id, recalcula KPIs.
// Devuelve true si el mensaje era valido.
static bool apply_ws_message(const std::string& raw) {
using nlohmann::json;
json msg = json::parse(raw, nullptr, false);
if (!msg.is_object()) return false;
const std::string type = msg.value("type", "");
if (type != "snapshot" && type != "delta") return false;
if (msg.contains("server_time") && msg["server_time"].is_number_integer()) {
g_data.claude.last_event_ts = msg["server_time"].get<long long>();
}
if (msg.contains("watermark") && msg["watermark"].is_number_integer()) {
long long w = msg["watermark"].get<long long>();
if (w > g_data.claude.last_seen_call_id) g_data.claude.last_seen_call_id = w;
}
// Snapshot reemplaza KPIs. Delta los actualiza por incremento.
if (type == "snapshot" && msg.contains("stats") && msg["stats"].is_object()) {
const auto& s = msg["stats"];
g_data.claude.total_calls = s.value("total_calls", 0);
g_data.claude.total_errors = s.value("total_errors", 0);
g_data.claude.total_violations = s.value("total_violations", 0);
g_data.claude.total_copies = s.value("total_copies", 0);
g_data.claude.total_versions = s.value("total_versions", 0);
g_data.claude.available = true;
}
if (!msg.contains("calls") || !msg["calls"].is_array()) return true;
if (type == "snapshot") {
g_data.claude.recent_executions.clear();
}
// Construye filas nuevas
std::vector<RecentExecutionRow> incoming;
incoming.reserve(msg["calls"].size());
int new_errors = 0;
int new_mcp = 0;
int new_reg_hits = 0;
for (const auto& c : msg["calls"]) {
RecentExecutionRow row;
row.id = c.value("id", 0LL);
row.ts = c.value("ts", 0LL);
row.function_id = c.value("function_id", "");
row.tool_used = c.value("tool_used", "");
row.duration_ms = c.value("duration_ms", 0);
row.success = c.value("success", true);
row.error_class = c.value("error_class", "");
row.error_snippet = c.value("error_snippet", "");
row.command_snippet = c.value("command_snippet", "");
row.session_id = c.value("session_id", "");
if (!row.success) new_errors++;
// Registry-aware tools: mcp*, heredoc*, fn_cli_run, fn_run_cli.
const auto starts_with = [](const std::string& s, const char* p) {
size_t lp = std::strlen(p);
return s.size() >= lp && std::memcmp(s.c_str(), p, lp) == 0;
};
if (starts_with(row.tool_used, "mcp") ||
starts_with(row.tool_used, "heredoc") ||
row.tool_used == "fn_cli_run" ||
row.tool_used == "fn_run_cli") {
new_mcp++;
}
if (!row.function_id.empty()) new_reg_hits++;
incoming.push_back(std::move(row));
}
if (type == "delta") {
g_data.claude.total_calls += static_cast<int>(incoming.size());
g_data.claude.total_errors += new_errors;
g_data.claude.total_mcp += new_mcp;
// registry_pct se recalcula sobre el total acumulado tras el delta.
// Aproximacion: prev_pct * prev_total + new_reg_hits / new_total.
int prev_total = g_data.claude.total_calls - static_cast<int>(incoming.size());
int prev_hits = static_cast<int>(g_data.claude.registry_pct *
static_cast<double>(prev_total) / 100.0 + 0.5);
int total_hits = prev_hits + new_reg_hits;
g_data.claude.registry_pct = (g_data.claude.total_calls > 0)
? 100.0 * static_cast<double>(total_hits) / static_cast<double>(g_data.claude.total_calls)
: 0.0;
}
// Prepend (newer al frente). Para delta: filas vienen ASC del server,
// las anadimos al frente en orden inverso. Para snapshot: ya vienen DESC.
if (type == "delta") {
for (auto it = incoming.rbegin(); it != incoming.rend(); ++it) {
g_data.claude.recent_executions.insert(
g_data.claude.recent_executions.begin(), std::move(*it));
}
} else {
for (auto& row : incoming) {
g_data.claude.recent_executions.push_back(std::move(row));
}
}
// Cap list (UI muestra ~100). Evita crecer indefinidamente con deltas.
const size_t kCap = 200;
if (g_data.claude.recent_executions.size() > kCap) {
g_data.claude.recent_executions.resize(kCap);
}
return true;
}
static void poll_ws() {
bool connected = g_ws.is_connected();
long long ts = g_ws.last_event_ts();
monitor_set_ws_state(connected, ts);
std::vector<std::string> msgs;
g_ws.drain(msgs, 32);
for (const auto& m : msgs) {
apply_ws_message(m);
}
}
static void reload_data() {
// Conservar la ventana del Monitor entre recargas (no se pierde al refrescar).
@@ -73,6 +216,11 @@ static void render() {
reload_monitor();
}
// Issue 0086: drena la cola de mensajes WS y aplica deltas a g_data.
// No bloquea — drain es O(N) sobre los mensajes encolados desde el
// ultimo frame (tipicamente 0-3).
poll_ws();
if (!g_loaded) {
fullscreen_window_begin("##error");
ImGui::TextColored(ImVec4(1, 0.3f, 0.3f, 1),
@@ -140,11 +288,12 @@ int main(int argc, char** argv) {
// Info de la ventana About (submenu Settings → About...)
fn_ui::about_window_set_info(
"fn_registry Dashboard",
"0.3.0",
"Dashboard ImGui para visualizar el estado del fn_registry. "
"Consume datos via sqlite_api HTTP (fallback a SQLite directo). "
"KPIs con sparkline, charts con leyenda, tablas, altura responsive, "
"Status panel en Settings, multi-viewport, dashboard_panel en views."
"0.4.0",
"Dashboard ImGui del fn_registry. Pestana Monitor por defecto con KPIs "
"live (Calls / Errors / Violations / Copies / Versions), Recent Executions "
"con timestamp, filtro de ventana (1h/24h/7d/30d/All) y WS subscription "
"al hub /api/events/call_monitor de sqlite_api. Resto de tabs (Dashboard, "
"Explorer, Projects, Apps, Analysis, Types) sin cambios."
);
// Seccion Status dentro de la ventana Settings (submenu Settings → Settings...).
@@ -187,6 +336,17 @@ int main(int argc, char** argv) {
reload_data();
// Issue 0086: lanza el cliente WS al hub de eventos. El hub solo arranca
// su ticker cuando recibe el primer subscriber, asi que esta conexion
// tambien le dice al servidor "empieza a streamear".
{
std::string ws_host;
int ws_port = 0;
if (parse_http_url(g_api_url, ws_host, ws_port)) {
g_ws.start(ws_host, ws_port, "/api/events/call_monitor");
}
}
return fn::run_app(
{.title = "fn_registry Dashboard",
.width = 1600,
+793 -185
View File
File diff suppressed because it is too large Load Diff
+378
View File
@@ -0,0 +1,378 @@
// Tab "Work" — issue 0102.
//
// Subprocess call a `dev_console work dashboard` con cache 30s.
#include "work_tab.h"
#include "imgui.h"
#include "core/tokens.h"
#include "core/page_header.h"
#include "core/empty_state.h"
#include "core/badge.h"
#include "data_table/data_table.h"
#include "core/data_table_types.h"
#include "nlohmann/json.hpp"
#include <chrono>
#include <cstdio>
#include <cstdlib>
#include <filesystem>
#include <sstream>
#include <string>
#include <vector>
#ifdef _WIN32
#define POPEN _popen
#define PCLOSE _pclose
#else
#define POPEN popen
#define PCLOSE pclose
#endif
using json = nlohmann::json;
namespace fs = std::filesystem;
namespace {
struct IssueStats { int total{0}, pendiente{0}, in_progress{0}, bloqueado{0}, completado{0}; };
struct FlowSlim {
std::string id, name, status, pattern, risk, priority;
int acceptance_pct{0}, dod_pct{0}, user_facing_pct{0};
std::vector<std::string> apps;
};
struct IssueSlim {
std::string id, title, status, type, priority;
std::vector<std::string> domain, depends;
bool deps_resolved{false};
int acceptance_pct{0};
};
struct WorkData {
IssueStats issue_stats;
IssueStats flow_stats;
std::vector<FlowSlim> flows;
std::vector<IssueSlim> top_issues;
long long fetched_at_ms{0};
std::string fetch_error;
};
static WorkData g_data;
static bool g_first_fetch_done = false;
static const long long kCacheTtlMs = 30 * 1000;
// Localiza el binario dev_console.
static std::string find_dev_console() {
const char* root = std::getenv("FN_REGISTRY_ROOT");
std::vector<fs::path> candidates;
if (root && *root) candidates.emplace_back(fs::path(root) / "apps/dev_console/dev_console");
candidates.emplace_back("./apps/dev_console/dev_console");
candidates.emplace_back("../../../apps/dev_console/dev_console"); // from build/<cfg>/bin
candidates.emplace_back("dev_console");
for (auto& p : candidates) {
std::error_code ec;
if (fs::exists(p, ec)) return p.string();
}
return "dev_console"; // fallback: depend on PATH
}
static long long now_ms() {
using namespace std::chrono;
return duration_cast<milliseconds>(steady_clock::now().time_since_epoch()).count();
}
static bool fetch_work(WorkData& out) {
std::string bin = find_dev_console();
std::string cmd = bin + " work dashboard 2>/dev/null";
FILE* pipe = POPEN(cmd.c_str(), "r");
if (!pipe) {
out.fetch_error = "popen() failed for " + bin;
return false;
}
std::string body;
char buf[4096];
while (fgets(buf, sizeof(buf), pipe)) body.append(buf);
int rc = PCLOSE(pipe);
if (rc != 0) {
out.fetch_error = "dev_console exit " + std::to_string(rc) + " (binary at " + bin + ")";
return false;
}
try {
json j = json::parse(body);
auto parse_stats = [](const json& j, IssueStats& s) {
s.total = j.value("total", 0);
s.pendiente = j.value("pendiente", 0);
s.in_progress = j.value("in_progress", 0);
s.bloqueado = j.value("bloqueado", 0);
s.completado = j.value("completado", 0);
};
if (j.contains("issue_stats")) parse_stats(j["issue_stats"], out.issue_stats);
if (j.contains("flow_stats")) parse_stats(j["flow_stats"], out.flow_stats);
out.flows.clear();
if (j.contains("flows") && j["flows"].is_array()) {
for (auto& f : j["flows"]) {
FlowSlim fs;
fs.id = f.value("id", "");
fs.name = f.value("name", "");
fs.status = f.value("status", "");
fs.pattern = f.value("pattern", "");
fs.risk = f.value("risk", "");
fs.priority = f.value("priority", "");
fs.acceptance_pct = f.value("acceptance_pct", 0);
fs.dod_pct = f.value("dod_pct", 0);
fs.user_facing_pct = f.value("user_facing_pct", 0);
if (f.contains("apps") && f["apps"].is_array()) {
for (auto& a : f["apps"]) fs.apps.push_back(a.get<std::string>());
}
out.flows.push_back(std::move(fs));
}
}
out.top_issues.clear();
if (j.contains("top_issues") && j["top_issues"].is_array()) {
for (auto& i : j["top_issues"]) {
IssueSlim is;
is.id = i.value("id", "");
is.title = i.value("title", "");
is.status = i.value("status", "");
is.type = i.value("type", "");
is.priority = i.value("priority", "");
is.deps_resolved = i.value("deps_resolved", false);
is.acceptance_pct = i.value("acceptance_pct", 0);
if (i.contains("domain") && i["domain"].is_array()) {
for (auto& d : i["domain"]) is.domain.push_back(d.get<std::string>());
}
if (i.contains("depends") && i["depends"].is_array()) {
for (auto& d : i["depends"]) is.depends.push_back(d.get<std::string>());
}
out.top_issues.push_back(std::move(is));
}
}
out.fetch_error.clear();
out.fetched_at_ms = now_ms();
return true;
} catch (const std::exception& e) {
out.fetch_error = std::string("parse: ") + e.what();
return false;
}
}
static void maybe_refetch(bool force = false) {
long long now = now_ms();
if (!force && g_first_fetch_done && (now - g_data.fetched_at_ms) < kCacheTtlMs) return;
fetch_work(g_data);
g_first_fetch_done = true;
}
static const char* join_apps(const std::vector<std::string>& apps, std::string& buf) {
buf.clear();
for (size_t i = 0; i < apps.size(); ++i) {
if (i) buf += ", ";
buf += apps[i];
}
return buf.c_str();
}
static const char* join_domain(const std::vector<std::string>& dom, std::string& buf) {
buf.clear();
for (size_t i = 0; i < dom.size(); ++i) {
if (i) buf += ",";
buf += dom[i];
}
return buf.c_str();
}
static ImVec4 prio_color(const std::string& prio) {
if (prio == "alta" || prio == "high") return fn_tokens::colors::error;
if (prio == "media" || prio == "medium") return fn_tokens::colors::warning;
return fn_tokens::colors::text_muted;
}
static void draw_kpi_block(const char* title, const IssueStats& s) {
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextUnformatted(title);
ImGui::PopStyleColor();
ImGui::Text("total=%d pendiente=%d in-progress=%d bloqueado=%d completado=%d",
s.total, s.pendiente, s.in_progress, s.bloqueado, s.completado);
}
} // namespace
void draw_work_tab() {
maybe_refetch();
// Header + refresh
page_header_begin("Work", "Issues + flows + KPIs (issue 0102)");
if (ImGui::Button("Refresh")) maybe_refetch(true);
ImGui::SameLine();
long long age_ms = now_ms() - g_data.fetched_at_ms;
long long age_s = age_ms / 1000;
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::Text("fetched %lld s ago", age_s);
ImGui::PopStyleColor();
page_header_end();
if (!g_data.fetch_error.empty()) {
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error);
ImGui::Text("dev_console error: %s", g_data.fetch_error.c_str());
ImGui::PopStyleColor();
ImGui::Spacing();
ImGui::TextWrapped("Build the binary with: cd apps/dev_console && go build -o dev_console .");
return;
}
// KPI block
ImGui::Spacing();
draw_kpi_block("Issues", g_data.issue_stats);
ImGui::Spacing();
draw_kpi_block("Flows", g_data.flow_stats);
ImGui::Spacing();
ImGui::Separator();
ImGui::Spacing();
// Flows table — migrado a data_table::render (issue 0107g)
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextUnformatted("Flows");
ImGui::PopStyleColor();
{
static data_table::State g_st_flows;
static std::vector<std::string> g_back_flows;
static std::vector<const char*> g_ptrs_flows;
g_back_flows.clear();
std::string tmp_buf;
for (const auto& f : g_data.flows) {
g_back_flows.push_back(f.id);
g_back_flows.push_back(f.name);
g_back_flows.push_back(f.pattern.empty() ? "-" : f.pattern);
g_back_flows.push_back(f.status);
g_back_flows.push_back(f.risk);
g_back_flows.push_back(std::to_string(f.acceptance_pct) + "%");
g_back_flows.push_back(std::to_string(f.dod_pct) + "%");
g_back_flows.push_back(std::to_string(f.user_facing_pct) + "%");
}
g_ptrs_flows.clear();
for (const auto& s : g_back_flows) g_ptrs_flows.push_back(s.c_str());
data_table::TableInput tbl;
tbl.name = "flows_work";
tbl.headers = {"ID", "Name", "Pattern", "Status", "Risk", "Accept", "DoD", "UserFace"};
tbl.types = {
data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String, data_table::ColumnType::String,
};
tbl.cells = g_ptrs_flows.empty() ? nullptr : g_ptrs_flows.data();
tbl.rows = (int)g_data.flows.size();
tbl.cols = 8;
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i];
// Status → CategoricalChip
tbl.column_specs[3].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[3].chips = {
{"activo", "#22c55e"}, {"done", "#22c55e"},
{"in-progress","#f59e0b"}, {"bloqueado","#ef4444"},
{"pendiente", "#a3a3a3"},
};
// Risk → CategoricalChip
tbl.column_specs[4].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[4].chips = {
{"alta", "#ef4444"}, {"high", "#ef4444"},
{"media", "#f59e0b"}, {"medium", "#f59e0b"},
{"baja", "#22c55e"}, {"low", "#22c55e"},
};
ImGui::BeginChild("##flows_work_host", ImVec2(-1, 220));
data_table::render("##flows_work_dt", {tbl}, g_st_flows, nullptr);
ImGui::EndChild();
}
ImGui::Spacing();
ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted);
ImGui::TextUnformatted("Top issues (priority alta, not done)");
ImGui::PopStyleColor();
// Top issues table — migrado a data_table::render (issue 0107g)
// Nota: la columna Deps original tenia colores condicionales (verde/amber segun deps_resolved).
// Mapeado a CategoricalChip: "-" (gris), "OK" (verde), "blocked" (amber).
{
static data_table::State g_st_issues;
static std::vector<std::string> g_back_issues;
static std::vector<const char*> g_ptrs_issues;
g_back_issues.clear();
std::string dom_buf;
for (const auto& iss : g_data.top_issues) {
g_back_issues.push_back(iss.id);
g_back_issues.push_back(iss.title);
g_back_issues.push_back(iss.type);
// domain: join con coma
dom_buf.clear();
for (size_t k = 0; k < iss.domain.size(); ++k) {
if (k) dom_buf += ",";
dom_buf += iss.domain[k];
}
g_back_issues.push_back(dom_buf);
g_back_issues.push_back(iss.status);
// deps: mostrar "-" / "OK" / "blocked"
if (iss.depends.empty()) {
g_back_issues.push_back("-");
} else if (iss.deps_resolved) {
g_back_issues.push_back("OK");
} else {
g_back_issues.push_back("blocked");
}
g_back_issues.push_back(iss.priority);
}
g_ptrs_issues.clear();
for (const auto& s : g_back_issues) g_ptrs_issues.push_back(s.c_str());
data_table::TableInput tbl;
tbl.name = "top_issues_work";
tbl.headers = {"ID", "Title", "Type", "Domain", "Status", "Deps", "Prio"};
tbl.types = {
data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String, data_table::ColumnType::String,
data_table::ColumnType::String,
};
tbl.cells = g_ptrs_issues.empty() ? nullptr : g_ptrs_issues.data();
tbl.rows = (int)g_data.top_issues.size();
tbl.cols = 7;
tbl.column_specs.resize(tbl.cols);
for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i];
// Status → CategoricalChip
tbl.column_specs[4].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[4].chips = {
{"pendiente", "#a3a3a3"},
{"in-progress", "#f59e0b"},
{"bloqueado", "#ef4444"},
{"completado", "#22c55e"},
};
// Deps → CategoricalChip (verde OK / amber blocked / gris -)
tbl.column_specs[5].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[5].chips = {
{"OK", "#22c55e"},
{"blocked", "#f59e0b"},
{"-", "#a3a3a3"},
};
// Prio → CategoricalChip
tbl.column_specs[6].renderer = data_table::CellRenderer::CategoricalChip;
tbl.column_specs[6].chips = {
{"alta", "#ef4444"}, {"high", "#ef4444"},
{"media", "#f59e0b"}, {"medium", "#f59e0b"},
{"baja", "#22c55e"}, {"low", "#22c55e"},
};
ImGui::BeginChild("##top_issues_work_host", ImVec2(-1, -1));
data_table::render("##top_issues_work_dt", {tbl}, g_st_issues, nullptr);
ImGui::EndChild();
}
}
+16
View File
@@ -0,0 +1,16 @@
#pragma once
// Tab "Work" del registry_dashboard — issue 0102.
//
// Consume `dev_console work dashboard --json` (apps/dev_console) cada N
// segundos y renderiza: KPIs de issues por estado + tabla de flows con
// Acceptance/DoD/User-facing % + top issues priorizados.
//
// Cache 30s para no spammear el subproceso. Mostrar "stale (Ns)" en header
// si la cache supera la edad.
//
// El binario `dev_console` debe estar accesible en PATH o en
// `<repo_root>/apps/dev_console/dev_console`. Sin binario -> placeholder
// con instrucciones de build.
void draw_work_tab();
+404
View File
@@ -0,0 +1,404 @@
#include "ws_client.h"
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <cstring>
#include <random>
#include <sstream>
#include <vector>
#ifdef _WIN32
#include <winsock2.h>
#include <ws2tcpip.h>
#pragma comment(lib, "ws2_32.lib")
typedef SOCKET sock_t;
#define SOCK_INVALID INVALID_SOCKET
#define SOCK_CLOSE closesocket
#define SOCK_ERR WSAGetLastError()
#define FN_SOCK_NONBLOCK(s) do { u_long m = 1; ioctlsocket((s), FIONBIO, &m); } while (0)
#else
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
typedef int sock_t;
#define SOCK_INVALID (-1)
#define SOCK_CLOSE close
#define SOCK_ERR errno
#define FN_SOCK_NONBLOCK(s) do { int f = fcntl((s), F_GETFL, 0); fcntl((s), F_SETFL, f | O_NONBLOCK); } while (0)
#endif
#ifdef _WIN32
static bool wsa_init_ws() {
static bool inited = false;
static std::once_flag flag;
std::call_once(flag, []() {
WSADATA wsa;
WSAStartup(MAKEWORD(2, 2), &wsa);
});
return true;
}
#endif
namespace {
// ----- Base64 (small, sufficient for 16-byte WS key) -----
const char* kBase64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::string base64_encode(const uint8_t* in, size_t len) {
std::string out;
out.reserve(((len + 2) / 3) * 4);
size_t i = 0;
for (; i + 3 <= len; i += 3) {
uint32_t v = (uint32_t(in[i]) << 16) | (uint32_t(in[i + 1]) << 8) | uint32_t(in[i + 2]);
out.push_back(kBase64[(v >> 18) & 63]);
out.push_back(kBase64[(v >> 12) & 63]);
out.push_back(kBase64[(v >> 6) & 63]);
out.push_back(kBase64[v & 63]);
}
if (i < len) {
uint32_t v = uint32_t(in[i]) << 16;
if (i + 1 < len) v |= uint32_t(in[i + 1]) << 8;
out.push_back(kBase64[(v >> 18) & 63]);
out.push_back(kBase64[(v >> 12) & 63]);
out.push_back(i + 1 < len ? kBase64[(v >> 6) & 63] : '=');
out.push_back('=');
}
return out;
}
// Send exactly n bytes (blocking).
bool send_all(sock_t sock, const char* data, size_t n) {
size_t sent = 0;
while (sent < n) {
int k = send(sock, data + sent, static_cast<int>(n - sent), 0);
if (k <= 0) return false;
sent += k;
}
return true;
}
// Receive exactly n bytes (blocking on a non-non-blocking socket).
bool recv_all(sock_t sock, char* data, size_t n) {
size_t got = 0;
while (got < n) {
int k = recv(sock, data + got, static_cast<int>(n - got), 0);
if (k <= 0) return false;
got += k;
}
return true;
}
// Receive up to n bytes; returns count, or -1 on error / -2 on would-block.
int recv_some(sock_t sock, char* data, size_t n) {
int k = recv(sock, data, static_cast<int>(n), 0);
if (k > 0) return k;
if (k == 0) return -1;
#ifdef _WIN32
if (WSAGetLastError() == WSAEWOULDBLOCK) return -2;
#else
if (errno == EAGAIN || errno == EWOULDBLOCK) return -2;
#endif
return -1;
}
} // namespace
WsClient::WsClient() = default;
WsClient::~WsClient() {
stop();
}
void WsClient::start(const std::string& host, int port, const std::string& path) {
State expected = State::Idle;
if (!state_.compare_exchange_strong(expected, State::Connecting)) return;
host_ = host;
port_ = port;
path_ = path;
stop_flag_.store(false);
worker_ = std::thread([this]() { this->run(); });
}
void WsClient::stop() {
stop_flag_.store(true);
int s = sock_.exchange(-1);
if (s != -1) SOCK_CLOSE(static_cast<sock_t>(s));
out_cv_.notify_all();
if (worker_.joinable()) worker_.join();
state_.store(State::Stopped);
}
int WsClient::drain(std::vector<std::string>& out, int max) {
std::lock_guard<std::mutex> g(in_mu_);
int n = 0;
while (!in_queue_.empty() && n < max) {
out.emplace_back(std::move(in_queue_.front()));
in_queue_.pop_front();
n++;
}
return n;
}
bool WsClient::send_text(const std::string& payload) {
if (state_.load() != State::Connected) return false;
{
std::lock_guard<std::mutex> g(out_mu_);
out_queue_.push_back(payload);
}
out_cv_.notify_one();
return true;
}
void WsClient::run() {
int backoff_ms = 500;
while (!stop_flag_.load()) {
state_.store(State::Connecting);
if (connect_once()) {
backoff_ms = 500; // reset on successful connect
state_.store(State::Connected);
read_loop();
}
state_.store(State::Backoff);
if (stop_flag_.load()) break;
// Exponential backoff: 0.5s → 1s → 2s → 4s → 8s (cap).
for (int slept = 0; slept < backoff_ms && !stop_flag_.load(); slept += 100) {
std::this_thread::sleep_for(std::chrono::milliseconds(100));
}
backoff_ms = std::min(backoff_ms * 2, 8000);
}
state_.store(State::Stopped);
}
bool WsClient::connect_once() {
#ifdef _WIN32
wsa_init_ws();
#endif
sock_t sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
if (sock == SOCK_INVALID) return false;
// 5s connect timeout via SO_*TIMEO. Stays blocking afterwards for the
// handshake; read_loop switches to non-blocking with select().
#ifdef _WIN32
DWORD timeout_ms = 5000;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms));
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms));
#else
struct timeval tv;
tv.tv_sec = 5;
tv.tv_usec = 0;
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv));
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));
#endif
struct sockaddr_in addr;
std::memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(static_cast<uint16_t>(port_));
addr.sin_addr.s_addr = inet_addr(host_.c_str());
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
SOCK_CLOSE(sock);
return false;
}
sock_.store(static_cast<int>(sock));
if (!handshake()) {
int s = sock_.exchange(-1);
if (s != -1) SOCK_CLOSE(static_cast<sock_t>(s));
return false;
}
// Non-blocking for the read loop.
FN_SOCK_NONBLOCK(sock);
return true;
}
bool WsClient::handshake() {
sock_t sock = static_cast<sock_t>(sock_.load());
// 16 random bytes → base64 → Sec-WebSocket-Key.
uint8_t key_raw[16];
std::random_device rd;
for (auto& b : key_raw) b = static_cast<uint8_t>(rd() & 0xff);
std::string key_b64 = base64_encode(key_raw, sizeof(key_raw));
std::ostringstream req;
req << "GET " << path_ << " HTTP/1.1\r\n";
req << "Host: " << host_ << ":" << port_ << "\r\n";
req << "Upgrade: websocket\r\n";
req << "Connection: Upgrade\r\n";
req << "Sec-WebSocket-Key: " << key_b64 << "\r\n";
req << "Sec-WebSocket-Version: 13\r\n";
req << "Origin: http://" << host_ << ":" << port_ << "\r\n";
req << "\r\n";
std::string raw = req.str();
if (!send_all(sock, raw.c_str(), raw.size())) return false;
// Read response headers (up to 4KB).
std::string resp;
char buf[1024];
while (resp.find("\r\n\r\n") == std::string::npos) {
int k = recv(sock, buf, sizeof(buf), 0);
if (k <= 0) return false;
resp.append(buf, k);
if (resp.size() > 4096) return false;
}
// Expect "HTTP/1.1 101".
if (resp.compare(0, 12, "HTTP/1.1 101") != 0 &&
resp.compare(0, 12, "HTTP/1.0 101") != 0) {
fprintf(stderr, "[ws] handshake failed: %.*s\n",
(int)std::min<size_t>(resp.size(), 120), resp.c_str());
return false;
}
// We intentionally skip Sec-WebSocket-Accept verification — controlled
// server, localhost-only, 101 status is enough for this app.
return true;
}
bool WsClient::read_loop() {
sock_t sock = static_cast<sock_t>(sock_.load());
std::vector<uint8_t> rb; // accumulated read buffer
rb.reserve(64 * 1024);
while (!stop_flag_.load()) {
// Block on select() for up to 100ms so we can both read and check
// the outgoing queue.
fd_set rfds;
FD_ZERO(&rfds);
FD_SET(sock, &rfds);
struct timeval tv;
tv.tv_sec = 0;
tv.tv_usec = 100 * 1000;
int sel = select(static_cast<int>(sock) + 1, &rfds, nullptr, nullptr, &tv);
if (sel < 0) return false;
if (sel > 0 && FD_ISSET(sock, &rfds)) {
uint8_t tmp[8192];
int k = recv_some(sock, reinterpret_cast<char*>(tmp), sizeof(tmp));
if (k == -1) return false;
if (k > 0) rb.insert(rb.end(), tmp, tmp + k);
}
// Drain outgoing queue (text frames, masked).
for (;;) {
std::string payload;
{
std::lock_guard<std::mutex> g(out_mu_);
if (out_queue_.empty()) break;
payload = std::move(out_queue_.front());
out_queue_.pop_front();
}
if (!send_frame(0x1, payload)) return false;
}
// Parse frames. RFC6455 minimal: assume server never masks, no
// continuation, opcodes: 0x1 text, 0x8 close, 0x9 ping, 0xA pong.
while (rb.size() >= 2) {
uint8_t b0 = rb[0];
uint8_t b1 = rb[1];
bool fin = (b0 & 0x80) != 0;
(void)fin;
int opcode = b0 & 0x0F;
bool mask = (b1 & 0x80) != 0;
uint64_t len = b1 & 0x7F;
size_t pos = 2;
if (len == 126) {
if (rb.size() < pos + 2) break;
len = (uint64_t(rb[pos]) << 8) | uint64_t(rb[pos + 1]);
pos += 2;
} else if (len == 127) {
if (rb.size() < pos + 8) break;
len = 0;
for (int i = 0; i < 8; i++) len = (len << 8) | rb[pos + i];
pos += 8;
}
uint8_t mkey[4] = {0, 0, 0, 0};
if (mask) {
if (rb.size() < pos + 4) break;
for (int i = 0; i < 4; i++) mkey[i] = rb[pos + i];
pos += 4;
}
if (rb.size() < pos + len) break;
std::string payload;
payload.resize(static_cast<size_t>(len));
for (size_t i = 0; i < len; i++) {
uint8_t c = rb[pos + i];
if (mask) c ^= mkey[i & 3];
payload[i] = static_cast<char>(c);
}
rb.erase(rb.begin(), rb.begin() + pos + len);
switch (opcode) {
case 0x1: { // text
last_event_ts_.store(std::chrono::duration_cast<std::chrono::seconds>(
std::chrono::system_clock::now().time_since_epoch()).count());
std::lock_guard<std::mutex> g(in_mu_);
in_queue_.emplace_back(std::move(payload));
// Bound the queue. Drop oldest if too big — UI consumes
// each frame so this should only kick in if the dashboard
// is paused (window minimized, etc.).
while (in_queue_.size() > 512) in_queue_.pop_front();
break;
}
case 0x8: // close
return false;
case 0x9: // ping → reply with pong (same payload)
if (!send_frame(0xA, payload)) return false;
break;
case 0xA: // pong, ignore
break;
default:
// 0x0 continuation or unexpected opcode: bail (server
// controlled by us, shouldn't happen).
return false;
}
}
}
return true;
}
bool WsClient::send_frame(int opcode, const std::string& payload) {
sock_t sock = static_cast<sock_t>(sock_.load());
if (sock == SOCK_INVALID || static_cast<int>(sock) < 0) return false;
std::vector<uint8_t> frame;
frame.reserve(payload.size() + 16);
frame.push_back(static_cast<uint8_t>(0x80 | (opcode & 0x0F))); // FIN + opcode
uint64_t len = payload.size();
if (len < 126) {
frame.push_back(static_cast<uint8_t>(0x80 | len)); // mask bit set
} else if (len <= 0xFFFF) {
frame.push_back(static_cast<uint8_t>(0x80 | 126));
frame.push_back(static_cast<uint8_t>((len >> 8) & 0xFF));
frame.push_back(static_cast<uint8_t>(len & 0xFF));
} else {
frame.push_back(static_cast<uint8_t>(0x80 | 127));
for (int i = 7; i >= 0; i--) frame.push_back(static_cast<uint8_t>((len >> (8 * i)) & 0xFF));
}
// Mask key (RFC requires mask for client → server frames).
std::random_device rd;
uint8_t mkey[4];
for (auto& b : mkey) b = static_cast<uint8_t>(rd() & 0xff);
for (int i = 0; i < 4; i++) frame.push_back(mkey[i]);
for (size_t i = 0; i < payload.size(); i++) {
frame.push_back(static_cast<uint8_t>(payload[i]) ^ mkey[i & 3]);
}
return send_all(sock, reinterpret_cast<const char*>(frame.data()), frame.size());
}
+74
View File
@@ -0,0 +1,74 @@
#pragma once
// Minimal WebSocket client (RFC 6455, ws:// only, no TLS) tailored for the
// Monitor tab. Background thread does connect + handshake + read loop and
// pushes incoming text payloads into a thread-safe queue that the ImGui
// thread drains each frame.
//
// Issue 0086 — first consumer of WS in the C++ dashboards. If a second app
// needs WS later, extract this file to cpp/functions/network/ via
// fn-constructor.
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <mutex>
#include <string>
#include <thread>
class WsClient {
public:
WsClient();
~WsClient();
// Non-blocking. Spawns background thread that connects to ws://host:port/path
// and keeps the connection alive with exponential reconnect backoff.
// Safe to call multiple times — second call is a no-op once running.
void start(const std::string& host, int port, const std::string& path);
// Stop the background thread and tear down the connection.
void stop();
// True while a WS connection is up and handshake completed.
bool is_connected() const { return state_.load() == State::Connected; }
// Epoch seconds of last received frame (for the live LED in the Monitor).
long long last_event_ts() const { return last_event_ts_.load(); }
// Drain at most `max` queued text payloads. Returns the number drained.
// Called once per frame from the render thread.
int drain(std::vector<std::string>& out, int max = 64);
// Send a text frame to the server. Returns true if queued for sending.
// Used to send {"watermark": N} commands on reconnect.
bool send_text(const std::string& payload);
private:
enum class State { Idle, Connecting, Connected, Backoff, Stopped };
void run();
bool connect_once();
bool handshake();
bool read_loop();
bool send_frame(int opcode, const std::string& payload);
std::string host_;
int port_ = 0;
std::string path_;
std::atomic<State> state_{State::Idle};
std::atomic<long long> last_event_ts_{0};
std::atomic<int> sock_{-1};
std::thread worker_;
std::atomic<bool> stop_flag_{false};
std::mutex in_mu_;
std::deque<std::string> in_queue_;
std::mutex out_mu_;
std::deque<std::string> out_queue_;
// For waking the writer side of read_loop when send_text is called.
std::condition_variable out_cv_;
};