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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user