Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8aa1146b45 | |||
| ca67f343df | |||
| 371266c52b | |||
| 0ac03fe5b0 | |||
| ff67e4e069 | |||
| ca436aa6cc | |||
| 092d03ba53 | |||
| 04cfa2aba0 | |||
| 9cbf2f7492 | |||
| 3f8c12db89 | |||
| 55a9c07e46 | |||
| 3d7c5bc0a1 | |||
| 00ee6a93e3 | |||
| 5c53cfcb68 | |||
| f7109a8ca0 | |||
| 9205567d5f |
@@ -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)
|
||||
|
||||
@@ -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
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
Executable
+65
@@ -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"
|
||||
@@ -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,
|
||||
|
||||
+378
@@ -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
@@ -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
@@ -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
@@ -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_;
|
||||
};
|
||||
Reference in New Issue
Block a user