#include "viz/agent_runs_timeline.h" #include "viz/agent_runs_timeline_helpers.h" #include "core/icons_tabler.h" #include "core/tokens.h" #include "imgui.h" #include #include #include #include namespace fn_viz { namespace { // Map color token -> fn_tokens color. Mirrors timeline::status_color_token. ImVec4 token_to_color(int t) { using namespace fn_tokens::colors; switch (t) { case 1: return info; case 2: return success; case 3: return primary; case 5: return warning; case 6: return error; case 0: default: return text_muted; } } // Parse "#rrggbb" to ImVec4. On bad input returns text_muted. ImVec4 parse_hex(const std::string& h) { if (h.size() != 7 || h[0] != '#') return fn_tokens::colors::text_muted; auto hex2 = [](const std::string& s, size_t i) -> int { int v = 0; for (int k = 0; k < 2; ++k) { char c = s[i + k]; int d = (c >= '0' && c <= '9') ? c - '0' : (c >= 'a' && c <= 'f') ? 10 + c - 'a' : (c >= 'A' && c <= 'F') ? 10 + c - 'A' : -1; if (d < 0) return -1; v = v * 16 + d; } return v; }; int r = hex2(h, 1), g = hex2(h, 3), b = hex2(h, 5); if (r < 0 || g < 0 || b < 0) return fn_tokens::colors::text_muted; return ImVec4(r / 255.0f, g / 255.0f, b / 255.0f, 1.0f); } void draw_app_chip(const std::string& app) { ImVec4 c = parse_hex(timeline::app_chip_hex(app)); ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(c.x, c.y, c.z, 0.20f)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(c.x, c.y, c.z, 0.30f)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(c.x, c.y, c.z, 0.40f)); ImGui::PushStyleColor(ImGuiCol_Text, c); ImGui::SmallButton(app.empty() ? "?" : app.c_str()); ImGui::PopStyleColor(4); } void draw_dod_badge(int done, int validated, int total) { char buf[48]; std::snprintf(buf, sizeof(buf), "%d/%d/%d", done, validated, total); ImVec4 c = (validated >= total && total > 0) ? fn_tokens::colors::success : (done > 0 ? fn_tokens::colors::info : fn_tokens::colors::text_muted); ImGui::TextColored(c, "%s", buf); } std::string fmt_started(int64_t ts) { if (ts <= 0) return "—"; std::time_t t = (std::time_t)ts; std::tm tm{}; #if defined(_WIN32) localtime_s(&tm, &t); #else localtime_r(&t, &tm); #endif char buf[32]; std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M", &tm); return std::string(buf); } void draw_filters(TimelineState& state, const std::vector& known_apps, const std::vector& known_statuses) { if (ImGui::Button(TI_FILTER " Apps")) ImGui::OpenPopup("##apps_filter"); if (ImGui::BeginPopup("##apps_filter")) { for (const auto& a : known_apps) { bool sel = false; for (const auto& s : state.filter.apps) if (s == a) { sel = true; break; } if (ImGui::Checkbox(a.c_str(), &sel)) { if (sel) state.filter.apps.push_back(a); else { state.filter.apps.erase( std::remove(state.filter.apps.begin(), state.filter.apps.end(), a), state.filter.apps.end()); } } } ImGui::EndPopup(); } ImGui::SameLine(); if (ImGui::Button(TI_FILTER " Status")) ImGui::OpenPopup("##status_filter"); if (ImGui::BeginPopup("##status_filter")) { for (const auto& s : known_statuses) { bool sel = false; for (const auto& x : state.filter.statuses) if (x == s) { sel = true; break; } if (ImGui::Checkbox(s.c_str(), &sel)) { if (sel) state.filter.statuses.push_back(s); else { state.filter.statuses.erase( std::remove(state.filter.statuses.begin(), state.filter.statuses.end(), s), state.filter.statuses.end()); } } } ImGui::EndPopup(); } ImGui::SameLine(); int days = (state.filter.since_ts == 0) ? 0 : (int)((std::time(nullptr) - state.filter.since_ts) / 86400); if (ImGui::InputInt("Since (days)", &days, 1, 7)) { if (days <= 0) state.filter.since_ts = 0; else state.filter.since_ts = std::time(nullptr) - (int64_t)days * 86400; } } void draw_connection_badge(const std::string& status) { ImVec4 c; const char* sym; if (status == "connected") { c = fn_tokens::colors::success; sym = "\xe2\x97\x8f"; // ● U+25CF } else if (status == "connecting") { c = fn_tokens::colors::warning; sym = "\xe2\x97\x90"; // ◐ } else { c = fn_tokens::colors::error; sym = "\xe2\x97\x8b"; // ○ U+25CB } ImGui::TextColored(c, "%s %s", sym, status.empty() ? "disconnected" : status.c_str()); } } // namespace void render_agent_runs_timeline(TimelineState& state) { // Snapshot under the lock so the rest of the frame is lock-free. std::vector runs_copy; std::string conn_copy; { std::lock_guard lk(state.runs_mutex); runs_copy = state.runs; conn_copy = state.connection_status; } // Build "known" sets from the runs themselves so the dropdowns reflect // whatever the source emits, even if it grows beyond the docs list. std::vector known_apps; std::vector known_statuses; { std::unordered_map apps_seen, status_seen; for (const auto& r : runs_copy) { if (!r.app.empty() && !apps_seen[r.app]++) known_apps.push_back(r.app); if (!r.status.empty() && !status_seen[r.status]++) known_statuses.push_back(r.status); } } draw_filters(state, known_apps, known_statuses); ImGui::SameLine(); ImGui::Dummy(ImVec2(16, 0)); ImGui::SameLine(); draw_connection_badge(conn_copy); ImGui::Separator(); auto rows = timeline::filter_and_sort(runs_copy, state.filter); if (ImGui::BeginTable("##agent_runs_table", 7, ImGuiTableFlags_RowBg | ImGuiTableFlags_Borders | ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) { ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 24.0f); ImGui::TableSetupColumn("app", ImGuiTableColumnFlags_WidthFixed, 130.0f); ImGui::TableSetupColumn("issue/card",ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("branch", ImGuiTableColumnFlags_WidthStretch); ImGui::TableSetupColumn("dod", ImGuiTableColumnFlags_WidthFixed, 70.0f); ImGui::TableSetupColumn("duration", ImGuiTableColumnFlags_WidthFixed, 90.0f); ImGui::TableSetupColumn("started", ImGuiTableColumnFlags_WidthFixed, 130.0f); ImGui::TableHeadersRow(); for (const auto& r : rows) { ImGui::TableNextRow(); // Whole-row highlight + selectable via the first cell spanning. ImGui::TableSetColumnIndex(0); bool selected = (state.selected_run_id == r.id); ImGui::PushID(r.id.c_str()); // Icon coloured by status. ImVec4 c = token_to_color(timeline::status_color_token(r.status)); ImGui::TextColored(c, "%s", timeline::status_icon_id(r.status).c_str()); // Make the row clickable: invisible button spanning all columns. // Simpler — use Selectable on cell 1 with SpanAllColumns. ImGui::TableSetColumnIndex(1); char label[96]; std::snprintf(label, sizeof(label), "##row_%s", r.id.c_str()); if (ImGui::Selectable(label, selected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { state.selected_run_id = r.id; if (state.on_select) state.on_select(r.id); } ImGui::SameLine(); draw_app_chip(r.app); ImGui::TableSetColumnIndex(2); if (!r.issue_id.empty() && !r.card_id.empty()) { ImGui::Text("%s / %s", r.issue_id.c_str(), r.card_id.c_str()); } else if (!r.issue_id.empty()) { ImGui::TextUnformatted(r.issue_id.c_str()); } else { ImGui::TextUnformatted(r.card_id.c_str()); } ImGui::TableSetColumnIndex(3); ImGui::TextUnformatted(r.branch.c_str()); ImGui::TableSetColumnIndex(4); draw_dod_badge(r.dod_done, r.dod_validated, r.dod_total); ImGui::TableSetColumnIndex(5); std::string dur = timeline::format_duration(r.started_at, r.finished_at); ImGui::TextColored( r.finished_at == 0 ? fn_tokens::colors::info : fn_tokens::colors::text, "%s", dur.c_str()); ImGui::TableSetColumnIndex(6); ImGui::TextUnformatted(fmt_started(r.started_at).c_str()); ImGui::PopID(); } ImGui::EndTable(); } // Footer counts per status (over the FULL set, not the filtered one — gives // a global sense even when filters narrow the table). ImGui::Separator(); std::unordered_map counts; for (const auto& r : runs_copy) counts[r.status]++; bool first = true; for (const auto& s : {"pending", "running", "done", "validated", "merged", "aborted", "failed"}) { int n = counts[s]; if (!first) { ImGui::SameLine(); ImGui::TextUnformatted(" "); ImGui::SameLine(); } first = false; ImVec4 c = token_to_color(timeline::status_color_token(s)); ImGui::TextColored(c, "%s: %d", s, n); } } void poll_sse_runs(TimelineState& state) { // STUB. See header comment + agent_runs_timeline.md ## Gotchas. // // Intended future wiring: // 1. spawn background thread with libcurl multi handle on state.sse_url // - parse "data:" lines, json-decode AgentRun events // - lock state.runs_mutex; upsert by run.id; set connection_status // 2. OR call fn_core::http_request("GET", url + "?since=...", ...) // every N seconds and full-refresh state.runs // // For now: noop so that consumers that wire poll_sse_runs() in their // main loop don't crash. Consumers populate state.runs manually // (fixtures, in-process orchestrator) until a real SSE issue lands. (void)state; } } // namespace fn_viz