diff --git a/CMakeLists.txt b/CMakeLists.txt index f2ede28..839e594 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -5,6 +5,7 @@ add_imgui_app(dag_engine_ui ws_client.cpp tabs.cpp ${CMAKE_SOURCE_DIR}/functions/core/empty_state.cpp + ${CMAKE_SOURCE_DIR}/functions/core/badge.cpp ) target_include_directories(dag_engine_ui PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/app.md b/app.md index a778cb4..0be1774 100644 --- a/app.md +++ b/app.md @@ -21,8 +21,26 @@ uses_functions: uses_types: [] framework: "imgui" entry_point: "main.cpp" -dir_path: "cpp/apps/dag_engine_ui" +dir_path: "apps/dag_engine_ui" repo_url: "https://gitea.organic-machine.com/dataforge/dag_engine_ui" +e2e_checks: + - id: build_cmake + cmd: "cmake --build cpp/build -j --target dag_engine_ui" + timeout_s: 300 + severity: critical + - id: binary_exists + cmd: "test -x cpp/build/linux/apps/dag_engine_ui/dag_engine_ui || test -x cpp/build/apps/dag_engine_ui/dag_engine_ui" + timeout_s: 5 + severity: critical + - id: self_test + cmd: "(cpp/build/linux/apps/dag_engine_ui/dag_engine_ui --self-test 2>&1 || cpp/build/apps/dag_engine_ui/dag_engine_ui --self-test 2>&1) | head -20" + timeout_s: 10 + expect_stdout_contains: "self-test" + severity: warning + - id: cpp_apps_conformance + cmd: "./fn doctor cpp-apps 2>&1 | grep -A1 dag_engine_ui || echo 'no issues'" + expect_stdout_contains: "no issues" + severity: critical --- # dag_engine_ui diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000..05cc7f1 Binary files /dev/null and b/appicon.ico differ diff --git a/data_http.cpp b/data_http.cpp index b6a0e5b..4e8ea21 100644 --- a/data_http.cpp +++ b/data_http.cpp @@ -73,6 +73,7 @@ static void parse_step(const json& j, DagStepRow& s) { s.finished_at = get_str(j, "finished_at"); s.duration_ms = get_int64(j, "duration_ms"); s.error = get_str(j, "error"); + s.function_id = get_str(j, "function_id"); } static void parse_dag_info(const json& j, DagInfo& d) { @@ -245,4 +246,33 @@ bool trigger_dag_http(const std::string& api_url, const std::string& name, return true; } +bool get_function_http(const std::string& api_url, + const std::string& function_id, + FnInfo& out) { + std::string host; + int port; + if (!parse_url(api_url, host, port)) return false; + if (function_id.empty()) return false; + HttpClient cli(host, port); + auto res = cli.get("/api/functions/" + function_id); + if (!res.ok()) { + fprintf(stderr, "[dag_http] get_function(%s) failed: status=%d\n", + function_id.c_str(), res.status); + return false; + } + auto j = json::parse(res.body, nullptr, false); + if (!j.is_object()) return false; + + out.id = get_str(j, "id"); + out.name = get_str(j, "name"); + out.description = get_str(j, "description"); + out.signature = get_str(j, "signature"); + out.purity = get_str(j, "purity"); + out.domain = get_str(j, "domain"); + out.lang = get_str(j, "lang"); + out.uses_functions = get_str_array(j, "uses_functions"); + out.uses_types = get_str_array(j, "uses_types"); + return true; +} + } // namespace dag_ui diff --git a/data_http.h b/data_http.h index f81e35f..43520c0 100644 --- a/data_http.h +++ b/data_http.h @@ -54,6 +54,20 @@ struct DagStepRow { std::string finished_at; long long duration_ms = 0; std::string error; + std::string function_id; // "" if not a function step +}; + +// Metadata de una funcion del registry (response shape de GET /api/functions/{id}). +struct FnInfo { + std::string id; + std::string name; + std::string description; + std::string signature; + std::string purity; // "pure" | "impure" + std::string domain; + std::string lang; + std::vector uses_functions; + std::vector uses_types; }; struct DagRunDetail { @@ -86,4 +100,10 @@ bool get_run_http(const std::string& api_url, const std::string& run_id, bool trigger_dag_http(const std::string& api_url, const std::string& name, std::string& out_run_id, std::string& out_error); +// GET /api/functions/{function_id} -> rellena out con metadata del registry. +// Devuelve false si red falla, status != 2xx, o JSON no parseable. +bool get_function_http(const std::string& api_url, + const std::string& function_id, + FnInfo& out); + } // namespace dag_ui diff --git a/main.cpp b/main.cpp index 57f15e3..7c9edf0 100644 --- a/main.cpp +++ b/main.cpp @@ -4,10 +4,13 @@ #include "core/icons_tabler.h" #include "core/logger.h" #include "data_http.h" +#include "http_client.h" #include "ws_client.h" #include "tabs.h" #include "vendor/nlohmann/json.hpp" +#include +#include #include #include @@ -48,6 +51,9 @@ 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; +static bool g_show_all_runs = true; +static bool g_show_health = true; +static bool g_show_function_panel = true; // Auto-fetch DAG list una vez al arrancar. static bool g_initial_fetched = false; @@ -171,11 +177,36 @@ static void render() { 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_all_runs) dag_ui_tabs::draw_all_runs(g_api_url, g_runs_all); + if (g_show_health) dag_ui_tabs::draw_health(g_api_url, g_runs_all); if (g_show_main) draw_main(); if (g_show_live) draw_live(); + if (g_show_function_panel) dag_ui_tabs::draw_function_panel(g_api_url, &g_show_function_panel); } -int main(int /*argc*/, char** /*argv*/) { +// Self-test: blocking HTTP GET to the dag_engine backend, no GUI. +// Returns 0 if reachable (any 2xx), 1 otherwise. +static int run_self_test() { + HttpClient client(g_ws_host, g_ws_port); + // Probe /api/dags as a sucedaneo de /health (no dedicated /health helper). + HttpResponse resp = client.get("/api/dags"); + if (resp.ok()) { + std::printf("self-test ok: dag_engine reachable at %s\n", g_api_url.c_str()); + return 0; + } + std::printf("self-test fail: dag_engine unreachable at %s (status=%d)\n", + g_api_url.c_str(), resp.status); + return 1; +} + +int main(int argc, char** argv) { + // CLI flag --self-test: probe backend and exit without opening GUI. + for (int i = 1; i < argc; i++) { + if (argv[i] && std::strcmp(argv[i], "--self-test") == 0) { + return run_self_test(); + } + } + // Conecta WS al backend dag_engine. Reconnect con backoff lo gestiona WsClient. g_ws.start(g_ws_host, g_ws_port, g_ws_path); @@ -184,6 +215,9 @@ int main(int /*argc*/, char** /*argv*/) { { "DAG Detail", nullptr, &g_show_dag_detail }, { "Run Detail", nullptr, &g_show_run_detail }, { "Timeline", nullptr, &g_show_timeline }, + { "All Runs", nullptr, &g_show_all_runs }, + { "Health", nullptr, &g_show_health }, + { "Function", nullptr, &g_show_function_panel }, { "Live (WS)", nullptr, &g_show_live }, { "Main (diag)", nullptr, &g_show_main }, }; diff --git a/tabs.cpp b/tabs.cpp index 1008263..245a077 100644 --- a/tabs.cpp +++ b/tabs.cpp @@ -3,6 +3,7 @@ #include "core/data_table_types.h" #include "core/icons_tabler.h" #include "core/empty_state.h" +#include "core/badge.h" #include #include @@ -28,6 +29,11 @@ Caches& caches() { 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; @@ -355,33 +361,84 @@ void draw_run_detail(const std::string& api_url) { return; } - data_table::TableInput ti; - ti.name = "steps"; - ti.headers = {"Step", "Status", "Exit", "Duration", "Started"}; - ti.types = { - data_table::ColumnType::String, - data_table::ColumnType::String, - data_table::ColumnType::Int, - data_table::ColumnType::String, - data_table::ColumnType::Date, - }; - ti.rows = static_cast(steps.size()); - ti.cols = static_cast(ti.headers.size()); - - g_back_run_steps.clear(); - g_back_run_steps.reserve(steps.size() * ti.cols); - for (auto& s : steps) { - g_back_run_steps.push_back(s.step_name); - 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); - } - cells_to_ptrs(g_back_run_steps, g_ptrs_run_steps); - ti.cells = g_ptrs_run_steps.data(); - + // Steps table — render nativo (ImGui::BeginTable) en vez de data_table::render + // para soportar la columna "Function" clickable (badge -> abre Function panel). + // Status sigue mostrando badge coloreado por tipo. ImGui::BeginChild("##run_steps_wrap", ImVec2(-1, ImGui::GetContentRegionAvail().y * 0.5f)); - data_table::render("##dt_run_steps", {ti}, g_st_run_steps); + const ImGuiTableFlags steps_flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp | + ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##dt_run_steps", 6, steps_flags)) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Step", ImGuiTableColumnFlags_WidthStretch, 1.6f); + ImGui::TableSetupColumn("Function", ImGuiTableColumnFlags_WidthStretch, 2.2f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthStretch, 0.8f); + ImGui::TableSetupColumn("Exit", ImGuiTableColumnFlags_WidthStretch, 0.4f); + ImGui::TableSetupColumn("Duration", ImGuiTableColumnFlags_WidthStretch, 0.7f); + ImGui::TableSetupColumn("Started", ImGuiTableColumnFlags_WidthStretch, 1.2f); + ImGui::TableHeadersRow(); + + for (size_t i = 0; i < steps.size(); i++) { + auto& s = steps[i]; + ImGui::TableNextRow(); + + // Step name + ImGui::TableSetColumnIndex(0); + ImGui::TextUnformatted(s.step_name.c_str()); + + // Function — badge clickable o "(shell)" + ImGui::TableSetColumnIndex(1); + if (!s.function_id.empty()) { + ImGui::PushID(static_cast(i)); + // Small button styled like a badge (registry green). + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.13f, 0.55f, 0.30f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(0.18f, 0.65f, 0.38f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(0.10f, 0.45f, 0.25f, 1.0f)); + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1, 1, 1, 1)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(6, 1)); + char btn[512]; + std::snprintf(btn, sizeof(btn), "%s %s", TI_FUNCTION, s.function_id.c_str()); + if (ImGui::SmallButton(btn)) { + auto& fp = function_panel(); + if (!fp.selected_id.empty() && fp.selected_id != s.function_id) { + fp.breadcrumb.push_back(fp.selected_id); + } + fp.selected_id = s.function_id; + fp.loaded = false; + fp.load_error.clear(); + } + ImGui::PopStyleVar(); + ImGui::PopStyleColor(4); + ImGui::PopID(); + } else { + ImGui::TextDisabled("(shell)"); + } + + // Status badge + ImGui::TableSetColumnIndex(2); + BadgeVariant v = BadgeVariant::Default; + if (s.status == "success") v = BadgeVariant::Success; + else if (s.status == "failed") v = BadgeVariant::Error; + else if (s.status == "running") v = BadgeVariant::Warning; + else if (s.status == "cancelled") v = BadgeVariant::Default; + else if (s.status == "pending") v = BadgeVariant::Info; + badge(s.status.c_str(), v); + + // Exit + ImGui::TableSetColumnIndex(3); + ImGui::Text("%d", s.exit_code); + + // Duration + ImGui::TableSetColumnIndex(4); + ImGui::TextUnformatted(format_duration(s.duration_ms).c_str()); + + // Started + ImGui::TableSetColumnIndex(5); + ImGui::TextUnformatted(s.started_at.c_str()); + } + ImGui::EndTable(); + } ImGui::EndChild(); // stdout/stderr expandible por step. @@ -607,4 +664,375 @@ void draw_timeline(const std::string& api_url, ImGui::End(); } + +// --------------------------------------------------------------------------- +// Health panel +// --------------------------------------------------------------------------- + +void draw_health(const std::string& /*api_url*/, + const std::vector& 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(success_all) / static_cast(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; + } + + 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(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 g_back_all_runs; +static std::vector g_ptrs_all_runs; + +void draw_all_runs(const std::string& /*api_url*/, + const std::vector& 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 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(sorted.size()); + ti.cols = static_cast(ti.headers.size()); + + auto status_badges = [](){ + std::vector 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_badges = [](){ + std::vector 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::Badge; + ti.column_specs[2].badges = status_badges(); + ti.column_specs[3].renderer = data_table::CellRenderer::Badge; + ti.column_specs[3].badges = trigger_badges(); + + // 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(_mkgmtime(&tm)); +#else + return static_cast(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 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(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 diff --git a/tabs.h b/tabs.h index ec4c6a1..ba193ae 100644 --- a/tabs.h +++ b/tabs.h @@ -32,6 +32,19 @@ struct Caches { Caches& caches(); +// Estado del panel lateral "Function" — registry metadata para el function_id +// seleccionado actualmente. selected_id == "" -> panel oculto. breadcrumb mantiene +// el historial de navegacion para soportar el boton Back. +struct FunctionPanelState { + std::string selected_id; // "" = panel oculto + dag_ui::FnInfo cached; + bool loaded = false; + std::string load_error; + std::vector breadcrumb; // ids visitados antes del actual +}; + +FunctionPanelState& function_panel(); + // Render cada tab. api_url es el endpoint dag_engine. // `live_runs` es el cache global mantenido por WS (sirve para DAG List status). void draw_dag_list(const std::string& api_url, @@ -47,4 +60,18 @@ void draw_run_detail(const std::string& api_url); void draw_timeline(const std::string& api_url, const std::vector& runs_all); +// Health panel: KPIs derivados de runs_all (client-side). +// runs_24h, success_rate, failed_runs_24h, pending_runs. +void draw_health(const std::string& api_url, + const std::vector& runs_all); + +// All Runs panel: historico completo de runs (todas las DAGs). Tabla +// ordenada por started_at desc. Click row -> set selection().run_id. +void draw_all_runs(const std::string& api_url, + const std::vector& runs_all); + +// Function panel: detalle de la funcion del registry seleccionada (id, domain, +// purity, signature, uses_functions[], uses_types[]). Lazy-load por click. +void draw_function_panel(const std::string& api_url, bool* p_open); + } // namespace dag_ui_tabs