// 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 { namespace { // Cache de la lista de jobs. Se refresca cada N frames para no abrir SQLite // en cada frame. ~10 Hz es suficiente para una progress bar fluida. struct JobsCache { std::vector rows; int last_frame_refresh = -100; int filter_idx = 0; // 0=all 1=active 2=done 3=error }; JobsCache g_jobs_cache; 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); if (s == "error") return ImVec4(0.95f, 0.45f, 0.45f, 1.0f); if (s == "cancelled") return ImVec4(0.65f, 0.65f, 0.65f, 1.0f); if (s == "queued") return ImVec4(0.85f, 0.78f, 0.45f, 1.0f); return ImVec4(0.7f, 0.7f, 0.7f, 1.0f); } bool filter_match(const std::string& status, int idx) { switch (idx) { 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) { if (!app.panel_jobs) return; if (!ImGui::Begin("Jobs", &app.panel_jobs)) { ImGui::End(); return; } // Refresh cache cada ~10 frames (~6 Hz a 60fps). int frame = ImGui::GetFrameCount(); if (frame - g_jobs_cache.last_frame_refresh > 10) { jobs_list(&g_jobs_cache.rows, 200); g_jobs_cache.last_frame_refresh = frame; } // Header: counters + filtro. JobCounters c = jobs_counters(); ImGui::TextColored(status_color("running"), "%s", TI_PLAYER_PLAY); ImGui::SameLine(); ImGui::Text("%d", c.running); ImGui::SameLine(0, 16); ImGui::TextColored(status_color("queued"), "%s", TI_HOURGLASS); ImGui::SameLine(); ImGui::Text("%d", c.queued); ImGui::SameLine(0, 16); ImGui::TextColored(status_color("done"), "%s", TI_CHECK); ImGui::SameLine(); ImGui::Text("%d", c.done); ImGui::SameLine(0, 16); ImGui::TextColored(status_color("error"), "%s", TI_ALERT_CIRCLE); ImGui::SameLine(); ImGui::Text("%d", c.error + c.cancelled); ImGui::SameLine(0, 24); const char* filter_labels[] = { "All", "Active", "Done", "Errors" }; ImGui::SetNextItemWidth(100); ImGui::Combo("##jobs_filter", &g_jobs_cache.filter_idx, filter_labels, IM_ARRAYSIZE(filter_labels)); ImGui::Separator(); // ------------------------------------------------------------------------- // 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). // ------------------------------------------------------------------------- // 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(); } } // namespace ge