feat(dev): issues 0100-0104 — dev_console binary + work_tab + DoD user-facing + frontmatter migration de 146 issues + taxonomia canonica
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ add_imgui_app(registry_dashboard
|
|||||||
http_client.cpp
|
http_client.cpp
|
||||||
ws_client.cpp
|
ws_client.cpp
|
||||||
views.cpp
|
views.cpp
|
||||||
|
work_tab.cpp
|
||||||
${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp
|
${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp
|
||||||
${CMAKE_SOURCE_DIR}/functions/viz/bar_chart.cpp
|
${CMAKE_SOURCE_DIR}/functions/viz/bar_chart.cpp
|
||||||
${CMAKE_SOURCE_DIR}/functions/viz/pie_chart.cpp
|
${CMAKE_SOURCE_DIR}/functions/viz/pie_chart.cpp
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
#include "views.h"
|
#include "views.h"
|
||||||
#include "data_http.h"
|
#include "data_http.h"
|
||||||
|
#include "work_tab.h"
|
||||||
#include <filesystem>
|
#include <filesystem>
|
||||||
#include "imgui.h"
|
#include "imgui.h"
|
||||||
#include "implot.h"
|
#include "implot.h"
|
||||||
@@ -1766,6 +1767,10 @@ void draw_dashboard(RegistryData& data) {
|
|||||||
draw_monitor(data);
|
draw_monitor(data);
|
||||||
ImGui::EndTabItem();
|
ImGui::EndTabItem();
|
||||||
}
|
}
|
||||||
|
if (ImGui::BeginTabItem("Work")) {
|
||||||
|
draw_work_tab();
|
||||||
|
ImGui::EndTabItem();
|
||||||
|
}
|
||||||
if (ImGui::BeginTabItem("Dashboard")) {
|
if (ImGui::BeginTabItem("Dashboard")) {
|
||||||
draw_kpi_row(data);
|
draw_kpi_row(data);
|
||||||
ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md));
|
ImGui::Dummy(ImVec2(0, fn_tokens::spacing::md));
|
||||||
|
|||||||
+311
@@ -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 <chrono>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <filesystem>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#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<std::string> apps;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct IssueSlim {
|
||||||
|
std::string id, title, status, type, priority;
|
||||||
|
std::vector<std::string> domain, depends;
|
||||||
|
bool deps_resolved{false};
|
||||||
|
int acceptance_pct{0};
|
||||||
|
};
|
||||||
|
|
||||||
|
struct WorkData {
|
||||||
|
IssueStats issue_stats;
|
||||||
|
IssueStats flow_stats;
|
||||||
|
std::vector<FlowSlim> flows;
|
||||||
|
std::vector<IssueSlim> 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<fs::path> 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/<cfg>/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<milliseconds>(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<std::string>());
|
||||||
|
}
|
||||||
|
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<std::string>());
|
||||||
|
}
|
||||||
|
if (i.contains("depends") && i["depends"].is_array()) {
|
||||||
|
for (auto& d : i["depends"]) is.depends.push_back(d.get<std::string>());
|
||||||
|
}
|
||||||
|
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<std::string>& 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<std::string>& 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+16
@@ -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
|
||||||
|
// `<repo_root>/apps/dev_console/dev_console`. Sin binario -> placeholder
|
||||||
|
// con instrucciones de build.
|
||||||
|
|
||||||
|
void draw_work_tab();
|
||||||
Reference in New Issue
Block a user