048a4a1457
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>
281 lines
10 KiB
C++
281 lines
10 KiB
C++
#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
|