// Tab "Work" — issue 0102. // // Subprocess call a `dev_console work dashboard` con cache 30s. #include "work_tab.h" #include "imgui.h" #include "core/tokens.h" #include "core/page_header.h" #include "core/empty_state.h" #include "core/badge.h" #include "data_table/data_table.h" #include "core/data_table_types.h" #include "nlohmann/json.hpp" #include #include #include #include #include #include #include #ifdef _WIN32 #define POPEN _popen #define PCLOSE _pclose #else #define POPEN popen #define PCLOSE pclose #endif using json = nlohmann::json; namespace fs = std::filesystem; namespace { struct IssueStats { int total{0}, pendiente{0}, in_progress{0}, bloqueado{0}, completado{0}; }; struct FlowSlim { std::string id, name, status, pattern, risk, priority; int acceptance_pct{0}, dod_pct{0}, user_facing_pct{0}; std::vector apps; }; struct IssueSlim { std::string id, title, status, type, priority; std::vector domain, depends; bool deps_resolved{false}; int acceptance_pct{0}; }; struct WorkData { IssueStats issue_stats; IssueStats flow_stats; std::vector flows; std::vector top_issues; long long fetched_at_ms{0}; std::string fetch_error; }; static WorkData g_data; static bool g_first_fetch_done = false; static const long long kCacheTtlMs = 30 * 1000; // Localiza el binario dev_console. static std::string find_dev_console() { const char* root = std::getenv("FN_REGISTRY_ROOT"); std::vector candidates; if (root && *root) candidates.emplace_back(fs::path(root) / "apps/dev_console/dev_console"); candidates.emplace_back("./apps/dev_console/dev_console"); candidates.emplace_back("../../../apps/dev_console/dev_console"); // from build//bin candidates.emplace_back("dev_console"); for (auto& p : candidates) { std::error_code ec; if (fs::exists(p, ec)) return p.string(); } return "dev_console"; // fallback: depend on PATH } static long long now_ms() { using namespace std::chrono; return duration_cast(steady_clock::now().time_since_epoch()).count(); } static bool fetch_work(WorkData& out) { std::string bin = find_dev_console(); std::string cmd = bin + " work dashboard 2>/dev/null"; FILE* pipe = POPEN(cmd.c_str(), "r"); if (!pipe) { out.fetch_error = "popen() failed for " + bin; return false; } std::string body; char buf[4096]; while (fgets(buf, sizeof(buf), pipe)) body.append(buf); int rc = PCLOSE(pipe); if (rc != 0) { out.fetch_error = "dev_console exit " + std::to_string(rc) + " (binary at " + bin + ")"; return false; } try { json j = json::parse(body); auto parse_stats = [](const json& j, IssueStats& s) { s.total = j.value("total", 0); s.pendiente = j.value("pendiente", 0); s.in_progress = j.value("in_progress", 0); s.bloqueado = j.value("bloqueado", 0); s.completado = j.value("completado", 0); }; if (j.contains("issue_stats")) parse_stats(j["issue_stats"], out.issue_stats); if (j.contains("flow_stats")) parse_stats(j["flow_stats"], out.flow_stats); out.flows.clear(); if (j.contains("flows") && j["flows"].is_array()) { for (auto& f : j["flows"]) { FlowSlim fs; fs.id = f.value("id", ""); fs.name = f.value("name", ""); fs.status = f.value("status", ""); fs.pattern = f.value("pattern", ""); fs.risk = f.value("risk", ""); fs.priority = f.value("priority", ""); fs.acceptance_pct = f.value("acceptance_pct", 0); fs.dod_pct = f.value("dod_pct", 0); fs.user_facing_pct = f.value("user_facing_pct", 0); if (f.contains("apps") && f["apps"].is_array()) { for (auto& a : f["apps"]) fs.apps.push_back(a.get()); } out.flows.push_back(std::move(fs)); } } out.top_issues.clear(); if (j.contains("top_issues") && j["top_issues"].is_array()) { for (auto& i : j["top_issues"]) { IssueSlim is; is.id = i.value("id", ""); is.title = i.value("title", ""); is.status = i.value("status", ""); is.type = i.value("type", ""); is.priority = i.value("priority", ""); is.deps_resolved = i.value("deps_resolved", false); is.acceptance_pct = i.value("acceptance_pct", 0); if (i.contains("domain") && i["domain"].is_array()) { for (auto& d : i["domain"]) is.domain.push_back(d.get()); } if (i.contains("depends") && i["depends"].is_array()) { for (auto& d : i["depends"]) is.depends.push_back(d.get()); } out.top_issues.push_back(std::move(is)); } } out.fetch_error.clear(); out.fetched_at_ms = now_ms(); return true; } catch (const std::exception& e) { out.fetch_error = std::string("parse: ") + e.what(); return false; } } static void maybe_refetch(bool force = false) { long long now = now_ms(); if (!force && g_first_fetch_done && (now - g_data.fetched_at_ms) < kCacheTtlMs) return; fetch_work(g_data); g_first_fetch_done = true; } static const char* join_apps(const std::vector& apps, std::string& buf) { buf.clear(); for (size_t i = 0; i < apps.size(); ++i) { if (i) buf += ", "; buf += apps[i]; } return buf.c_str(); } static const char* join_domain(const std::vector& dom, std::string& buf) { buf.clear(); for (size_t i = 0; i < dom.size(); ++i) { if (i) buf += ","; buf += dom[i]; } return buf.c_str(); } static ImVec4 prio_color(const std::string& prio) { if (prio == "alta" || prio == "high") return fn_tokens::colors::error; if (prio == "media" || prio == "medium") return fn_tokens::colors::warning; return fn_tokens::colors::text_muted; } static void draw_kpi_block(const char* title, const IssueStats& s) { ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted(title); ImGui::PopStyleColor(); ImGui::Text("total=%d pendiente=%d in-progress=%d bloqueado=%d completado=%d", s.total, s.pendiente, s.in_progress, s.bloqueado, s.completado); } } // namespace void draw_work_tab() { maybe_refetch(); // Header + refresh page_header_begin("Work", "Issues + flows + KPIs (issue 0102)"); if (ImGui::Button("Refresh")) maybe_refetch(true); ImGui::SameLine(); long long age_ms = now_ms() - g_data.fetched_at_ms; long long age_s = age_ms / 1000; ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::Text("fetched %lld s ago", age_s); ImGui::PopStyleColor(); page_header_end(); if (!g_data.fetch_error.empty()) { ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::error); ImGui::Text("dev_console error: %s", g_data.fetch_error.c_str()); ImGui::PopStyleColor(); ImGui::Spacing(); ImGui::TextWrapped("Build the binary with: cd apps/dev_console && go build -o dev_console ."); return; } // KPI block ImGui::Spacing(); draw_kpi_block("Issues", g_data.issue_stats); ImGui::Spacing(); draw_kpi_block("Flows", g_data.flow_stats); ImGui::Spacing(); ImGui::Separator(); ImGui::Spacing(); // Flows table — migrado a data_table::render (issue 0107g) ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("Flows"); ImGui::PopStyleColor(); { static data_table::State g_st_flows; static std::vector g_back_flows; static std::vector g_ptrs_flows; g_back_flows.clear(); std::string tmp_buf; for (const auto& f : g_data.flows) { g_back_flows.push_back(f.id); g_back_flows.push_back(f.name); g_back_flows.push_back(f.pattern.empty() ? "-" : f.pattern); g_back_flows.push_back(f.status); g_back_flows.push_back(f.risk); g_back_flows.push_back(std::to_string(f.acceptance_pct) + "%"); g_back_flows.push_back(std::to_string(f.dod_pct) + "%"); g_back_flows.push_back(std::to_string(f.user_facing_pct) + "%"); } g_ptrs_flows.clear(); for (const auto& s : g_back_flows) g_ptrs_flows.push_back(s.c_str()); data_table::TableInput tbl; tbl.name = "flows_work"; tbl.headers = {"ID", "Name", "Pattern", "Status", "Risk", "Accept", "DoD", "UserFace"}; tbl.types = { data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, }; tbl.cells = g_ptrs_flows.empty() ? nullptr : g_ptrs_flows.data(); tbl.rows = (int)g_data.flows.size(); tbl.cols = 8; tbl.column_specs.resize(tbl.cols); for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i]; // Status → CategoricalChip tbl.column_specs[3].renderer = data_table::CellRenderer::CategoricalChip; tbl.column_specs[3].chips = { {"activo", "#22c55e"}, {"done", "#22c55e"}, {"in-progress","#f59e0b"}, {"bloqueado","#ef4444"}, {"pendiente", "#a3a3a3"}, }; // Risk → CategoricalChip tbl.column_specs[4].renderer = data_table::CellRenderer::CategoricalChip; tbl.column_specs[4].chips = { {"alta", "#ef4444"}, {"high", "#ef4444"}, {"media", "#f59e0b"}, {"medium", "#f59e0b"}, {"baja", "#22c55e"}, {"low", "#22c55e"}, }; ImGui::BeginChild("##flows_work_host", ImVec2(-1, 220)); data_table::render("##flows_work_dt", {tbl}, g_st_flows, nullptr); ImGui::EndChild(); } ImGui::Spacing(); ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); ImGui::TextUnformatted("Top issues (priority alta, not done)"); ImGui::PopStyleColor(); // Top issues table — migrado a data_table::render (issue 0107g) // Nota: la columna Deps original tenia colores condicionales (verde/amber segun deps_resolved). // Mapeado a CategoricalChip: "-" (gris), "OK" (verde), "blocked" (amber). { static data_table::State g_st_issues; static std::vector g_back_issues; static std::vector g_ptrs_issues; g_back_issues.clear(); std::string dom_buf; for (const auto& iss : g_data.top_issues) { g_back_issues.push_back(iss.id); g_back_issues.push_back(iss.title); g_back_issues.push_back(iss.type); // domain: join con coma dom_buf.clear(); for (size_t k = 0; k < iss.domain.size(); ++k) { if (k) dom_buf += ","; dom_buf += iss.domain[k]; } g_back_issues.push_back(dom_buf); g_back_issues.push_back(iss.status); // deps: mostrar "-" / "OK" / "blocked" if (iss.depends.empty()) { g_back_issues.push_back("-"); } else if (iss.deps_resolved) { g_back_issues.push_back("OK"); } else { g_back_issues.push_back("blocked"); } g_back_issues.push_back(iss.priority); } g_ptrs_issues.clear(); for (const auto& s : g_back_issues) g_ptrs_issues.push_back(s.c_str()); data_table::TableInput tbl; tbl.name = "top_issues_work"; tbl.headers = {"ID", "Title", "Type", "Domain", "Status", "Deps", "Prio"}; tbl.types = { data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, data_table::ColumnType::String, }; tbl.cells = g_ptrs_issues.empty() ? nullptr : g_ptrs_issues.data(); tbl.rows = (int)g_data.top_issues.size(); tbl.cols = 7; tbl.column_specs.resize(tbl.cols); for (int i = 0; i < tbl.cols; i++) tbl.column_specs[i].id = tbl.headers[i]; // Status → CategoricalChip tbl.column_specs[4].renderer = data_table::CellRenderer::CategoricalChip; tbl.column_specs[4].chips = { {"pendiente", "#a3a3a3"}, {"in-progress", "#f59e0b"}, {"bloqueado", "#ef4444"}, {"completado", "#22c55e"}, }; // Deps → CategoricalChip (verde OK / amber blocked / gris -) tbl.column_specs[5].renderer = data_table::CellRenderer::CategoricalChip; tbl.column_specs[5].chips = { {"OK", "#22c55e"}, {"blocked", "#f59e0b"}, {"-", "#a3a3a3"}, }; // Prio → CategoricalChip tbl.column_specs[6].renderer = data_table::CellRenderer::CategoricalChip; tbl.column_specs[6].chips = { {"alta", "#ef4444"}, {"high", "#ef4444"}, {"media", "#f59e0b"}, {"medium", "#f59e0b"}, {"baja", "#22c55e"}, {"low", "#22c55e"}, }; ImGui::BeginChild("##top_issues_work_host", ImVec2(-1, -1)); data_table::render("##top_issues_work_dt", {tbl}, g_st_issues, nullptr); ImGui::EndChild(); } }