From 4cb36a92e826610a61c06bd6bca379518e1c61d4 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Fri, 15 May 2026 17:14:11 +0200 Subject: [PATCH] feat: panel Timeline scatter (X=tiempo, Y=DAG, color=status) via ImPlot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - tabs.cpp draw_timeline: scatter ImPlot con eje X tiempo (UseLocalTime), eje Y categorico DAG (SetupAxisTicks), 1 serie por status con color consistente (verde/rojo/amarillo/gris). - Combo ventana: 15m/1h/6h/24h/7d. Default 24h. - Hover tooltip: punto mas cercano en pixel-space -> muestra status, dag, run id, started/finished, trigger, error. - main.cpp: g_runs_all cache. Snapshot inicial via list_runs_http(limit=200) + upserts desde WS deltas. Auto-refresh por g_refresh_pending. - Panel toggle "Timeline" en el menu View. - Helper parse_rfc3339 inline (ignora offset, asume hora local — coherente con ImPlot::UseLocalTime). Co-Authored-By: Claude Opus 4.7 (1M context) --- main.cpp | 21 ++++++ tabs.cpp | 197 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ tabs.h | 5 ++ 3 files changed, 223 insertions(+) diff --git a/main.cpp b/main.cpp index 1c7daf8..57f15e3 100644 --- a/main.cpp +++ b/main.cpp @@ -22,11 +22,23 @@ static std::string g_ws_path = "/api/ws/dagruns"; // Cache en memoria del primer fetch + ultimos eventos WS. static std::vector g_dags; static std::vector g_live_runs; // upsert por id desde WS +static std::vector g_runs_all; // cache para Timeline (snapshot REST + WS upsert) 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; +// Upsert por id en g_runs_all. Mantiene mas reciente al frente. +static void upsert_run_in_all(const dag_ui::DagRunRow& r) { + for (auto& existing : g_runs_all) { + if (existing.id == r.id) { + existing = r; + return; + } + } + g_runs_all.push_back(r); +} + static WsClient g_ws; // Toggles de paneles (visibles desde el menu View del menubar canonico) @@ -35,6 +47,7 @@ static bool g_show_live = true; static bool g_show_dag_list = true; static bool g_show_dag_detail = true; static bool g_show_run_detail = true; +static bool g_show_timeline = true; // Auto-fetch DAG list una vez al arrancar. static bool g_initial_fetched = false; @@ -75,6 +88,7 @@ static void parse_ws_payload(const std::string& payload) { r.finished_at = rj.value("finished_at", ""); r.error = rj.value("error", ""); upsert_live_run(r); + upsert_run_in_all(r); // Cuando un run termina, refresca DAG List para que last_runs // refleje la nueva ejecucion en R1..R5. if (r.status == "success" || r.status == "failed" || @@ -139,6 +153,11 @@ static void render() { g_initial_fetched = true; g_refresh_pending = false; dag_ui::list_dags_http(g_api_url, g_dags); + // Tambien snapshot inicial / refresh de runs para Timeline. + std::vector tmp; + if (dag_ui::list_runs_http(g_api_url, "", 200, tmp)) { + for (auto& r : tmp) upsert_run_in_all(r); + } } // Drain WS messages this frame (cheap, max 64). @@ -151,6 +170,7 @@ static void render() { if (g_show_dag_list) dag_ui_tabs::draw_dag_list(g_api_url, g_dags, g_live_runs); if (g_show_dag_detail) dag_ui_tabs::draw_dag_detail(g_api_url); if (g_show_run_detail) dag_ui_tabs::draw_run_detail(g_api_url); + if (g_show_timeline) dag_ui_tabs::draw_timeline(g_api_url, g_runs_all); if (g_show_main) draw_main(); if (g_show_live) draw_live(); } @@ -163,6 +183,7 @@ int main(int /*argc*/, char** /*argv*/) { { "DAGs", nullptr, &g_show_dag_list }, { "DAG Detail", nullptr, &g_show_dag_detail }, { "Run Detail", nullptr, &g_show_run_detail }, + { "Timeline", nullptr, &g_show_timeline }, { "Live (WS)", nullptr, &g_show_live }, { "Main (diag)", nullptr, &g_show_main }, }; diff --git a/tabs.cpp b/tabs.cpp index 4d3e302..750e1cf 100644 --- a/tabs.cpp +++ b/tabs.cpp @@ -5,8 +5,12 @@ #include "core/empty_state.h" #include +#include #include +#include #include +#include +#include namespace dag_ui_tabs { @@ -406,4 +410,197 @@ void draw_run_detail(const std::string& api_url) { ImGui::End(); } +// --------------------------------------------------------------------------- +// Timeline (ImPlot scatter X=tiempo, Y=DAG) +// --------------------------------------------------------------------------- + +// Parse RFC3339 "2026-05-15T17:01:20+02:00" -> epoch seconds (local time). +// Devuelve 0 si parse falla. +static time_t parse_rfc3339(const std::string& s) { + if (s.empty()) return 0; + std::tm tm{}; + // strptime no acepta timezone offsets en todos los libc. Hacemos manual: + // "YYYY-MM-DDTHH:MM:SS" -> los primeros 19 chars. Ignoramos el offset y + // tratamos como local time (coherente con ImPlot::UseLocalTime=true). + if (s.size() < 19) return 0; + tm.tm_year = std::atoi(s.substr(0, 4).c_str()) - 1900; + tm.tm_mon = std::atoi(s.substr(5, 2).c_str()) - 1; + tm.tm_mday = std::atoi(s.substr(8, 2).c_str()); + tm.tm_hour = std::atoi(s.substr(11, 2).c_str()); + tm.tm_min = std::atoi(s.substr(14, 2).c_str()); + tm.tm_sec = std::atoi(s.substr(17, 2).c_str()); + tm.tm_isdst = -1; + return std::mktime(&tm); +} + +static ImVec4 color_for_status(const std::string& st) { + if (st == "success") return ImVec4(0.30f, 0.85f, 0.40f, 0.95f); // verde + if (st == "failed") return ImVec4(0.95f, 0.35f, 0.30f, 0.95f); // rojo + if (st == "running") return ImVec4(0.95f, 0.80f, 0.20f, 0.95f); // amarillo + if (st == "pending") return ImVec4(0.60f, 0.65f, 0.75f, 0.85f); // gris azul + if (st == "cancelled") return ImVec4(0.50f, 0.50f, 0.50f, 0.85f); // gris + return ImVec4(0.70f, 0.70f, 0.70f, 0.70f); +} + +// Ventana de tiempo seleccionable. +static const char* kTLWindowLabels[] = {"15m", "1h", "6h", "24h", "7d"}; +static const int kTLWindowSecs[] = {900, 3600, 21600, 86400, 604800}; +static int g_tl_window_idx = 3; // 24h + +void draw_timeline(const std::string& api_url, + const std::vector& runs_all) +{ + if (!ImGui::Begin(TI_CHART_LINE " Timeline")) { + ImGui::End(); + return; + } + + ImGui::SetNextItemWidth(120); + if (ImGui::BeginCombo("Window", kTLWindowLabels[g_tl_window_idx])) { + for (int i = 0; i < IM_ARRAYSIZE(kTLWindowLabels); i++) { + bool sel = (i == g_tl_window_idx); + if (ImGui::Selectable(kTLWindowLabels[i], sel)) g_tl_window_idx = i; + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::SameLine(); + ImGui::TextDisabled("%zu runs en cache", runs_all.size()); + + if (runs_all.empty()) { + empty_state("( no data )", "No runs yet", + "Trigger a DAG (DAG Detail -> Run Now) o espera al scheduler."); + ImGui::End(); + return; + } + + // Calculate window bounds. + const double now = static_cast(std::time(nullptr)); + const int win_s = kTLWindowSecs[g_tl_window_idx]; + const double left = now - static_cast(win_s); + + // Asignar Y index por DAG. Mantener orden alfabetico estable. + std::map dag_y; + { + std::vector uniq; + for (auto& r : runs_all) { + if (!dag_y.count(r.dag_name)) { + dag_y[r.dag_name] = -1; + uniq.push_back(r.dag_name); + } + } + std::sort(uniq.begin(), uniq.end()); + for (size_t i = 0; i < uniq.size(); i++) dag_y[uniq[i]] = static_cast(i); + } + + // Buffers por status -> {xs, ys} para PlotScatter. + struct StatusBuf { std::vector xs, ys; }; + std::map by_status; + + for (auto& r : runs_all) { + if (r.dag_name.empty()) continue; + time_t t = parse_rfc3339(r.started_at); + if (t == 0) continue; + double x = static_cast(t); + if (x < left || x > now + 10.0) continue; + double y = static_cast(dag_y[r.dag_name]); + by_status[r.status].xs.push_back(x); + by_status[r.status].ys.push_back(y); + } + + ImPlot::GetStyle().UseLocalTime = true; + + // Y ticks = DAG names. + std::vector ticks; + std::vector labels; + std::vector labels_owner; // backing storage for c_str() + labels_owner.reserve(dag_y.size()); + ticks.reserve(dag_y.size()); + labels.reserve(dag_y.size()); + // Build sorted-by-y view + std::vector> pairs(dag_y.begin(), dag_y.end()); + std::sort(pairs.begin(), pairs.end(), + [](auto& a, auto& b){ return a.second < b.second; }); + for (auto& p : pairs) { + ticks.push_back(static_cast(p.second)); + labels_owner.push_back(p.first); + } + for (auto& s : labels_owner) labels.push_back(s.c_str()); + + const float plot_h = ImGui::GetContentRegionAvail().y - 4.0f; + if (ImPlot::BeginPlot("##dag_timeline", ImVec2(-1, plot_h), + ImPlotFlags_NoTitle | ImPlotFlags_NoMouseText)) + { + ImPlot::SetupAxis(ImAxis_X1, "time", + ImPlotAxisFlags_NoGridLines | ImPlotAxisFlags_NoHighlight); + ImPlot::SetupAxisScale(ImAxis_X1, ImPlotScale_Time); + ImPlot::SetupAxisLimits(ImAxis_X1, left, now + 10.0, ImPlotCond_Always); + + ImPlot::SetupAxis(ImAxis_Y1, "DAG", + ImPlotAxisFlags_NoGridLines | ImPlotAxisFlags_NoHighlight); + if (!ticks.empty()) { + ImPlot::SetupAxisTicks(ImAxis_Y1, ticks.data(), + static_cast(ticks.size()), + labels.data()); + ImPlot::SetupAxisLimits(ImAxis_Y1, -0.5, + static_cast(ticks.size()) - 0.5, + ImPlotCond_Always); + } + + for (auto& kv : by_status) { + if (kv.second.xs.empty()) continue; + ImPlotSpec spec; + spec.Marker = ImPlotMarker_Circle; + spec.MarkerSize = 6.0f; + ImVec4 col = color_for_status(kv.first); + spec.MarkerFillColor = col; + spec.MarkerLineColor = col; + spec.LineColor = col; + ImPlot::PlotScatter(kv.first.c_str(), + kv.second.xs.data(), + kv.second.ys.data(), + static_cast(kv.second.xs.size()), + spec); + } + + // Hover tooltip: closest run. + if (ImPlot::IsPlotHovered() && !runs_all.empty()) { + const ImVec2 mp_px = ImGui::GetIO().MousePos; + const double kHitRadiusPx = 12.0; + double best_dist = kHitRadiusPx; + const dag_ui::DagRunRow* best = nullptr; + for (auto& r : runs_all) { + if (!dag_y.count(r.dag_name)) continue; + time_t t = parse_rfc3339(r.started_at); + if (t == 0) continue; + double x = static_cast(t); + if (x < left || x > now + 10.0) continue; + double y = static_cast(dag_y[r.dag_name]); + ImVec2 px = ImPlot::PlotToPixels(x, y); + double dx = px.x - mp_px.x; + double dy = px.y - mp_px.y; + double d = std::sqrt(dx*dx + dy*dy); + if (d < best_dist) { best_dist = d; best = &r; } + } + if (best) { + ImGui::BeginTooltip(); + ImGui::TextColored(color_for_status(best->status), "%s", best->status.c_str()); + ImGui::Text("%s", best->dag_name.c_str()); + ImGui::Separator(); + ImGui::Text("run id: %s", best->id.c_str()); + ImGui::Text("started: %s", best->started_at.c_str()); + if (!best->finished_at.empty()) + ImGui::Text("finished: %s", best->finished_at.c_str()); + ImGui::Text("trigger: %s", best->trigger.c_str()); + if (!best->error.empty()) { + ImGui::TextColored(ImVec4(1,0.4f,0.4f,1), "err: %s", best->error.c_str()); + } + ImGui::EndTooltip(); + } + } + ImPlot::EndPlot(); + } + ImGui::End(); +} + } // namespace dag_ui_tabs diff --git a/tabs.h b/tabs.h index 2acf34e..ec4c6a1 100644 --- a/tabs.h +++ b/tabs.h @@ -42,4 +42,9 @@ void draw_dag_detail(const std::string& api_url); void draw_run_detail(const std::string& api_url); +// Timeline panel: scatter X=time, Y=DAG name, color por status. +// `runs_all` es el cache global de las ultimas N runs (mantenido por main.cpp). +void draw_timeline(const std::string& api_url, + const std::vector& runs_all); + } // namespace dag_ui_tabs