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) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:30:23 +02:00
parent e271b6e7f8
commit c1e88af5c7
2 changed files with 305 additions and 0 deletions
+274
View File
@@ -0,0 +1,274 @@
#include "dod_evidence_panel.h"
#include "core/icons_tabler.h"
#include "core/tokens.h"
#include <imgui.h>
#include <cstdio>
#include <fstream>
#include <sstream>
#include <string>
#ifdef _WIN32
# ifndef WIN32_LEAN_AND_MEAN
# define WIN32_LEAN_AND_MEAN
# endif
# include <windows.h>
# include <shellapi.h>
#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<size_t>(sz);
if (n > cap) n = cap;
s.resize(n);
f.seekg(0, std::ios::beg);
f.read(&s[0], static_cast<std::streamsize>(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<string, GLuint> 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
+31
View File
@@ -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