Files
graph_explorer/views_jobs.cpp
T
egutierrez 6df04652d8 feat(jobs): sistema de jobs asincronos + panel UI (issue 0026)
Infra para correr enrichers en background mientras la app sigue interactiva.

C++:
- jobs.{h,cpp}: tabla jobs en graph_explorer.db, JobRunner con N=2 std::thread
  workers, fork+exec POSIX con pipes, parser de PROGRESS:<float> <stage> en
  stderr, captura de stdout JSON, persistencia + dirty_counter.
- enrichers.{h,cpp}: scanner de enrichers/<id>/manifest.yaml, parser YAML
  minimo (id/name/description/applies_to), filtro por tipo de nodo.
- views_jobs.cpp: panel "Jobs" dockeable con tabla (status/enricher/target/
  progress/time), filtro all/active/done/errors, cancelar/borrar inline.

Wiring:
- main.cpp: resolve_registry_root() (FN_REGISTRY_ROOT env o subir desde cwd
  buscando registry.db), jobs_init/enrichers_load antes de fn::run_app,
  jobs_shutdown al cerrar, dirty_counter -> want_reload, jobs_set_ops_db al
  cambiar de proyecto.
- main.cpp:render_context_menu: menu "Run enricher" sustituye placeholder
  con submenu filtrado por type_ref via enrichers_for_type. Submit abre
  panel Jobs auto.
- views.h: AppState::panel_jobs flag + decl views_jobs().
- CMakeLists.txt: anade jobs.cpp + enrichers.cpp + views_jobs.cpp y enlaza
  Threads::Threads.

Wire protocol enricher (subprocess Python):
- stdin:  JSON con node_id, metadata, ops_db_path, app_dir, cache_dir,
          registry_root, params.
- stderr: PROGRESS:<float> <stage> + LOG lineas libres.
- stdout: JSON resumen al final.
- exit 0 = ok, !=0 = error con stderr capturado en panel Jobs.

El run.py escribe directamente al operations.db (sqlite3 stdlib) — C++ solo
orquesta, no parsea entities/relations.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:24:37 +02:00

200 lines
7.3 KiB
C++

#include "views.h"
#include "jobs.h"
#include "core/icons_tabler.h"
#include "core/tokens.h"
#include "imgui.h"
#include <cfloat>
#include <chrono>
#include <cstdio>
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<JobRow> 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);
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);
}
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::milliseconds>(
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 2: return status == "done";
case 3: return status == "error" || status == "cancelled";
default: return true;
}
}
} // 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();
// 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();
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();
}
ImGui::End();
}
} // namespace ge