feat(viz): agent_runs_timeline ImGui panel + SSE stub

Issue 0118.

fn_viz::render_agent_runs_timeline(TimelineState&):
- Filtros: multi-select apps, multi-select statuses, Since (days).
- Connection badge (● green / ◐ amber / ○ red) por state.connection_status.
- Tabla 7 cols: status icon | app chip | issue/card | branch | dod badge |
  duration | started. Selectable SpanAllColumns dispara on_select callback.
- Footer: contadores per-status sobre el set completo.

Thread-safe: snapshot bajo runs_mutex al inicio del frame. SSE client
NO implementado — poll_sse_runs() es stub documentado en .md ## Gotchas.
Consumer puede usar http_request_cpp_core para polling fallback contra
GET /api/runs hasta que un endpoint /api/runs/stream estable aparezca.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-18 18:31:29 +02:00
parent c2bdc586a4
commit a91ef5aace
2 changed files with 362 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
#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 <algorithm>
#include <cstdio>
#include <ctime>
#include <unordered_map>
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<std::string>& known_apps,
const std::vector<std::string>& 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<AgentRun> runs_copy;
std::string conn_copy;
{
std::lock_guard<std::mutex> 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<std::string> known_apps;
std::vector<std::string> known_statuses;
{
std::unordered_map<std::string, int> 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<std::string, int> 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
+82
View File
@@ -0,0 +1,82 @@
#pragma once
//
// agent_runs_timeline.h — ImGui panel: cross-app timeline of agent runs.
//
// API surface declared per issue 0118. Render is impure (uses ImGui); pure
// helpers live in agent_runs_timeline_helpers.{h,cpp} so unit tests can
// exercise filter / sort / formatting without a GL context.
//
// Typical wiring (consumer side):
//
// fn_viz::TimelineState g_state;
// g_state.sse_url = "http://127.0.0.1:8497/api/runs/stream";
// g_state.on_select = [](const std::string& id){ open_detail_panel(id); };
//
// ImGui::Begin("Agent runs");
// fn_viz::render_agent_runs_timeline(g_state);
// ImGui::End();
//
// // Whenever a worker thread (real SSE client, polling fallback, fixture)
// // wants to update the list:
// {
// std::lock_guard<std::mutex> lk(g_state.runs_mutex);
// g_state.runs = new_runs;
// g_state.connection_status = "connected";
// }
//
// SSE client itself is OUT OF SCOPE for this issue — see the documented stub
// in poll_sse_runs() and the .md ## Gotchas section.
#include <cstdint>
#include <functional>
#include <mutex>
#include <string>
#include <vector>
namespace fn_viz {
struct AgentRun {
std::string id;
std::string app; // kanban_cpp | skill_tree | ...
std::string issue_id;
std::string card_id;
std::string branch;
std::string status; // pending|running|done|validated|merged|aborted|failed
int64_t started_at = 0; // unix epoch seconds
int64_t finished_at = 0; // 0 if still running
int dod_total = 0;
int dod_done = 0;
int dod_validated = 0;
};
struct TimelineFilter {
std::vector<std::string> apps;
std::vector<std::string> statuses;
int64_t since_ts = 0; // 0 means "no lower bound"
};
struct TimelineState {
std::string sse_url;
std::vector<AgentRun> runs;
TimelineFilter filter;
std::function<void(const std::string& run_id)> on_select;
std::string selected_run_id;
std::string connection_status; // "connected"|"disconnected"|"connecting"
std::mutex runs_mutex; // SSE thread writes, UI reads
};
// Renders the timeline INLINE inside the current ImGui window. The caller
// owns ImGui::Begin/End — this function only draws filters, badge, table
// and footer counts. Safe to call every frame; cost is O(N) over runs.
void render_agent_runs_timeline(TimelineState& state);
// STUB. Documented in agent_runs_timeline.md ## Gotchas.
// Today this is a no-op. The intent is that a future issue wires either:
// (a) a real SSE client (libcurl multi + custom read callback), or
// (b) a polling fallback that calls fn_core::http_request("GET", url, ...)
// every N seconds and replaces state.runs under runs_mutex.
// Until then, the consumer is responsible for populating state.runs (e.g.
// test fixtures, or an in-process orchestrator that owns the data already).
void poll_sse_runs(TimelineState& state);
} // namespace fn_viz