diff --git a/views.h b/views.h index 377dfe4..99c53b4 100644 --- a/views.h +++ b/views.h @@ -252,6 +252,10 @@ struct AppState { // Persiste filters/sort/stages entre frames; uno por instancia de render. data_table::State table_dt_state; + // data_table::State para el panel Jobs (issue 0081-J Phase 1). + // Persiste filters/sort/stages entre frames para la tabla de jobs. + data_table::State jobs_dt_state; + // ---- Type Editor (issue 0007) ------------------------------------------ // Draft del editor de tipos. Se inicializa con una copia de parsed_types // tras cargar el grafo. Save reescribe `types.yaml` y dispara diff --git a/views_jobs.cpp b/views_jobs.cpp index 36bc679..c0cb8f9 100644 --- a/views_jobs.cpp +++ b/views_jobs.cpp @@ -1,14 +1,33 @@ +// views_jobs.cpp — Jobs panel (issue 0026). +// +// Migration history (issue 0081-J): +// v1: ImGui::BeginTable inline with 6 columns (status, enricher, target, +// progress, time, ##actions). +// v2: data_table::render for 5 data columns (status, enricher, target, +// progress, time) with declarative CellRenderer (Badge, Progress, +// Duration). Action buttons (Cancel/Delete) rendered in a separate +// small ImGui::BeginTable below — option (a) from issue 0081-J spec. +// Rationale: data_table::State does not expose selected_row_idx yet +// (Phase 2 feature); a dedicated actions bar keeps all action logic +// isolated and works with any filter state in the main table. +// TODO(0081-J Phase 2): when data_table::State gets selected_row_idx, +// migrate actions to option (b) toolbar per selected row. + #include "views.h" #include "jobs.h" #include "core/icons_tabler.h" #include "core/tokens.h" +#include "core/data_table_types.h" +#include "viz/data_table.h" #include "imgui.h" #include #include #include +#include +#include namespace ge { @@ -20,19 +39,9 @@ struct JobsCache { std::vector rows; int last_frame_refresh = -100; int filter_idx = 0; // 0=all 1=active 2=done 3=error - char buf[8] = {}; }; JobsCache g_jobs_cache; -const char* status_icon(const std::string& s) { - if (s == "queued") return TI_HOURGLASS; - if (s == "running") return TI_PLAYER_PLAY; - if (s == "done") return TI_CHECK; - if (s == "error") return TI_ALERT_CIRCLE; - if (s == "cancelled") return TI_X; - return TI_QUESTION_MARK; -} - ImVec4 status_color(const std::string& s) { if (s == "running") return ImVec4(0.36f, 0.78f, 1.0f, 1.0f); if (s == "done") return ImVec4(0.40f, 0.85f, 0.55f, 1.0f); @@ -42,31 +51,27 @@ ImVec4 status_color(const std::string& s) { return ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } -std::string format_duration(long long started, long long finished) { - if (started <= 0) return "—"; - long long end = finished > 0 ? finished - : std::chrono::duration_cast( - std::chrono::system_clock::now().time_since_epoch()) - .count(); - long long ms = end - started; - if (ms < 0) ms = 0; - char b[32]; - if (ms < 1000) std::snprintf(b, sizeof(b), "%lld ms", ms); - else if (ms < 60'000) std::snprintf(b, sizeof(b), "%.1f s", ms / 1000.0); - else std::snprintf(b, sizeof(b), "%.1f m", ms / 60'000.0); - return b; -} - bool filter_match(const std::string& status, int idx) { switch (idx) { - case 0: return true; // all - case 1: return status == "queued" || status == "running"; // active + case 0: return true; + case 1: return status == "queued" || status == "running"; case 2: return status == "done"; case 3: return status == "error" || status == "cancelled"; default: return true; } } +// Duration helper: computes elapsed ms for running jobs (finished_at=0). +long long job_duration_ms(const JobRow& r) { + if (r.started_at <= 0) return 0; + long long end = r.finished_at > 0 + ? r.finished_at + : std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + long long ms = end - r.started_at; + return ms < 0 ? 0 : ms; +} + } // namespace void views_jobs(AppState& app) { @@ -106,93 +111,252 @@ void views_jobs(AppState& app) { ImGui::Separator(); - // Tabla. - ImGuiTableFlags tflags = ImGuiTableFlags_Borders | - ImGuiTableFlags_RowBg | - ImGuiTableFlags_SizingStretchProp | - ImGuiTableFlags_ScrollY; - if (ImGui::BeginTable("jobs_table", 6, tflags, - ImVec2(0, ImGui::GetContentRegionAvail().y - 4))) { - ImGui::TableSetupScrollFreeze(0, 1); - ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 90); - ImGui::TableSetupColumn("Enricher", ImGuiTableColumnFlags_WidthStretch, 1.5f); - ImGui::TableSetupColumn("Target", ImGuiTableColumnFlags_WidthStretch, 2.0f); - ImGui::TableSetupColumn("Progress", ImGuiTableColumnFlags_WidthStretch, 2.0f); - ImGui::TableSetupColumn("Time", ImGuiTableColumnFlags_WidthFixed, 70); - ImGui::TableSetupColumn("##actions",ImGuiTableColumnFlags_WidthFixed, 80); - ImGui::TableHeadersRow(); + // ------------------------------------------------------------------------- + // Actions mini-table (option a — Fase 1; TODO: migrate to selected-row + // toolbar when data_table::State exposes selected_row_idx in Phase 2). + // + // Shows Cancel/Delete buttons for every row that passes the current filter. + // Fixed-height child so it doesn't push the data_table below the panel. + // We pre-compute the list of actionable rows (active or terminal). + // ------------------------------------------------------------------------- - for (const auto& r : g_jobs_cache.rows) { - if (!filter_match(r.status, g_jobs_cache.filter_idx)) continue; - ImGui::PushID(r.id.c_str()); - ImGui::TableNextRow(); - - // Status. - ImGui::TableSetColumnIndex(0); - ImGui::TextColored(status_color(r.status), "%s %s", - status_icon(r.status), r.status.c_str()); - - // Enricher. - ImGui::TableSetColumnIndex(1); - ImGui::TextUnformatted(r.enricher_id.c_str()); - - // Target. - ImGui::TableSetColumnIndex(2); - if (!r.node_name.empty()) { - ImGui::TextUnformatted(r.node_name.c_str()); - } else if (!r.node_id.empty()) { - ImGui::TextDisabled("%s", r.node_id.c_str()); - } else { - ImGui::TextDisabled("(global)"); - } - - // Progress. - ImGui::TableSetColumnIndex(3); - if (r.status == "running" || r.status == "queued") { - ImGui::ProgressBar((float)r.progress, ImVec2(-FLT_MIN, 0), - r.stage.empty() ? nullptr : r.stage.c_str()); - } else if (r.status == "error" && !r.error.empty()) { - ImGui::TextColored(status_color("error"), "%s", - r.error.size() > 64 - ? (r.error.substr(0, 64) + "…").c_str() - : r.error.c_str()); - if (ImGui::IsItemHovered()) { - ImGui::SetTooltip("%s", r.error.c_str()); - } - } else if (r.status == "done" && !r.result_json.empty()) { - ImGui::TextDisabled("%s", - r.result_json.size() > 80 - ? (r.result_json.substr(0, 80) + "…").c_str() - : r.result_json.c_str()); - if (ImGui::IsItemHovered() && r.result_json.size() > 80) { - ImGui::SetTooltip("%s", r.result_json.c_str()); - } - } else { - ImGui::TextDisabled("—"); - } - - // Time. - ImGui::TableSetColumnIndex(4); - ImGui::TextDisabled("%s", - format_duration(r.started_at, r.finished_at).c_str()); - - // Actions. - ImGui::TableSetColumnIndex(5); - if (r.status == "queued" || r.status == "running") { - if (ImGui::SmallButton("Cancel")) { - jobs_cancel(r.id.c_str()); - } - } else { - if (ImGui::SmallButton("Delete")) { - jobs_delete(r.id.c_str()); - } - } - - ImGui::PopID(); - } - ImGui::EndTable(); + // Collect visible rows for actions. + std::vector visible; + visible.reserve(g_jobs_cache.rows.size()); + for (const auto& r : g_jobs_cache.rows) { + if (filter_match(r.status, g_jobs_cache.filter_idx)) + visible.push_back(&r); } + // Actions bar height: up to 5 rows visible, ~22px each + header ~22px. + // Only render if there are any rows with actionable buttons. + bool any_actionable = false; + for (const auto* rp : visible) { + if (rp->status == "queued" || rp->status == "running" || + rp->status == "done" || rp->status == "error" || + rp->status == "cancelled") { + any_actionable = true; + break; + } + } + + if (any_actionable && !visible.empty()) { + int visible_rows = (int)visible.size(); + int capped = visible_rows < 5 ? visible_rows : 5; + float row_h = ImGui::GetTextLineHeightWithSpacing(); + float child_h = row_h * (float)(capped + 1) + ImGui::GetStyle().ItemSpacing.y * 2.0f; + child_h = child_h < 120.0f ? child_h : 120.0f; // hard cap + + ImGui::TextDisabled("Actions"); + if (ImGui::BeginChild("##jobs_actions", ImVec2(0.0f, child_h), + ImGuiChildFlags_Borders)) { + ImGuiTableFlags aflags = ImGuiTableFlags_Borders | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingStretchProp | + ImGuiTableFlags_ScrollY; + if (ImGui::BeginTable("##jobs_actions_tbl", 3, aflags)) { + ImGui::TableSetupScrollFreeze(0, 1); + ImGui::TableSetupColumn("Job", ImGuiTableColumnFlags_WidthStretch, 3.0f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 64.0f); + ImGui::TableHeadersRow(); + + for (const auto* rp : visible) { + const JobRow& r = *rp; + ImGui::PushID(r.id.c_str()); + ImGui::TableNextRow(); + + // Job label (enricher + target truncated). + ImGui::TableSetColumnIndex(0); + std::string label = r.enricher_id; + if (!r.node_name.empty()) label += " / " + r.node_name; + else if (!r.node_id.empty()) label += " / " + r.node_id; + if (label.size() > 40) label = label.substr(0, 38) + "…"; + ImGui::TextUnformatted(label.c_str()); + + // Status badge. + ImGui::TableSetColumnIndex(1); + ImGui::TextColored(status_color(r.status), "%s", r.status.c_str()); + + // Action button. + ImGui::TableSetColumnIndex(2); + if (r.status == "queued" || r.status == "running") { + if (ImGui::SmallButton("Cancel")) { + jobs_cancel(r.id.c_str()); + // Invalidate cache immediately. + g_jobs_cache.last_frame_refresh = -100; + } + } else { + if (ImGui::SmallButton("Delete")) { + jobs_delete(r.id.c_str()); + g_jobs_cache.last_frame_refresh = -100; + } + } + ImGui::PopID(); + } + ImGui::EndTable(); + } + } + ImGui::EndChild(); + ImGui::Spacing(); + } + + // ------------------------------------------------------------------------- + // Main data table via data_table::render (issue 0081-J migration). + // + // Columns: + // 0: status — Badge renderer (queued/running/done/error/cancelled) + // 1: enricher — Text (default) + // 2: target — Text (default) + // 3: progress — Progress renderer, 0..1 scale + // 4: time_ms — Duration renderer, warn=1000ms error=10000ms + // + // Rows are filtered by g_jobs_cache.filter_idx BEFORE populating + // cells, so data_table only sees the subset the user wants. + // + // Cell backing: static vectors rebuilt when cache refreshes. + // ------------------------------------------------------------------------- + + static const std::vector k_headers = { + "status", "enricher", "target", "progress", "time_ms" + }; + constexpr int k_ncols = 5; + + static std::vector s_cell_backing; + static std::vector s_cells; + static int s_rows_cached = -1; + static int s_filter_cached = -1; + + // Rebuild when cache was refreshed or filter changed. + bool needs_rebuild = (s_rows_cached != (int)g_jobs_cache.rows.size()) || + (s_filter_cached != g_jobs_cache.filter_idx) || + (frame - g_jobs_cache.last_frame_refresh <= 10); // just refreshed + + if (needs_rebuild) { + // Collect filtered rows. + std::vector frows; + frows.reserve(g_jobs_cache.rows.size()); + for (const auto& r : g_jobs_cache.rows) { + if (filter_match(r.status, g_jobs_cache.filter_idx)) + frows.push_back(&r); + } + + const int nrows = (int)frows.size(); + s_cell_backing.resize((size_t)nrows * k_ncols); + s_cells.resize((size_t)nrows * k_ncols); + + for (int i = 0; i < nrows; ++i) { + const JobRow& r = *frows[(size_t)i]; + const size_t base = (size_t)i * k_ncols; + + // status + s_cell_backing[base + 0] = r.status; + + // enricher + s_cell_backing[base + 1] = r.enricher_id; + + // target: node_name > node_id > "(global)" + if (!r.node_name.empty()) + s_cell_backing[base + 2] = r.node_name; + else if (!r.node_id.empty()) + s_cell_backing[base + 2] = r.node_id; + else + s_cell_backing[base + 2] = "(global)"; + + // progress: float 0..1 -> string (data_table Progress renderer + // parses via strtof; value is in [0,1]). + char pbuf[16]; + std::snprintf(pbuf, sizeof(pbuf), "%.4f", (float)r.progress); + s_cell_backing[base + 3] = pbuf; + + // time_ms: duration in milliseconds as float string. + char tbuf[24]; + long long ms = job_duration_ms(r); + std::snprintf(tbuf, sizeof(tbuf), "%lld", ms); + s_cell_backing[base + 4] = tbuf; + } + + for (size_t k = 0; k < s_cell_backing.size(); ++k) + s_cells[k] = s_cell_backing[k].c_str(); + + s_rows_cached = (int)g_jobs_cache.rows.size(); + s_filter_cached = g_jobs_cache.filter_idx; + } + + // Build column specs with declarative renderers. + static const std::vector k_col_specs = []() { + std::vector specs(k_ncols); + + // Col 0: status — Badge + specs[0].id = "status"; + specs[0].renderer = data_table::CellRenderer::Badge; + specs[0].badges = { + { "running", "#3b82f6", "Running" }, + { "done", "#22c55e", "Done" }, + { "error", "#ef4444", "Error" }, + { "cancelled", "#9ca3af", "Cancelled" }, + { "queued", "#f59e0b", "Queued" }, + }; + + // Col 1: enricher — Text (default) + specs[1].id = "enricher"; + specs[1].renderer = data_table::CellRenderer::Text; + + // Col 2: target — Text (default) + specs[2].id = "target"; + specs[2].renderer = data_table::CellRenderer::Text; + + // Col 3: progress — Progress bar, values are 0..1 + specs[3].id = "progress"; + specs[3].renderer = data_table::CellRenderer::Progress; + specs[3].progress_scale_100 = false; + + // Col 4: time_ms — Duration, warn=1000ms error=10000ms + specs[4].id = "time_ms"; + specs[4].renderer = data_table::CellRenderer::Duration; + specs[4].duration_warn_ms = 1000.0f; + specs[4].duration_error_ms = 10000.0f; + + return specs; + }(); + + // Compute available height for main table. + float avail_h = ImGui::GetContentRegionAvail().y - 4.0f; + if (avail_h < 80.0f) avail_h = 80.0f; + + ImGui::TextDisabled("Data"); + ImGui::BeginChild("##jobs_data", ImVec2(0.0f, avail_h), ImGuiChildFlags_None); + + int nrows_vis = s_rows_cached; // total cached — filtered subset + // Recompute actual filtered row count for the data child. + int nrows_data = 0; + for (const auto& r : g_jobs_cache.rows) { + if (filter_match(r.status, g_jobs_cache.filter_idx)) ++nrows_data; + } + (void)nrows_vis; + + if (nrows_data == 0) { + ImGui::TextDisabled("(no jobs match current filter)"); + } else { + data_table::TableInput tbl; + tbl.name = "jobs"; + tbl.headers = k_headers; + tbl.types = { + data_table::ColumnType::String, // status + data_table::ColumnType::String, // enricher + data_table::ColumnType::String, // target + data_table::ColumnType::Float, // progress + data_table::ColumnType::Float, // time_ms + }; + tbl.cells = s_cells.empty() ? nullptr : s_cells.data(); + tbl.rows = nrows_data; + tbl.cols = k_ncols; + tbl.column_specs = k_col_specs; + + data_table::render("##jobs_dt", {tbl}, app.jobs_dt_state); + } + + ImGui::EndChild(); ImGui::End(); }