1bad5685a1
- app.md - appicon.ico - tabs.cpp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1043 lines
39 KiB
C++
1043 lines
39 KiB
C++
#include "tabs.h"
|
|
#include "data_table/data_table.h"
|
|
#include "core/data_table_types.h"
|
|
#include "core/icons_tabler.h"
|
|
#include "core/empty_state.h"
|
|
#include "core/badge.h"
|
|
|
|
#include <imgui.h>
|
|
#include <implot.h>
|
|
#include <algorithm>
|
|
#include <cmath>
|
|
#include <cstdio>
|
|
#include <ctime>
|
|
#include <map>
|
|
|
|
namespace dag_ui_tabs {
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Globals
|
|
// ---------------------------------------------------------------------------
|
|
|
|
Selection& selection() {
|
|
static Selection s;
|
|
return s;
|
|
}
|
|
|
|
Caches& caches() {
|
|
static Caches c;
|
|
return c;
|
|
}
|
|
|
|
FunctionPanelState& function_panel() {
|
|
static FunctionPanelState fps;
|
|
return fps;
|
|
}
|
|
|
|
// data_table::State persistente por panel (issue 0081-J pattern).
|
|
static data_table::State g_st_dag_list;
|
|
static data_table::State g_st_dag_runs;
|
|
static data_table::State g_st_run_steps;
|
|
|
|
// Backing storage para cells de cada tabla. Owner del char* const* en TableInput.
|
|
static std::vector<std::string> g_back_dag_list;
|
|
static std::vector<const char*> g_ptrs_dag_list;
|
|
|
|
static std::vector<std::string> g_back_dag_runs;
|
|
static std::vector<const char*> g_ptrs_dag_runs;
|
|
|
|
static std::vector<std::string> g_back_run_steps;
|
|
static std::vector<const char*> g_ptrs_run_steps;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void cells_to_ptrs(const std::vector<std::string>& backing,
|
|
std::vector<const char*>& ptrs) {
|
|
ptrs.resize(backing.size());
|
|
for (size_t i = 0; i < backing.size(); i++) ptrs[i] = backing[i].c_str();
|
|
}
|
|
|
|
static std::string format_duration(long long ms) {
|
|
if (ms <= 0) return "-";
|
|
if (ms < 1000) return std::to_string(ms) + "ms";
|
|
char buf[32];
|
|
std::snprintf(buf, sizeof(buf), "%.2fs", ms / 1000.0);
|
|
return buf;
|
|
}
|
|
|
|
static std::string status_for_dag(const std::string& dag_name,
|
|
const dag_ui::DagInfo& info,
|
|
const std::vector<dag_ui::DagRunRow>& live_runs) {
|
|
// Find most recent live run that matches this DAG.
|
|
const dag_ui::DagRunRow* best = nullptr;
|
|
for (auto& r : live_runs) {
|
|
if (r.dag_name != dag_name) continue;
|
|
if (!best || r.started_at > best->started_at) best = &r;
|
|
}
|
|
if (best) return best->status;
|
|
if (info.has_last_run) return info.last_run_status;
|
|
return "-";
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DAG List
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Forward decl — main.cpp owns the cache and the refresh trigger.
|
|
extern "C" void dag_list_request_refresh();
|
|
|
|
void draw_dag_list(const std::string& api_url,
|
|
const std::vector<dag_ui::DagInfo>& dags,
|
|
const std::vector<dag_ui::DagRunRow>& live_runs)
|
|
{
|
|
if (!ImGui::Begin(TI_LIST " DAGs")) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
if (ImGui::Button(TI_REFRESH " Refresh##dag_list")) {
|
|
dag_list_request_refresh();
|
|
}
|
|
ImGui::SameLine();
|
|
ImGui::TextDisabled("Double-click row -> inspect. Recent = last 5 runs inline.");
|
|
|
|
if (dags.empty()) {
|
|
empty_state("( no DAGs )", "Empty registry",
|
|
"Place a YAML in apps/dag_engine/dags_migrated/ and reload the server.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Build TableInput. 6 columns (was 10 with R1..R5 as separate Badge cols —
|
|
// that was an antipattern fixed in issue 0081-O.5). "Recent" uses CellRenderer::Dots
|
|
// to show up to 5 inline colored dots from a comma-separated status string.
|
|
data_table::TableInput ti;
|
|
ti.name = "dags";
|
|
ti.headers = {"Name", "Schedule", "Status", "Recent", "Tags", "Valid"};
|
|
ti.types = {
|
|
data_table::ColumnType::String, // Name
|
|
data_table::ColumnType::String, // Schedule
|
|
data_table::ColumnType::String, // Status (last run)
|
|
data_table::ColumnType::String, // Recent (dots: up to 5 runs)
|
|
data_table::ColumnType::String, // Tags
|
|
data_table::ColumnType::String, // Valid
|
|
};
|
|
ti.rows = static_cast<int>(dags.size());
|
|
ti.cols = static_cast<int>(ti.headers.size());
|
|
|
|
// BadgeRule set: shared by Recent (Dots).
|
|
auto run_status_badges = [](){
|
|
std::vector<data_table::BadgeRule> rules;
|
|
rules.push_back({"success", "#22c55e", ""}); // verde
|
|
rules.push_back({"failed", "#ef4444", ""}); // rojo
|
|
rules.push_back({"running", "#eab308", ""}); // amarillo
|
|
rules.push_back({"pending", "#94a3b8", ""}); // gris azulado
|
|
rules.push_back({"cancelled", "#6b7280", ""}); // gris
|
|
return rules;
|
|
};
|
|
// ChipRule set: Status (CategoricalChip — dot izquierda + texto, siempre visible).
|
|
auto run_status_chips = [](){
|
|
std::vector<data_table::ChipRule> rules;
|
|
rules.push_back({"success", "#22c55e"});
|
|
rules.push_back({"failed", "#ef4444"});
|
|
rules.push_back({"running", "#eab308"});
|
|
rules.push_back({"pending", "#94a3b8"});
|
|
rules.push_back({"cancelled", "#6b7280"});
|
|
return rules;
|
|
};
|
|
|
|
// ColumnSpec per column.
|
|
ti.column_specs.resize(ti.cols);
|
|
for (int i = 0; i < ti.cols; i++) ti.column_specs[i].id = ti.headers[i];
|
|
|
|
// idx 2 — "Status": CategoricalChip (dot izquierda + texto, always visible).
|
|
ti.column_specs[2].renderer = data_table::CellRenderer::CategoricalChip;
|
|
ti.column_specs[2].chips = run_status_chips();
|
|
|
|
// idx 3 — "Recent": Dots renderer — each dot = one of the last 5 runs.
|
|
ti.column_specs[3].renderer = data_table::CellRenderer::Dots;
|
|
ti.column_specs[3].badges = run_status_badges();
|
|
ti.column_specs[3].dots_max = 5;
|
|
ti.column_specs[3].dots_show_count = false;
|
|
ti.column_specs[3].tooltip_on_hover = true; // hover each dot -> show status string
|
|
|
|
g_back_dag_list.clear();
|
|
g_back_dag_list.reserve(dags.size() * ti.cols);
|
|
for (auto& d : dags) {
|
|
g_back_dag_list.push_back(d.name);
|
|
g_back_dag_list.push_back(d.schedule.empty() ? "-" : d.schedule[0]);
|
|
g_back_dag_list.push_back(status_for_dag(d.name, d, live_runs));
|
|
// "Recent": join up to 5 last run statuses as comma-separated string.
|
|
// Most-recent first. Only include runs that exist (no padding with "-").
|
|
{
|
|
std::string recent;
|
|
int n = std::min(5, static_cast<int>(d.last_runs_status.size()));
|
|
for (int i = 0; i < n; i++) {
|
|
if (i) recent += ',';
|
|
recent += d.last_runs_status[i];
|
|
}
|
|
g_back_dag_list.push_back(recent);
|
|
}
|
|
std::string tags_csv;
|
|
for (size_t i = 0; i < d.tags.size(); i++) {
|
|
if (i) tags_csv += ",";
|
|
tags_csv += d.tags[i];
|
|
}
|
|
g_back_dag_list.push_back(tags_csv);
|
|
g_back_dag_list.push_back(d.valid ? "yes" : "no");
|
|
}
|
|
cells_to_ptrs(g_back_dag_list, g_ptrs_dag_list);
|
|
ti.cells = g_ptrs_dag_list.data();
|
|
|
|
std::vector<data_table::TableEvent> events;
|
|
ImGui::BeginChild("##dag_list_wrap", ImVec2(-1, -1));
|
|
data_table::render("##dt_dag_list", {ti}, g_st_dag_list, &events);
|
|
ImGui::EndChild();
|
|
|
|
// Handle row events -> select DAG.
|
|
for (auto& ev : events) {
|
|
if (ev.kind == data_table::TableEventKind::RowDoubleClick && ev.row >= 0 &&
|
|
ev.row < static_cast<int>(dags.size())) {
|
|
selection().dag_name = dags[ev.row].name;
|
|
caches().dag_detail_loaded = false; // force reload
|
|
}
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// DAG Detail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void load_dag_detail(const std::string& api_url) {
|
|
auto& sel = selection();
|
|
auto& c = caches();
|
|
c.dag_detail = {};
|
|
if (sel.dag_name.empty()) {
|
|
c.dag_detail_loaded = false;
|
|
return;
|
|
}
|
|
if (dag_ui::get_dag_http(api_url, sel.dag_name, c.dag_detail)) {
|
|
c.dag_detail_loaded = true;
|
|
}
|
|
}
|
|
|
|
void draw_dag_detail(const std::string& api_url) {
|
|
if (!ImGui::Begin(TI_INFO_CIRCLE " DAG Detail")) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
auto& sel = selection();
|
|
auto& c = caches();
|
|
|
|
if (sel.dag_name.empty()) {
|
|
empty_state("( nothing selected )", "Pick a DAG",
|
|
"Double-click a row in the DAG list to inspect it here.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
if (!c.dag_detail_loaded) load_dag_detail(api_url);
|
|
|
|
auto& info = c.dag_detail.info;
|
|
ImGui::Text("%s %s", TI_HASH, info.name.empty() ? sel.dag_name.c_str() : info.name.c_str());
|
|
if (!info.description.empty()) ImGui::TextWrapped("%s", info.description.c_str());
|
|
if (!info.schedule.empty()) ImGui::Text("Schedule: %s", info.schedule[0].c_str());
|
|
|
|
ImGui::Separator();
|
|
|
|
if (ImGui::Button(TI_PLAYER_PLAY " Run Now")) {
|
|
std::string run_id, err;
|
|
if (dag_ui::trigger_dag_http(api_url, sel.dag_name, run_id, err)) {
|
|
sel.run_id = run_id;
|
|
c.run_detail_loaded = false;
|
|
} else {
|
|
// Surface error via console; UI banner could be added later.
|
|
fprintf(stderr, "[dag_detail] trigger failed: %s\n", err.c_str());
|
|
}
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_REFRESH " Refresh")) {
|
|
c.dag_detail_loaded = false;
|
|
}
|
|
|
|
ImGui::Separator();
|
|
ImGui::TextUnformatted("Recent runs:");
|
|
|
|
auto& runs = c.dag_detail.recent_runs;
|
|
if (runs.empty()) {
|
|
ImGui::TextDisabled("( no runs yet )");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
data_table::TableInput ti;
|
|
ti.name = "runs";
|
|
ti.headers = {"Run ID", "Status", "Trigger", "Started", "Finished", "Error"};
|
|
ti.types = {
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::Date,
|
|
data_table::ColumnType::Date,
|
|
data_table::ColumnType::String,
|
|
};
|
|
ti.rows = static_cast<int>(runs.size());
|
|
ti.cols = static_cast<int>(ti.headers.size());
|
|
|
|
g_back_dag_runs.clear();
|
|
g_back_dag_runs.reserve(runs.size() * ti.cols);
|
|
for (auto& r : runs) {
|
|
g_back_dag_runs.push_back(r.id);
|
|
g_back_dag_runs.push_back(r.status);
|
|
g_back_dag_runs.push_back(r.trigger);
|
|
g_back_dag_runs.push_back(r.started_at);
|
|
g_back_dag_runs.push_back(r.finished_at);
|
|
g_back_dag_runs.push_back(r.error);
|
|
}
|
|
cells_to_ptrs(g_back_dag_runs, g_ptrs_dag_runs);
|
|
ti.cells = g_ptrs_dag_runs.data();
|
|
|
|
std::vector<data_table::TableEvent> events;
|
|
ImGui::BeginChild("##dag_runs_wrap", ImVec2(-1, -1));
|
|
data_table::render("##dt_dag_runs", {ti}, g_st_dag_runs, &events);
|
|
ImGui::EndChild();
|
|
|
|
for (auto& ev : events) {
|
|
if (ev.kind == data_table::TableEventKind::RowDoubleClick && ev.row >= 0 &&
|
|
ev.row < static_cast<int>(runs.size())) {
|
|
sel.run_id = runs[ev.row].id;
|
|
caches().run_detail_loaded = false;
|
|
}
|
|
}
|
|
ImGui::End();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Run Detail
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static void load_run_detail(const std::string& api_url) {
|
|
auto& sel = selection();
|
|
auto& c = caches();
|
|
c.run_detail = {};
|
|
if (sel.run_id.empty()) {
|
|
c.run_detail_loaded = false;
|
|
return;
|
|
}
|
|
if (dag_ui::get_run_http(api_url, sel.run_id, c.run_detail)) {
|
|
c.run_detail_loaded = true;
|
|
}
|
|
}
|
|
|
|
void draw_run_detail(const std::string& api_url) {
|
|
if (!ImGui::Begin(TI_CLIPBOARD_LIST " Run Detail")) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
auto& sel = selection();
|
|
auto& c = caches();
|
|
|
|
if (sel.run_id.empty()) {
|
|
empty_state("( nothing selected )", "Pick a run",
|
|
"Double-click a row in DAG Detail recent runs.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
if (!c.run_detail_loaded) load_run_detail(api_url);
|
|
|
|
auto& run = c.run_detail.run;
|
|
ImGui::Text("%s %s", TI_HASH, run.id.empty() ? sel.run_id.c_str() : run.id.c_str());
|
|
ImGui::Text("Status: %s | Trigger: %s", run.status.c_str(), run.trigger.c_str());
|
|
ImGui::Text("Started: %s | Finished: %s",
|
|
run.started_at.c_str(), run.finished_at.empty() ? "-" : run.finished_at.c_str());
|
|
if (!run.error.empty()) {
|
|
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "Error: %s", run.error.c_str());
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::Button(TI_REFRESH " Refresh##run")) {
|
|
c.run_detail_loaded = false;
|
|
}
|
|
|
|
ImGui::Separator();
|
|
auto& steps = c.run_detail.steps;
|
|
if (steps.empty()) {
|
|
ImGui::TextDisabled("( no steps yet )");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Steps table — migrado a data_table::render (issue 0107g).
|
|
// La columna Function usa CellRenderer::Button con action_id="open_fn".
|
|
// Celdas con function_id="" muestran "(shell)" via Text (no button).
|
|
static data_table::State g_st_run_steps;
|
|
static std::vector<std::string> g_back_run_steps;
|
|
static std::vector<const char*> g_ptrs_run_steps;
|
|
|
|
g_back_run_steps.clear();
|
|
for (const auto& s : steps) {
|
|
g_back_run_steps.push_back(s.step_name);
|
|
g_back_run_steps.push_back(s.function_id.empty() ? "(shell)" : s.function_id);
|
|
g_back_run_steps.push_back(s.status);
|
|
g_back_run_steps.push_back(std::to_string(s.exit_code));
|
|
g_back_run_steps.push_back(format_duration(s.duration_ms));
|
|
g_back_run_steps.push_back(s.started_at);
|
|
}
|
|
g_ptrs_run_steps.clear();
|
|
for (const auto& sv : g_back_run_steps) g_ptrs_run_steps.push_back(sv.c_str());
|
|
|
|
data_table::TableInput tbl_steps;
|
|
tbl_steps.name = "dt_run_steps";
|
|
tbl_steps.headers = {"Step", "Function", "Status", "Exit", "Duration", "Started"};
|
|
tbl_steps.types = {
|
|
data_table::ColumnType::String, data_table::ColumnType::String,
|
|
data_table::ColumnType::String, data_table::ColumnType::Int,
|
|
data_table::ColumnType::String, data_table::ColumnType::String,
|
|
};
|
|
tbl_steps.cells = g_ptrs_run_steps.empty() ? nullptr : g_ptrs_run_steps.data();
|
|
tbl_steps.rows = (int)steps.size();
|
|
tbl_steps.cols = 6;
|
|
|
|
tbl_steps.column_specs.resize(tbl_steps.cols);
|
|
for (int i = 0; i < tbl_steps.cols; i++) tbl_steps.column_specs[i].id = tbl_steps.headers[i];
|
|
// Function → Button (celdas "(shell)" no son function_ids — se ven como texto si label=value)
|
|
tbl_steps.column_specs[1].renderer = data_table::CellRenderer::Button;
|
|
tbl_steps.column_specs[1].button_action = "open_fn";
|
|
tbl_steps.column_specs[1].button_label = ""; // "" → usa valor de celda como label
|
|
tbl_steps.column_specs[1].button_color_hex = "#21882b";
|
|
tbl_steps.column_specs[1].tooltip = "Open in Function panel";
|
|
tbl_steps.column_specs[1].tooltip_on_hover = true;
|
|
// Status → CategoricalChip
|
|
tbl_steps.column_specs[2].renderer = data_table::CellRenderer::CategoricalChip;
|
|
tbl_steps.column_specs[2].chips = {
|
|
{"success", "#22c55e"},
|
|
{"failed", "#ef4444"},
|
|
{"running", "#f59e0b"},
|
|
{"cancelled", "#a3a3a3"},
|
|
{"pending", "#3b82f6"},
|
|
};
|
|
// Duration → Duration renderer
|
|
tbl_steps.column_specs[4].renderer = data_table::CellRenderer::Duration;
|
|
tbl_steps.column_specs[4].duration_warn_ms = 5000.0f;
|
|
tbl_steps.column_specs[4].duration_error_ms = 30000.0f;
|
|
|
|
std::vector<data_table::TableEvent> step_events;
|
|
ImGui::BeginChild("##run_steps_wrap", ImVec2(-1, ImGui::GetContentRegionAvail().y * 0.5f));
|
|
data_table::render("##dt_run_steps", {tbl_steps}, g_st_run_steps, &step_events);
|
|
ImGui::EndChild();
|
|
|
|
for (const auto& ev : step_events) {
|
|
if (ev.kind == data_table::TableEventKind::ButtonClick
|
|
&& ev.action_id == "open_fn"
|
|
&& ev.value != "(shell)") {
|
|
auto& fp = function_panel();
|
|
if (!fp.selected_id.empty() && fp.selected_id != ev.value) {
|
|
fp.breadcrumb.push_back(fp.selected_id);
|
|
}
|
|
fp.selected_id = ev.value;
|
|
fp.loaded = false;
|
|
fp.load_error.clear();
|
|
}
|
|
}
|
|
|
|
// stdout/stderr expandible por step.
|
|
ImGui::Separator();
|
|
ImGui::TextUnformatted("Step output:");
|
|
for (size_t i = 0; i < steps.size(); i++) {
|
|
char hdr[256];
|
|
std::snprintf(hdr, sizeof(hdr), "%s##step_%zu", steps[i].step_name.c_str(), i);
|
|
if (ImGui::CollapsingHeader(hdr)) {
|
|
if (!steps[i].stdout_text.empty()) {
|
|
ImGui::TextUnformatted("stdout:");
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.08f, 0.08f, 0.08f, 1));
|
|
ImGui::BeginChild((std::string("##stdout_") + std::to_string(i)).c_str(),
|
|
ImVec2(-1, 80), false);
|
|
ImGui::TextUnformatted(steps[i].stdout_text.c_str());
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
if (!steps[i].stderr_text.empty()) {
|
|
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "stderr:");
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.12f, 0.06f, 0.06f, 1));
|
|
ImGui::BeginChild((std::string("##stderr_") + std::to_string(i)).c_str(),
|
|
ImVec2(-1, 80), false);
|
|
ImGui::TextUnformatted(steps[i].stderr_text.c_str());
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
}
|
|
}
|
|
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();
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Health panel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
void draw_health(const std::string& /*api_url*/,
|
|
const std::vector<dag_ui::DagRunRow>& runs_all)
|
|
{
|
|
if (!ImGui::Begin(TI_ACTIVITY " Health")) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
const time_t now = std::time(nullptr);
|
|
const time_t cutoff_24h = now - 86400;
|
|
|
|
int runs_24h = 0;
|
|
int success_24h = 0;
|
|
int failed_24h = 0;
|
|
int cancelled_24h = 0;
|
|
int pending_total = 0;
|
|
int success_all = 0;
|
|
int failed_all = 0;
|
|
int cancelled_all = 0;
|
|
|
|
for (auto& r : runs_all) {
|
|
if (r.status == "pending" || r.status == "running") pending_total++;
|
|
|
|
// success_rate computed across success+failed+cancelled (terminal states).
|
|
if (r.status == "success") success_all++;
|
|
if (r.status == "failed") failed_all++;
|
|
if (r.status == "cancelled") cancelled_all++;
|
|
|
|
time_t t = parse_rfc3339(r.started_at);
|
|
if (t == 0) continue;
|
|
if (t < cutoff_24h) continue;
|
|
runs_24h++;
|
|
if (r.status == "success") success_24h++;
|
|
if (r.status == "failed") failed_24h++;
|
|
if (r.status == "cancelled") cancelled_24h++;
|
|
}
|
|
|
|
int terminal_all = success_all + failed_all + cancelled_all;
|
|
float success_rate = (terminal_all > 0)
|
|
? (100.0f * static_cast<float>(success_all) / static_cast<float>(terminal_all))
|
|
: 0.0f;
|
|
|
|
if (runs_all.empty()) {
|
|
empty_state(TI_ACTIVITY, "No runs yet",
|
|
"Trigger a DAG to populate health metrics.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// LAYOUT-TABLE — KPI/form/splitter, no data; keep BeginTable inline.
|
|
if (ImGui::BeginTable("##health_kpis", 4,
|
|
ImGuiTableFlags_Borders | ImGuiTableFlags_SizingStretchSame))
|
|
{
|
|
ImGui::TableNextRow();
|
|
|
|
ImGui::TableNextColumn();
|
|
ImGui::Text("%s Runs (24h)", TI_ACTIVITY);
|
|
ImGui::Text("%d", runs_24h);
|
|
ImGui::TextDisabled("success: %d", success_24h);
|
|
|
|
ImGui::TableNextColumn();
|
|
ImGui::Text("%s Success rate", TI_CHECK);
|
|
ImGui::TextColored(ImVec4(0.30f, 0.85f, 0.40f, 1.0f), "%.1f%%", success_rate);
|
|
ImGui::TextDisabled("%d / %d terminal", success_all, terminal_all);
|
|
|
|
ImGui::TableNextColumn();
|
|
ImGui::Text("%s Failed (24h)", TI_ALERT_TRIANGLE);
|
|
if (failed_24h > 0) {
|
|
ImGui::TextColored(ImVec4(0.95f, 0.35f, 0.30f, 1.0f), "%d", failed_24h);
|
|
} else {
|
|
ImGui::Text("%d", failed_24h);
|
|
}
|
|
ImGui::TextDisabled("cancelled: %d", cancelled_24h);
|
|
|
|
ImGui::TableNextColumn();
|
|
ImGui::Text("%s Pending/Running", TI_LOADER);
|
|
if (pending_total > 0) {
|
|
ImGui::TextColored(ImVec4(0.95f, 0.80f, 0.20f, 1.0f), "%d", pending_total);
|
|
} else {
|
|
ImGui::Text("%d", pending_total);
|
|
}
|
|
ImGui::TextDisabled("active now");
|
|
|
|
ImGui::EndTable();
|
|
}
|
|
|
|
ImGui::Separator();
|
|
ImGui::TextDisabled("Computed client-side from %zu runs in cache.", runs_all.size());
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Function panel — sidebar con metadata del function_id seleccionado.
|
|
// Lazy-load on click. Cada uses_functions[] es navegable (TreeNode click ->
|
|
// recursive load). Boton Back consume el breadcrumb.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static BadgeVariant variant_for_purity(const std::string& p) {
|
|
if (p == "pure") return BadgeVariant::Success;
|
|
if (p == "impure") return BadgeVariant::Warning;
|
|
return BadgeVariant::Default;
|
|
}
|
|
|
|
void draw_function_panel(const std::string& api_url, bool* p_open) {
|
|
auto& fp = function_panel();
|
|
if (fp.selected_id.empty()) return; // panel oculto si no hay seleccion
|
|
|
|
char title[512];
|
|
std::snprintf(title, sizeof(title),
|
|
TI_FUNCTION " Function — %s###function_panel",
|
|
fp.selected_id.c_str());
|
|
|
|
if (!ImGui::Begin(title, p_open)) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Lazy load.
|
|
if (!fp.loaded && fp.load_error.empty()) {
|
|
fp.cached = {};
|
|
if (dag_ui::get_function_http(api_url, fp.selected_id, fp.cached)) {
|
|
fp.loaded = true;
|
|
} else {
|
|
fp.load_error = "Failed to fetch /api/functions/" + fp.selected_id;
|
|
}
|
|
}
|
|
|
|
// Toolbar: Back + Close (clear) + Refresh
|
|
bool has_history = !fp.breadcrumb.empty();
|
|
if (!has_history) ImGui::BeginDisabled();
|
|
if (ImGui::SmallButton(TI_ARROW_LEFT " Back")) {
|
|
std::string prev = fp.breadcrumb.back();
|
|
fp.breadcrumb.pop_back();
|
|
fp.selected_id = prev;
|
|
fp.loaded = false;
|
|
fp.load_error.clear();
|
|
}
|
|
if (!has_history) ImGui::EndDisabled();
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton(TI_REFRESH " Reload##fn_panel")) {
|
|
fp.loaded = false;
|
|
fp.load_error.clear();
|
|
}
|
|
ImGui::SameLine();
|
|
if (ImGui::SmallButton(TI_X " Close##fn_panel")) {
|
|
fp.selected_id.clear();
|
|
fp.breadcrumb.clear();
|
|
fp.loaded = false;
|
|
fp.load_error.clear();
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
ImGui::Separator();
|
|
|
|
if (!fp.load_error.empty()) {
|
|
ImGui::TextColored(ImVec4(1, 0.4f, 0.4f, 1), "%s", fp.load_error.c_str());
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
if (!fp.loaded) {
|
|
ImGui::TextDisabled("loading...");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
const auto& fn = fp.cached;
|
|
|
|
// Header: id grande + 3 badges (domain / lang / purity)
|
|
ImGui::TextUnformatted(fn.id.c_str());
|
|
if (!fn.domain.empty()) { badge(fn.domain.c_str(), BadgeVariant::Info); ImGui::SameLine(); }
|
|
if (!fn.lang.empty()) { badge(fn.lang.c_str(), BadgeVariant::Default); ImGui::SameLine(); }
|
|
if (!fn.purity.empty()) { badge(fn.purity.c_str(), variant_for_purity(fn.purity)); }
|
|
|
|
ImGui::Spacing();
|
|
|
|
if (!fn.description.empty()) {
|
|
ImGui::TextWrapped("%s", fn.description.c_str());
|
|
ImGui::Spacing();
|
|
}
|
|
|
|
if (!fn.signature.empty()) {
|
|
ImGui::TextDisabled("signature");
|
|
ImGui::PushStyleColor(ImGuiCol_ChildBg, ImVec4(0.08f, 0.08f, 0.08f, 1));
|
|
ImGui::BeginChild("##fn_sig", ImVec2(-1, ImGui::GetTextLineHeightWithSpacing() * 2.4f), false,
|
|
ImGuiWindowFlags_HorizontalScrollbar);
|
|
ImGui::TextUnformatted(fn.signature.c_str());
|
|
ImGui::EndChild();
|
|
ImGui::PopStyleColor();
|
|
}
|
|
|
|
ImGui::Separator();
|
|
|
|
// uses_functions[]
|
|
char hdr_fns[64];
|
|
std::snprintf(hdr_fns, sizeof(hdr_fns),
|
|
TI_FUNCTION " Uses functions (%zu)###uses_fns",
|
|
fn.uses_functions.size());
|
|
if (ImGui::CollapsingHeader(hdr_fns, ImGuiTreeNodeFlags_DefaultOpen)) {
|
|
if (fn.uses_functions.empty()) {
|
|
ImGui::TextDisabled(" (none)");
|
|
} else {
|
|
for (size_t i = 0; i < fn.uses_functions.size(); i++) {
|
|
const std::string& dep = fn.uses_functions[i];
|
|
ImGui::PushID(static_cast<int>(i));
|
|
ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_Leaf |
|
|
ImGuiTreeNodeFlags_NoTreePushOnOpen |
|
|
ImGuiTreeNodeFlags_SpanAvailWidth;
|
|
ImGui::TreeNodeEx(dep.c_str(), flags, "%s %s", TI_CODE, dep.c_str());
|
|
if (ImGui::IsItemClicked()) {
|
|
if (!fp.selected_id.empty() && fp.selected_id != dep) {
|
|
fp.breadcrumb.push_back(fp.selected_id);
|
|
}
|
|
fp.selected_id = dep;
|
|
fp.loaded = false;
|
|
fp.load_error.clear();
|
|
}
|
|
ImGui::PopID();
|
|
}
|
|
}
|
|
}
|
|
|
|
// uses_types[]
|
|
char hdr_types[64];
|
|
std::snprintf(hdr_types, sizeof(hdr_types),
|
|
TI_NETWORK " Uses types (%zu)###uses_types",
|
|
fn.uses_types.size());
|
|
if (ImGui::CollapsingHeader(hdr_types)) {
|
|
if (fn.uses_types.empty()) {
|
|
ImGui::TextDisabled(" (none)");
|
|
} else {
|
|
for (auto& t : fn.uses_types) ImGui::BulletText("%s", t.c_str());
|
|
}
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// All Runs panel
|
|
// ---------------------------------------------------------------------------
|
|
|
|
static data_table::State g_st_all_runs;
|
|
static std::vector<std::string> g_back_all_runs;
|
|
static std::vector<const char*> g_ptrs_all_runs;
|
|
|
|
void draw_all_runs(const std::string& /*api_url*/,
|
|
const std::vector<dag_ui::DagRunRow>& runs_all)
|
|
{
|
|
if (!ImGui::Begin(TI_HISTORY " All Runs")) {
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
if (runs_all.empty()) {
|
|
empty_state(TI_HISTORY, "No runs yet",
|
|
"Lanza algun DAG desde DAG List para que aparezca aqui.");
|
|
ImGui::End();
|
|
return;
|
|
}
|
|
|
|
// Sort by started_at desc (most recent first). Hacer copia para no mutar el cache.
|
|
std::vector<const dag_ui::DagRunRow*> sorted;
|
|
sorted.reserve(runs_all.size());
|
|
for (auto& r : runs_all) sorted.push_back(&r);
|
|
std::sort(sorted.begin(), sorted.end(),
|
|
[](const dag_ui::DagRunRow* a, const dag_ui::DagRunRow* b){
|
|
return a->started_at > b->started_at;
|
|
});
|
|
|
|
data_table::TableInput ti;
|
|
ti.name = "all_runs";
|
|
ti.headers = {"Run ID", "DAG", "Status", "Trigger", "Started", "Finished", "Duration"};
|
|
ti.types = {
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::String,
|
|
data_table::ColumnType::Date,
|
|
data_table::ColumnType::Date,
|
|
data_table::ColumnType::String,
|
|
};
|
|
ti.rows = static_cast<int>(sorted.size());
|
|
ti.cols = static_cast<int>(ti.headers.size());
|
|
|
|
auto status_chips = [](){
|
|
std::vector<data_table::ChipRule> rules;
|
|
rules.push_back({"success", "#22c55e"});
|
|
rules.push_back({"failed", "#ef4444"});
|
|
rules.push_back({"running", "#eab308"});
|
|
rules.push_back({"pending", "#94a3b8"});
|
|
rules.push_back({"cancelled", "#6b7280"});
|
|
return rules;
|
|
};
|
|
auto trigger_chips = [](){
|
|
std::vector<data_table::ChipRule> rules;
|
|
rules.push_back({"manual", "#3b82f6"});
|
|
rules.push_back({"cron", "#a855f7"});
|
|
rules.push_back({"api", "#06b6d4"});
|
|
return rules;
|
|
};
|
|
|
|
ti.column_specs.resize(ti.cols);
|
|
for (int i = 0; i < ti.cols; i++) ti.column_specs[i].id = ti.headers[i];
|
|
ti.column_specs[2].renderer = data_table::CellRenderer::CategoricalChip;
|
|
ti.column_specs[2].chips = status_chips();
|
|
ti.column_specs[3].renderer = data_table::CellRenderer::CategoricalChip;
|
|
ti.column_specs[3].chips = trigger_chips();
|
|
|
|
// Helper: duracion humana entre started_at y finished_at (best-effort).
|
|
auto duration_str = [](const std::string& s, const std::string& f) -> std::string {
|
|
if (s.empty() || f.empty()) return "-";
|
|
// Parse ISO 8601 minimalist (YYYY-MM-DDTHH:MM:SS).
|
|
auto to_secs = [](const std::string& t) -> long long {
|
|
int Y,M,D,h,mi,se;
|
|
if (std::sscanf(t.c_str(), "%d-%d-%dT%d:%d:%d", &Y,&M,&D,&h,&mi,&se) != 6) return 0;
|
|
std::tm tm = {}; tm.tm_year=Y-1900; tm.tm_mon=M-1; tm.tm_mday=D;
|
|
tm.tm_hour=h; tm.tm_min=mi; tm.tm_sec=se;
|
|
#ifdef _WIN32
|
|
return static_cast<long long>(_mkgmtime(&tm));
|
|
#else
|
|
return static_cast<long long>(timegm(&tm));
|
|
#endif
|
|
};
|
|
long long ss = to_secs(s), ff = to_secs(f);
|
|
if (ss == 0 || ff == 0 || ff < ss) return "-";
|
|
long long dur = ff - ss;
|
|
if (dur < 60) return std::to_string(dur) + "s";
|
|
if (dur < 3600) return std::to_string(dur/60) + "m " + std::to_string(dur%60) + "s";
|
|
return std::to_string(dur/3600) + "h " + std::to_string((dur%3600)/60) + "m";
|
|
};
|
|
|
|
g_back_all_runs.clear();
|
|
g_back_all_runs.reserve(sorted.size() * ti.cols);
|
|
for (auto* r : sorted) {
|
|
// Truncate run id for display (keep last 8 chars).
|
|
std::string short_id = r->id;
|
|
if (short_id.size() > 12) short_id = "..." + short_id.substr(short_id.size() - 8);
|
|
g_back_all_runs.push_back(short_id);
|
|
g_back_all_runs.push_back(r->dag_name);
|
|
g_back_all_runs.push_back(r->status);
|
|
g_back_all_runs.push_back(r->trigger);
|
|
g_back_all_runs.push_back(r->started_at);
|
|
g_back_all_runs.push_back(r->finished_at);
|
|
g_back_all_runs.push_back(duration_str(r->started_at, r->finished_at));
|
|
}
|
|
cells_to_ptrs(g_back_all_runs, g_ptrs_all_runs);
|
|
ti.cells = g_ptrs_all_runs.data();
|
|
|
|
std::vector<data_table::TableEvent> events;
|
|
ImGui::BeginChild("##all_runs_wrap", ImVec2(-1, -1));
|
|
data_table::render("##dt_all_runs", {ti}, g_st_all_runs, &events);
|
|
ImGui::EndChild();
|
|
|
|
for (auto& ev : events) {
|
|
if (ev.kind == data_table::TableEventKind::RowDoubleClick &&
|
|
ev.row >= 0 && ev.row < static_cast<int>(sorted.size())) {
|
|
selection().run_id = sorted[ev.row]->id;
|
|
selection().dag_name = sorted[ev.row]->dag_name;
|
|
caches().run_detail_loaded = false;
|
|
caches().dag_detail_loaded = false;
|
|
}
|
|
}
|
|
|
|
ImGui::End();
|
|
}
|
|
|
|
} // namespace dag_ui_tabs
|