feat: cliente WebSocket + panel Live (issue 0095 step 4)

- ws_client.{h,cpp}: copia de registry_dashboard (RFC 6455 manual sobre TCP, sin TLS).
  Background thread, reconnect con backoff, drain por frame.
- main.cpp: arranca WsClient apuntando a /api/ws/dagruns. Drain por frame.
  Parse JSON snapshot/delta -> upsert g_live_runs por id.
  Panel "Live (WS)" muestra estado conexion, watermarks runs/steps, lista live runs.
- CMakeLists.txt: ws_client.cpp en sources.

Build verificado. Tabs DAG List/Detail/Run Detail con data_table::render() en commits siguientes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-15 16:47:18 +02:00
parent 44026d0a70
commit d01c7157a1
4 changed files with 567 additions and 14 deletions
+87 -14
View File
@@ -4,19 +4,67 @@
#include "core/icons_tabler.h"
#include "core/logger.h"
#include "data_http.h"
#include "ws_client.h"
#include "vendor/nlohmann/json.hpp"
#include <string>
#include <vector>
// Config global del backend HTTP. Persistido por imgui.ini via Mantine-no-go.
static std::string g_api_url = "http://127.0.0.1:8090";
using json = nlohmann::json;
// Cache en memoria del primer fetch. Tabs proximos updates via WS.
static std::vector<dag_ui::DagInfo> g_dags;
static std::string g_last_error;
// Config global del backend HTTP.
static std::string g_api_url = "http://127.0.0.1:8090";
static std::string g_ws_host = "127.0.0.1";
static int g_ws_port = 8090;
static std::string g_ws_path = "/api/ws/dagruns";
// Cache en memoria del primer fetch + ultimos eventos WS.
static std::vector<dag_ui::DagInfo> g_dags;
static std::vector<dag_ui::DagRunRow> g_live_runs; // upsert por id desde WS
static long long g_ws_runs_wm = 0;
static long long g_ws_steps_wm = 0;
static int g_ws_msg_count = 0;
static std::string g_last_error;
static WsClient g_ws;
// Toggles de paneles (visibles desde el menu View del menubar canonico)
static bool g_show_main = true;
static bool g_show_live = true;
// Upsert por id en g_live_runs.
static void upsert_live_run(const dag_ui::DagRunRow& r) {
for (auto& existing : g_live_runs) {
if (existing.id == r.id) {
existing = r;
return;
}
}
g_live_runs.push_back(r);
}
static void parse_ws_payload(const std::string& payload) {
auto j = json::parse(payload, nullptr, false);
if (!j.is_object()) return;
g_ws_msg_count++;
if (j.contains("watermark") && j["watermark"].is_object()) {
if (j["watermark"].contains("runs")) g_ws_runs_wm = j["watermark"]["runs"].get<long long>();
if (j["watermark"].contains("steps")) g_ws_steps_wm = j["watermark"]["steps"].get<long long>();
}
if (j.contains("runs") && j["runs"].is_array()) {
for (auto& rj : j["runs"]) {
dag_ui::DagRunRow r;
r.id = rj.value("id", "");
r.dag_name = rj.value("dag_name", "");
r.status = rj.value("status", "");
r.trigger = rj.value("trigger", "");
r.started_at = rj.value("started_at", "");
r.finished_at = rj.value("finished_at", "");
r.error = rj.value("error", "");
upsert_live_run(r);
}
}
}
static void draw_main() {
if (!ImGui::Begin(TI_HOME " Main", &g_show_main)) {
@@ -45,21 +93,46 @@ static void draw_main() {
ImGui::End();
}
static void render() {
// El framework dibuja menubar (View/Layouts/Settings/About) y un
// DockSpaceOverViewport central (auto_dockspace=true por defecto).
// Aqui solo se dibujan los paneles propios de la app.
if (g_show_main) draw_main();
static void draw_live() {
if (!ImGui::Begin(TI_BOLT " Live (WS)", &g_show_live)) {
ImGui::End();
return;
}
bool connected = g_ws.is_connected();
ImGui::TextColored(connected ? ImVec4(0.3f, 0.9f, 0.3f, 1) : ImVec4(0.9f, 0.4f, 0.4f, 1),
"%s", connected ? "connected" : "disconnected");
ImGui::SameLine();
ImGui::Text("| msgs=%d | wm runs=%lld steps=%lld",
g_ws_msg_count, g_ws_runs_wm, g_ws_steps_wm);
ImGui::Separator();
ImGui::Text("Live runs: %zu", g_live_runs.size());
for (auto& r : g_live_runs) {
ImGui::BulletText("%s [%s] %s @ %s",
r.dag_name.c_str(), r.status.c_str(),
r.id.c_str(), r.started_at.c_str());
}
ImGui::End();
}
// === Data panel (uncomment to enable) ===
// static data_table::State data_state;
// static std::vector<data_table::TableInput> data_tables; // populate from your source
// data_table::render("main_data", data_tables, data_state);
static void render() {
// Drain WS messages this frame (cheap, max 64).
{
std::vector<std::string> msgs;
g_ws.drain(msgs, 64);
for (auto& m : msgs) parse_ws_payload(m);
}
if (g_show_main) draw_main();
if (g_show_live) draw_live();
}
int main(int /*argc*/, char** /*argv*/) {
// Conecta WS al backend dag_engine. Reconnect con backoff lo gestiona WsClient.
g_ws.start(g_ws_host, g_ws_port, g_ws_path);
static fn_ui::PanelToggle panels[] = {
{ "Main", nullptr, &g_show_main },
{ "Live (WS)", nullptr, &g_show_live },
};
fn::AppConfig cfg;