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:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user