feat: panel Timeline scatter (X=tiempo, Y=DAG, color=status) via ImPlot
- 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) <noreply@anthropic.com>
This commit is contained in:
@@ -22,11 +22,23 @@ static std::string g_ws_path = "/api/ws/dagruns";
|
|||||||
// Cache en memoria del primer fetch + ultimos eventos WS.
|
// Cache en memoria del primer fetch + ultimos eventos WS.
|
||||||
static std::vector<dag_ui::DagInfo> g_dags;
|
static std::vector<dag_ui::DagInfo> g_dags;
|
||||||
static std::vector<dag_ui::DagRunRow> g_live_runs; // upsert por id desde WS
|
static std::vector<dag_ui::DagRunRow> g_live_runs; // upsert por id desde WS
|
||||||
|
static std::vector<dag_ui::DagRunRow> g_runs_all; // cache para Timeline (snapshot REST + WS upsert)
|
||||||
static long long g_ws_runs_wm = 0;
|
static long long g_ws_runs_wm = 0;
|
||||||
static long long g_ws_steps_wm = 0;
|
static long long g_ws_steps_wm = 0;
|
||||||
static int g_ws_msg_count = 0;
|
static int g_ws_msg_count = 0;
|
||||||
static std::string g_last_error;
|
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;
|
static WsClient g_ws;
|
||||||
|
|
||||||
// Toggles de paneles (visibles desde el menu View del menubar canonico)
|
// 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_list = true;
|
||||||
static bool g_show_dag_detail = true;
|
static bool g_show_dag_detail = true;
|
||||||
static bool g_show_run_detail = true;
|
static bool g_show_run_detail = true;
|
||||||
|
static bool g_show_timeline = true;
|
||||||
|
|
||||||
// Auto-fetch DAG list una vez al arrancar.
|
// Auto-fetch DAG list una vez al arrancar.
|
||||||
static bool g_initial_fetched = false;
|
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.finished_at = rj.value("finished_at", "");
|
||||||
r.error = rj.value("error", "");
|
r.error = rj.value("error", "");
|
||||||
upsert_live_run(r);
|
upsert_live_run(r);
|
||||||
|
upsert_run_in_all(r);
|
||||||
// Cuando un run termina, refresca DAG List para que last_runs
|
// Cuando un run termina, refresca DAG List para que last_runs
|
||||||
// refleje la nueva ejecucion en R1..R5.
|
// refleje la nueva ejecucion en R1..R5.
|
||||||
if (r.status == "success" || r.status == "failed" ||
|
if (r.status == "success" || r.status == "failed" ||
|
||||||
@@ -139,6 +153,11 @@ static void render() {
|
|||||||
g_initial_fetched = true;
|
g_initial_fetched = true;
|
||||||
g_refresh_pending = false;
|
g_refresh_pending = false;
|
||||||
dag_ui::list_dags_http(g_api_url, g_dags);
|
dag_ui::list_dags_http(g_api_url, g_dags);
|
||||||
|
// Tambien snapshot inicial / refresh de runs para Timeline.
|
||||||
|
std::vector<dag_ui::DagRunRow> 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).
|
// 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_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_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_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_main) draw_main();
|
||||||
if (g_show_live) draw_live();
|
if (g_show_live) draw_live();
|
||||||
}
|
}
|
||||||
@@ -163,6 +183,7 @@ int main(int /*argc*/, char** /*argv*/) {
|
|||||||
{ "DAGs", nullptr, &g_show_dag_list },
|
{ "DAGs", nullptr, &g_show_dag_list },
|
||||||
{ "DAG Detail", nullptr, &g_show_dag_detail },
|
{ "DAG Detail", nullptr, &g_show_dag_detail },
|
||||||
{ "Run Detail", nullptr, &g_show_run_detail },
|
{ "Run Detail", nullptr, &g_show_run_detail },
|
||||||
|
{ "Timeline", nullptr, &g_show_timeline },
|
||||||
{ "Live (WS)", nullptr, &g_show_live },
|
{ "Live (WS)", nullptr, &g_show_live },
|
||||||
{ "Main (diag)", nullptr, &g_show_main },
|
{ "Main (diag)", nullptr, &g_show_main },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,8 +5,12 @@
|
|||||||
#include "core/empty_state.h"
|
#include "core/empty_state.h"
|
||||||
|
|
||||||
#include <imgui.h>
|
#include <imgui.h>
|
||||||
|
#include <implot.h>
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
|
#include <cmath>
|
||||||
#include <cstdio>
|
#include <cstdio>
|
||||||
|
#include <ctime>
|
||||||
|
#include <map>
|
||||||
|
|
||||||
namespace dag_ui_tabs {
|
namespace dag_ui_tabs {
|
||||||
|
|
||||||
@@ -406,4 +410,197 @@ void draw_run_detail(const std::string& api_url) {
|
|||||||
ImGui::End();
|
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<dag_ui::DagRunRow>& 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<double>(std::time(nullptr));
|
||||||
|
const int win_s = kTLWindowSecs[g_tl_window_idx];
|
||||||
|
const double left = now - static_cast<double>(win_s);
|
||||||
|
|
||||||
|
// Asignar Y index por DAG. Mantener orden alfabetico estable.
|
||||||
|
std::map<std::string, int> dag_y;
|
||||||
|
{
|
||||||
|
std::vector<std::string> 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<int>(i);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Buffers por status -> {xs, ys} para PlotScatter.
|
||||||
|
struct StatusBuf { std::vector<double> xs, ys; };
|
||||||
|
std::map<std::string, StatusBuf> 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<double>(t);
|
||||||
|
if (x < left || x > now + 10.0) continue;
|
||||||
|
double y = static_cast<double>(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<double> ticks;
|
||||||
|
std::vector<const char*> labels;
|
||||||
|
std::vector<std::string> 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<std::pair<std::string,int>> 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<double>(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<int>(ticks.size()),
|
||||||
|
labels.data());
|
||||||
|
ImPlot::SetupAxisLimits(ImAxis_Y1, -0.5,
|
||||||
|
static_cast<double>(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<int>(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<double>(t);
|
||||||
|
if (x < left || x > now + 10.0) continue;
|
||||||
|
double y = static_cast<double>(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
|
} // namespace dag_ui_tabs
|
||||||
|
|||||||
@@ -42,4 +42,9 @@ void draw_dag_detail(const std::string& api_url);
|
|||||||
|
|
||||||
void draw_run_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<dag_ui::DagRunRow>& runs_all);
|
||||||
|
|
||||||
} // namespace dag_ui_tabs
|
} // namespace dag_ui_tabs
|
||||||
|
|||||||
Reference in New Issue
Block a user