From c1e88af5c7a9948eaf4c3ca1d8ad19dfa73d21fa Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Mon, 18 May 2026 18:30:23 +0200 Subject: [PATCH] feat(viz): render ImGui dod_evidence_panel (issue 0117) Panel con header (run_id + counts) + tabla 6-col (status icon / id / kind / expected / evidence preview / actions). Soporta 4 kinds de evidence: screenshot (stub textual), log (5-line preview + popup), url (xdg-open/ShellExecuteA) y cmd (diff expected vs actual). Botones Validate/Reject invocan callbacks on_validate/on_reject. Co-Authored-By: Claude Opus 4.7 (1M context) --- cpp/functions/viz/dod_evidence_panel.cpp | 274 +++++++++++++++++++++++ cpp/functions/viz/dod_evidence_panel.h | 31 +++ 2 files changed, 305 insertions(+) create mode 100644 cpp/functions/viz/dod_evidence_panel.cpp create mode 100644 cpp/functions/viz/dod_evidence_panel.h diff --git a/cpp/functions/viz/dod_evidence_panel.cpp b/cpp/functions/viz/dod_evidence_panel.cpp new file mode 100644 index 00000000..27d50ffe --- /dev/null +++ b/cpp/functions/viz/dod_evidence_panel.cpp @@ -0,0 +1,274 @@ +#include "dod_evidence_panel.h" +#include "core/icons_tabler.h" +#include "core/tokens.h" + +#include + +#include +#include +#include +#include + +#ifdef _WIN32 +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# include +# include +#endif + +namespace fn_viz { +namespace { + +// Mapea icon_id (helpers) -> macro TI_*. +const char* ti_for(const std::string& icon_id) { + if (icon_id == "circle-dot") return TI_CIRCLE_DOT; + if (icon_id == "circle-check") return TI_CIRCLE_CHECK; + if (icon_id == "circle-x") return TI_CIRCLE_X; + return TI_CIRCLE_DASHED; +} + +// Color por token (0=neutral, 1=info, 2=success, 3=danger). +ImVec4 color_for_token(int tok) { + using namespace fn_tokens; + switch (tok) { + case 1: return colors::info; + case 2: return colors::success; + case 3: return colors::error; + default: return colors::text_muted; + } +} + +// Abre una URL en el browser por defecto. Stub en plataformas no soportadas. +void open_url(const std::string& url) { +#ifdef _WIN32 + ShellExecuteA(nullptr, "open", url.c_str(), nullptr, nullptr, SW_SHOWNORMAL); +#else + // xdg-open con argumento quoteado minimo (sin caracteres especiales no + // se gana mucho con quoting completo). Si el caller pasa una URL + // controlada por usuario, esto deberia validarse antes. + std::string cmd = "xdg-open '" + url + "' >/dev/null 2>&1 &"; + int rc = std::system(cmd.c_str()); + (void)rc; +#endif +} + +// Lee primeras N lineas de un archivo log. Devuelve "" si no se puede abrir. +std::string read_first_lines(const std::string& path, int max_lines) { + std::ifstream f(path); + if (!f.is_open()) return ""; + std::ostringstream out; + std::string line; + int n = 0; + while (n < max_lines && std::getline(f, line)) { + out << line << "\n"; + ++n; + } + return out.str(); +} + +// Carga entero el archivo. Truncado a 64KiB para popup. +std::string read_full_capped(const std::string& path, size_t cap = 64 * 1024) { + std::ifstream f(path, std::ios::binary); + if (!f.is_open()) return ""; + std::string s; + f.seekg(0, std::ios::end); + std::streamsize sz = f.tellg(); + if (sz < 0) return ""; + size_t n = static_cast(sz); + if (n > cap) n = cap; + s.resize(n); + f.seekg(0, std::ios::beg); + f.read(&s[0], static_cast(n)); + return s; +} + +void render_preview_screenshot(const DodEvidence& ev) { + // Sin GL context cacheado ni stb_image load aqui: stub textual. La carga + // real requiere mantener un map que el panel no + // posee. Las apps consumidoras pueden override esto sustituyendo la + // funcion (issue futura: callback render_preview). + ImGui::TextDisabled("[screenshot] %s", ev.payload_path.c_str()); +} + +void render_preview_log(const DodEvidence& ev, int row_idx) { + if (ev.payload_path.empty()) { + ImGui::TextDisabled("(no log path)"); + return; + } + std::string head = read_first_lines(ev.payload_path, 5); + if (head.empty()) { + ImGui::TextDisabled("(log not readable)"); + } else { + ImGui::TextWrapped("%s", head.c_str()); + } + char btn_id[64]; + std::snprintf(btn_id, sizeof(btn_id), "Show full##log_%d", row_idx); + if (ImGui::SmallButton(btn_id)) { + ImGui::OpenPopup(btn_id); + } + if (ImGui::BeginPopup(btn_id)) { + std::string full = read_full_capped(ev.payload_path); + ImGui::InputTextMultiline("##log_full", full.data(), full.size() + 1, + ImVec2(600, 400), ImGuiInputTextFlags_ReadOnly); + ImGui::EndPopup(); + } +} + +void render_preview_url(const DodEvidence& ev) { + ImGui::TextUnformatted(TI_EXTERNAL_LINK); + ImGui::SameLine(); + if (ImGui::SmallButton(ev.payload_url.c_str())) { + open_url(ev.payload_url); + } +} + +void render_preview_cmd(const DodEvidence& ev, const DodItem& item) { + using namespace fn_tokens; + const bool match = (ev.payload_text == item.expected); + ImVec4 ok = colors::success; + ImVec4 bad = colors::error; + if (ImGui::BeginTable("##cmd_diff", 2, ImGuiTableFlags_SizingStretchSame)) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("expected"); + ImGui::TextWrapped("%s", item.expected.c_str()); + ImGui::TableSetColumnIndex(1); + ImGui::TextDisabled("actual"); + ImGui::PushStyleColor(ImGuiCol_Text, match ? ok : bad); + ImGui::TextWrapped("%s", ev.payload_text.c_str()); + ImGui::PopStyleColor(); + ImGui::EndTable(); + } +} + +} // namespace + +void render_dod_evidence_panel(DodPanelState& state) { + using namespace fn_tokens; + namespace P = ::fn_viz::dod_panel; + + P::StatusCounts c = P::count_status(state); + + // Header. + ImGui::PushStyleColor(ImGuiCol_Text, colors::text_muted); + ImGui::Text("run_id:"); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::TextUnformatted(state.run_id.empty() ? "(none)" : state.run_id.c_str()); + + ImGui::SameLine(); + ImGui::TextDisabled(" | "); + ImGui::SameLine(); + ImGui::Text("total %d ", c.total); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, colors::info); + ImGui::Text("done %d ", c.done); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, colors::success); + ImGui::Text("validated %d ", c.validated); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, colors::error); + ImGui::Text("failed %d", c.failed); + ImGui::PopStyleColor(); + + if (c.missing_required > 0) { + ImGui::SameLine(); + ImGui::TextDisabled(" | "); + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, colors::error); + ImGui::Text("required missing: %d", c.missing_required); + ImGui::PopStyleColor(); + } + + ImGui::Separator(); + + // Tabla principal. + const ImGuiTableFlags flags = + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_SizingStretchProp; + if (!ImGui::BeginTable("##dod_table", 6, flags)) return; + + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); + ImGui::TableSetupColumn("id", ImGuiTableColumnFlags_WidthFixed, 120.0f); + ImGui::TableSetupColumn("kind", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("expected", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("evidence", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("actions", ImGuiTableColumnFlags_WidthFixed, 130.0f); + ImGui::TableHeadersRow(); + + int row_idx = 0; + for (const auto& item : state.items) { + ImGui::TableNextRow(); + ImGui::PushID(row_idx); + + // Col 0: status icon. + ImGui::TableSetColumnIndex(0); + const std::string icon_id = P::status_icon_id(item.status); + const int tok = P::status_color_token(item.status); + ImGui::PushStyleColor(ImGuiCol_Text, color_for_token(tok)); + ImGui::TextUnformatted(ti_for(icon_id)); + ImGui::PopStyleColor(); + + // Col 1: id (+ required badge si missing). + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(item.id.c_str()); + const DodEvidence* ev = P::find_evidence(state, item.id); + const bool resolved = (item.status == "done" || item.status == "validated"); + if (item.required && ev == nullptr && !resolved) { + ImGui::PushStyleColor(ImGuiCol_Text, colors::error); + ImGui::TextUnformatted("required, missing"); + ImGui::PopStyleColor(); + } + + // Col 2: kind. + ImGui::TableSetColumnIndex(2); + ImGui::TextUnformatted(item.kind.c_str()); + + // Col 3: expected. + ImGui::TableSetColumnIndex(3); + ImGui::TextWrapped("%s", item.expected.c_str()); + + // Col 4: evidence preview. + ImGui::TableSetColumnIndex(4); + if (ev == nullptr) { + ImGui::TextDisabled("(no evidence)"); + } else if (item.kind == "screenshot") { + render_preview_screenshot(*ev); + } else if (item.kind == "log") { + render_preview_log(*ev, row_idx); + } else if (item.kind == "url") { + render_preview_url(*ev); + } else if (item.kind == "cmd") { + render_preview_cmd(*ev, item); + } else { + ImGui::TextDisabled("(unknown kind: %s)", item.kind.c_str()); + } + + // Col 5: actions. + ImGui::TableSetColumnIndex(5); + if (ev != nullptr) { + ImGui::PushStyleColor(ImGuiCol_Text, colors::success); + const bool clicked_v = ImGui::SmallButton(TI_CHECK " Validate"); + ImGui::PopStyleColor(); + if (clicked_v && state.on_validate) state.on_validate(item.id); + + ImGui::SameLine(); + ImGui::PushStyleColor(ImGuiCol_Text, colors::error); + const bool clicked_r = ImGui::SmallButton(TI_X " Reject"); + ImGui::PopStyleColor(); + if (clicked_r && state.on_reject) state.on_reject(item.id); + } else { + ImGui::TextDisabled("—"); + } + + ImGui::PopID(); + ++row_idx; + } + + ImGui::EndTable(); +} + +} // namespace fn_viz diff --git a/cpp/functions/viz/dod_evidence_panel.h b/cpp/functions/viz/dod_evidence_panel.h new file mode 100644 index 00000000..8913b67e --- /dev/null +++ b/cpp/functions/viz/dod_evidence_panel.h @@ -0,0 +1,31 @@ +#pragma once + +// dod_evidence_panel — ImGui panel reutilizable para validar DoD evidence. +// +// Renderiza una tabla con items DoD (Definition of Done) + evidencias +// adjuntas. Cada fila: icono de status, id, kind, expected, preview de +// evidence (screenshot path / log preview / url clickable / cmd output con +// diff vs expected), y botones Validate/Reject que invocan callbacks. +// +// La logica pura (contar status, buscar evidence, mapear icon/color) vive en +// dod_evidence_panel_helpers.{h,cpp} y se testea sin ImGui. +// +// Uso: +// #include "viz/dod_evidence_panel.h" +// fn_viz::DodPanelState state; +// state.run_id = "run_abc123"; +// state.items = load_dod_items(...); +// state.evidences = load_dod_evidences(...); +// state.on_validate = [](const std::string& id) { db.mark_validated(id); }; +// state.on_reject = [](const std::string& id) { db.mark_failed(id); }; +// fn_viz::render_dod_evidence_panel(state); + +#include "dod_evidence_panel_helpers.h" + +namespace fn_viz { + +// Renderiza el panel inline en el current ImGui window. +// Debe llamarse dentro de un frame ImGui activo (entre NewFrame/Render). +void render_dod_evidence_panel(DodPanelState& state); + +} // namespace fn_viz