diff --git a/CMakeLists.txt b/CMakeLists.txt index 6d768ca..b77441b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -21,6 +21,7 @@ add_imgui_app(registry_dashboard http_client.cpp ws_client.cpp views.cpp + work_tab.cpp ${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp ${CMAKE_SOURCE_DIR}/functions/viz/bar_chart.cpp ${CMAKE_SOURCE_DIR}/functions/viz/pie_chart.cpp diff --git a/views.cpp b/views.cpp index 8e0c40d..a14715d 100644 --- a/views.cpp +++ b/views.cpp @@ -1,5 +1,6 @@ #include "views.h" #include "data_http.h" +#include "work_tab.h" #include #include "imgui.h" #include "implot.h" @@ -1766,6 +1767,10 @@ void draw_dashboard(RegistryData& data) { draw_monitor(data); ImGui::EndTabItem(); } + if (ImGui::BeginTabItem("Work")) { + draw_work_tab(); + ImGui::EndTabItem(); + } if (ImGui::BeginTabItem("Dashboard")) { draw_kpi_row(data); ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md)); diff --git a/work_tab.cpp b/work_tab.cpp new file mode 100644 index 0000000..2aa7172 --- /dev/null +++ b/work_tab.cpp @@ -0,0 +1,311 @@ +// 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 "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 + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::TextUnformatted("Flows"); + ImGui::PopStyleColor(); + + if (ImGui::BeginTable("##flows_work", 8, + ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders | + ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Resizable)) { + ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 50.0f); + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("Pattern", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Risk", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Accept", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("DoD", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("UserFace", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableHeadersRow(); + + std::string buf; + for (auto& f : g_data.flows) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::TextUnformatted(f.id.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(f.name.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(f.pattern.empty() ? "-" : f.pattern.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(f.status.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(f.risk.c_str()); + ImGui::TableNextColumn(); ImGui::Text("%d%%", f.acceptance_pct); + ImGui::TableNextColumn(); ImGui::Text("%d%%", f.dod_pct); + ImGui::TableNextColumn(); ImGui::Text("%d%%", f.user_facing_pct); + } + ImGui::EndTable(); + } + + ImGui::Spacing(); + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::text_muted); + ImGui::TextUnformatted("Top issues (priority alta, not done)"); + ImGui::PopStyleColor(); + + if (ImGui::BeginTable("##top_issues_work", 7, + ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders | + ImGuiTableFlags_SizingStretchProp | ImGuiTableFlags_Resizable)) { + ImGui::TableSetupColumn("ID", ImGuiTableColumnFlags_WidthFixed, 70.0f); + ImGui::TableSetupColumn("Title"); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableSetupColumn("Domain", ImGuiTableColumnFlags_WidthFixed, 140.0f); + ImGui::TableSetupColumn("Status", ImGuiTableColumnFlags_WidthFixed, 90.0f); + ImGui::TableSetupColumn("Deps", ImGuiTableColumnFlags_WidthFixed, 110.0f); + ImGui::TableSetupColumn("Prio", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableHeadersRow(); + + std::string buf; + for (auto& i : g_data.top_issues) { + ImGui::TableNextRow(); + ImGui::TableNextColumn(); ImGui::TextUnformatted(i.id.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(i.title.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(i.type.c_str()); + ImGui::TableNextColumn(); ImGui::TextUnformatted(join_domain(i.domain, buf)); + ImGui::TableNextColumn(); ImGui::TextUnformatted(i.status.c_str()); + ImGui::TableNextColumn(); + if (i.depends.empty()) { + ImGui::TextUnformatted("-"); + } else if (i.deps_resolved) { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::success); + ImGui::TextUnformatted("OK"); + ImGui::PopStyleColor(); + } else { + ImGui::PushStyleColor(ImGuiCol_Text, fn_tokens::colors::warning); + ImGui::Text("blocked"); + ImGui::PopStyleColor(); + } + ImGui::TableNextColumn(); + ImGui::PushStyleColor(ImGuiCol_Text, prio_color(i.priority)); + ImGui::TextUnformatted(i.priority.c_str()); + ImGui::PopStyleColor(); + } + ImGui::EndTable(); + } +} diff --git a/work_tab.h b/work_tab.h new file mode 100644 index 0000000..5aab70f --- /dev/null +++ b/work_tab.h @@ -0,0 +1,16 @@ +#pragma once + +// Tab "Work" del registry_dashboard — issue 0102. +// +// Consume `dev_console work dashboard --json` (apps/dev_console) cada N +// segundos y renderiza: KPIs de issues por estado + tabla de flows con +// Acceptance/DoD/User-facing % + top issues priorizados. +// +// Cache 30s para no spammear el subproceso. Mostrar "stale (Ns)" en header +// si la cache supera la edad. +// +// El binario `dev_console` debe estar accesible en PATH o en +// `/apps/dev_console/dev_console`. Sin binario -> placeholder +// con instrucciones de build. + +void draw_work_tab();