#include #include "app_base.h" #include "core/panel_menu.h" #include "core/icons_tabler.h" #include "core/logger.h" #include "data_table/data_table.h" #include "core/data_table_types.h" #include "http_client.h" #include "vendor/nlohmann/json.hpp" #include #include #include #include #include #include #include #include #include using json = nlohmann::json; using clk = std::chrono::steady_clock; struct ServiceRow { std::string app_id; std::string app_name; std::string pc_id; bool is_self = false; bool reachable = true; std::string runtime; int port = 0; std::string health_endpoint; std::string systemd_unit; std::string systemd_state; bool port_listening = false; int http_status = 0; int http_latency_ms = 0; long long last_check_ts = 0; long long last_change_ts = 0; std::string last_error; std::string overall; }; struct Snapshot { std::vector rows; std::string self_pc; long long ts = 0; std::string fetch_error; long long fetched_at_ms = 0; }; // State shared between background fetch and render. Mutex protects all of it. static std::mutex g_mu; static Snapshot g_snap; static std::atomic g_fetching{false}; static std::atomic g_force_pending{false}; // Config. static char g_host_buf[128] = "127.0.0.1"; static int g_port = 8485; static bool g_show_overview = true; static bool g_auto_refresh = true; static int g_refresh_seconds = 5; static long long now_ms() { return std::chrono::duration_cast( clk::now().time_since_epoch()).count(); } static long long now_unix() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()).count(); } static std::string format_age(long long ts_seconds) { if (ts_seconds <= 0) return "—"; long long age = now_unix() - ts_seconds; if (age < 0) age = 0; if (age < 60) return std::to_string(age) + "s ago"; if (age < 3600) return std::to_string(age / 60) + "m ago"; if (age < 86400) return std::to_string(age / 3600) + "h ago"; return std::to_string(age / 86400) + "d ago"; } static ImVec4 color_for_overall(const std::string& o) { if (o == "ok") return ImVec4(0.36f, 0.85f, 0.55f, 1.0f); if (o == "degraded") return ImVec4(0.95f, 0.75f, 0.30f, 1.0f); if (o == "down") return ImVec4(0.92f, 0.40f, 0.40f, 1.0f); if (o == "no-route") return ImVec4(0.60f, 0.60f, 0.66f, 1.0f); if (o == "not-installed") return ImVec4(0.70f, 0.50f, 0.95f, 1.0f); // morado return ImVec4(0.55f, 0.55f, 0.60f, 1.0f); } // Parse JSON snapshot returned by /api/services or /api/check. static void parse_snapshot(const std::string& body, Snapshot& out) { out.rows.clear(); out.fetch_error.clear(); auto j = json::parse(body, nullptr, false); if (j.is_discarded()) { out.fetch_error = "invalid JSON in response"; return; } if (j.contains("self_pc") && j["self_pc"].is_string()) out.self_pc = j["self_pc"].get(); if (j.contains("ts") && j["ts"].is_number_integer()) out.ts = j["ts"].get(); if (!j.contains("services") || !j["services"].is_array()) { if (j.contains("error") && j["error"].is_string()) out.fetch_error = j["error"].get(); return; } for (const auto& s : j["services"]) { ServiceRow r; if (s.contains("app_id")) r.app_id = s.value("app_id", ""); if (s.contains("app_name")) r.app_name = s.value("app_name", ""); if (s.contains("pc_id")) r.pc_id = s.value("pc_id", ""); if (s.contains("is_self")) r.is_self = s.value("is_self", false); if (s.contains("reachable")) r.reachable = s.value("reachable", true); if (s.contains("runtime")) r.runtime = s.value("runtime", ""); if (s.contains("port")) r.port = s.value("port", 0); if (s.contains("health_endpoint"))r.health_endpoint = s.value("health_endpoint", ""); if (s.contains("systemd_unit")) r.systemd_unit = s.value("systemd_unit", ""); if (s.contains("systemd_state")) r.systemd_state = s.value("systemd_state", ""); if (s.contains("port_listening")) r.port_listening = s.value("port_listening", false); if (s.contains("http_status")) r.http_status = s.value("http_status", 0); if (s.contains("http_latency_ms"))r.http_latency_ms = s.value("http_latency_ms", 0); if (s.contains("last_check_ts")) r.last_check_ts = s.value("last_check_ts", 0LL); if (s.contains("last_change_ts")) r.last_change_ts = s.value("last_change_ts", 0LL); if (s.contains("last_error")) r.last_error = s.value("last_error", ""); if (s.contains("overall")) r.overall = s.value("overall", "unknown"); out.rows.push_back(std::move(r)); } } // Forward decl: defined below. static void fetch_async(const std::string& method); // Fire a restart POST for a given (app_id, pc_id) in the background and // trigger a fresh /api/services fetch once the server responds. Result is // briefly surfaced as a status string in g_action_status (read in the panel). static std::mutex g_action_mu; static std::string g_action_status; // "ok: /" or "err: ..." static long long g_action_status_until_ms = 0; static void restart_async(const std::string& app_id, const std::string& pc_id) { std::string host = g_host_buf; int port = g_port; std::thread([host, port, app_id, pc_id]() { const long long started = now_ms(); HttpClient cli(host, port); std::string path = "/api/action/" + app_id + "/" + pc_id + "/restart"; HttpResponse resp = cli.post(path, ""); const long long elapsed = now_ms() - started; std::string msg; if (resp.status == 0) { msg = "err: connection failed"; } else if (resp.ok()) { msg = "ok: restart " + app_id + " on " + pc_id; } else { msg = "err: HTTP " + std::to_string(resp.status) + " — " + resp.body.substr(0, 200); } fn_log::log_info("restart app=%s pc=%s status=%d elapsed_ms=%lld msg=%s", app_id.c_str(), pc_id.c_str(), resp.status, elapsed, msg.c_str()); { std::lock_guard lk(g_action_mu); g_action_status = msg; g_action_status_until_ms = now_ms() + 6000; } // Refresh after restart to pick up new state. fetch_async("GET"); }).detach(); } // Mutate the table state filters so that the `overall` column (col index 3) // is filtered to a given value. Empty value clears the filter. static void apply_overall_filter(data_table::State& st, const std::string& value) { if (st.stages.empty()) { data_table::Stage s0; st.stages.push_back(s0); st.active_stage = 0; } auto& filters = st.stages[0].filters; // Drop any existing filter on the overall column. filters.erase( std::remove_if(filters.begin(), filters.end(), [](const data_table::Filter& f) { return f.col == 3; }), filters.end()); if (!value.empty()) { data_table::Filter f; f.col = 3; f.op = data_table::Op::Eq; f.value = value; filters.push_back(f); } } // Read the currently-active overall filter (if any), for chip "pressed" state. static std::string current_overall_filter(const data_table::State& st) { if (st.stages.empty()) return ""; for (const auto& f : st.stages[0].filters) { if (f.col == 3 && f.op == data_table::Op::Eq) return f.value; } return ""; } // Run an HTTP request in background and update g_snap. On error, preserve the // previous rows (so the user does not see the table go blank when one poll // fails) and only update fetch_error + fetched_at_ms. static void fetch_async(const std::string& method) { if (g_fetching.exchange(true)) return; std::string host = g_host_buf; int port = g_port; std::thread([host, port, method]() { const long long started = now_ms(); HttpClient cli(host, port); HttpResponse resp; if (method == "POST") { resp = cli.post("/api/check", ""); } else { resp = cli.get("/api/services"); } const long long elapsed = now_ms() - started; Snapshot snap; snap.fetched_at_ms = now_ms(); bool ok = false; if (resp.status == 0) { snap.fetch_error = "connection failed (services_api running?)"; } else if (!resp.ok()) { snap.fetch_error = "HTTP " + std::to_string(resp.status); } else { parse_snapshot(resp.body, snap); ok = snap.fetch_error.empty(); } fn_log::log_info("fetch %s status=%d rows=%d elapsed_ms=%lld err=%s", method.c_str(), resp.status, (int)snap.rows.size(), elapsed, snap.fetch_error.empty() ? "-" : snap.fetch_error.c_str()); { std::lock_guard lk(g_mu); if (ok) { g_snap = std::move(snap); } else { // Preserve previous rows; only refresh error + timestamp so // the user sees the banner without losing the last known state. g_snap.fetched_at_ms = snap.fetched_at_ms; g_snap.fetch_error = snap.fetch_error; } } g_fetching = false; }).detach(); } static void draw_overview() { if (!ImGui::Begin(TI_DASHBOARD " Services", &g_show_overview, ImGuiWindowFlags_NoCollapse)) { ImGui::End(); return; } // Header controls. ImGui::AlignTextToFramePadding(); ImGui::Text("services_api: "); ImGui::SameLine(); ImGui::SetNextItemWidth(160.0f); ImGui::InputText("##host", g_host_buf, sizeof(g_host_buf)); ImGui::SameLine(); ImGui::SetNextItemWidth(80.0f); ImGui::InputInt("##port", &g_port, 0); if (g_port < 1) g_port = 1; if (g_port > 65535) g_port = 65535; ImGui::SameLine(); bool busy = g_fetching.load(); // SIN disabled toggle: fetch_async retorna inmediato si ya hay uno // en curso. Evita parpadeo de bg del boton. if (ImGui::Button(TI_REFRESH " Refresh")) { fetch_async("GET"); } ImGui::SameLine(); if (ImGui::Button(TI_BOLT " Force check")) { fetch_async("POST"); } ImGui::SameLine(); ImGui::Checkbox("Auto", &g_auto_refresh); ImGui::SameLine(); ImGui::SetNextItemWidth(90.0f); ImGui::DragInt("interval s", &g_refresh_seconds, 0.2f, 1, 120); // Indicador de actividad sin layout-shift: dot con alpha fade segun busy. ImGui::SameLine(); { static float s_busy_alpha = 0.f; float target = busy ? 1.f : 0.f; float dt = ImGui::GetIO().DeltaTime; float k = busy ? 6.f : 3.f; s_busy_alpha += (target - s_busy_alpha) * std::min(1.f, dt * k); ImVec2 p = ImGui::GetCursorScreenPos(); float h = ImGui::GetTextLineHeight(); ImVec2 center(p.x + h * 0.5f, p.y + h * 0.5f); ImU32 col = IM_COL32(244, 192, 77, (int)(255 * s_busy_alpha)); ImGui::GetWindowDrawList()->AddCircleFilled(center, h * 0.28f, col, 12); ImGui::Dummy(ImVec2(h + 6.f, h)); } // Status line. Snapshot snap; { std::lock_guard lk(g_mu); snap = g_snap; } ImGui::SameLine(); if (!snap.fetch_error.empty()) { // Preserved snapshot: show error PLUS age of last good data so the // user knows the table is stale, not blank. if (snap.ts > 0) { std::string age = format_age(snap.ts); ImGui::TextColored(ImVec4(0.92f, 0.40f, 0.40f, 1.0f), "%s (last good: %s)", snap.fetch_error.c_str(), age.c_str()); } else { ImGui::TextColored(ImVec4(0.92f, 0.40f, 0.40f, 1.0f), "%s", snap.fetch_error.c_str()); } } else if (snap.ts > 0) { std::string age = format_age(snap.ts); ImGui::TextColored(ImVec4(0.60f, 0.60f, 0.66f, 1.0f), "self=%s | data %s", snap.self_pc.c_str(), age.c_str()); } else { ImGui::TextColored(ImVec4(0.60f, 0.60f, 0.66f, 1.0f), "no data yet (waiting for services_api...)"); } ImGui::Separator(); // Summary counts. int n_ok = 0, n_deg = 0, n_down = 0, n_nr = 0, n_ni = 0, n_other = 0; for (const auto& r : snap.rows) { if (r.overall == "ok") n_ok++; else if (r.overall == "degraded") n_deg++; else if (r.overall == "down") n_down++; else if (r.overall == "no-route") n_nr++; else if (r.overall == "not-installed") n_ni++; else n_other++; } static data_table::State g_table_state; const std::string active_filter = current_overall_filter(g_table_state); auto pill = [&](const char* label, const char* filter_value, int n, ImVec4 col) { const bool active = !active_filter.empty() && active_filter == filter_value; const float alpha_idle = active ? 0.55f : 0.25f; const float alpha_hov = active ? 0.65f : 0.35f; const float alpha_act = active ? 0.80f : 0.45f; ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(col.x, col.y, col.z, alpha_idle)); ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(col.x, col.y, col.z, alpha_hov)); ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(col.x, col.y, col.z, alpha_act)); ImGui::PushStyleColor(ImGuiCol_Text, col); char buf[64]; snprintf(buf, sizeof(buf), "%s %d", label, n); if (ImGui::Button(buf)) { // Toggle: clicking active clears; otherwise sets. apply_overall_filter(g_table_state, active ? "" : filter_value); } ImGui::PopStyleColor(4); }; pill("OK", "ok", n_ok, color_for_overall("ok")); ImGui::SameLine(); pill("DEGRADED", "degraded", n_deg, color_for_overall("degraded")); ImGui::SameLine(); pill("DOWN", "down", n_down, color_for_overall("down")); ImGui::SameLine(); pill("NO-INSTALL", "not-installed", n_ni, color_for_overall("not-installed")); ImGui::SameLine(); pill("NO-ROUTE", "no-route", n_nr, color_for_overall("no-route")); ImGui::SameLine(); pill("OTHER", "unknown", n_other, color_for_overall("unknown")); ImGui::SameLine(); if (ImGui::SmallButton(TI_X " Clear filter")) { apply_overall_filter(g_table_state, ""); } // Brief action status (right of the pills). { std::lock_guard lk(g_action_mu); if (!g_action_status.empty() && now_ms() < g_action_status_until_ms) { ImGui::SameLine(); ImVec4 c = g_action_status.rfind("err:", 0) == 0 ? color_for_overall("down") : color_for_overall("ok"); ImGui::TextColored(c, "%s", g_action_status.c_str()); } } ImGui::Separator(); // Table via data_table::render (issue 0081). Build TableInput from snap. // g_table_state was declared above (chips filter mutates it). static std::vector g_cells_owning; // owning storage static std::vector g_cells_ptrs; // row-major view static const char* HEADERS[] = { "app", "pc", "self", "overall", "systemd", "port", "listening", "http", "latency_ms", "runtime", "last_change", "note", "status", "action", }; static const data_table::ColumnType TYPES[] = { data_table::ColumnType::String, // app data_table::ColumnType::String, // pc data_table::ColumnType::String, // self data_table::ColumnType::String, // overall data_table::ColumnType::String, // systemd data_table::ColumnType::Int, // port data_table::ColumnType::String, // listening data_table::ColumnType::Int, // http data_table::ColumnType::Int, // latency_ms data_table::ColumnType::String, // runtime data_table::ColumnType::String, // last_change data_table::ColumnType::String, // note data_table::ColumnType::String, // status (categorical chip) data_table::ColumnType::String, // action (button) }; constexpr int NCOLS = (int)(sizeof(HEADERS) / sizeof(HEADERS[0])); // Order rows: by app_name then pc_id (deterministic for the user). std::vector ordered; ordered.reserve(snap.rows.size()); for (const auto& r : snap.rows) ordered.push_back(&r); std::sort(ordered.begin(), ordered.end(), [](const ServiceRow* a, const ServiceRow* b) { if (a->app_name != b->app_name) return a->app_name < b->app_name; return a->pc_id < b->pc_id; }); const int nrows = (int)ordered.size(); g_cells_owning.clear(); g_cells_owning.reserve(nrows * NCOLS); for (const auto* r : ordered) { // 0 app g_cells_owning.push_back(r->app_name); // 1 pc (suffix with "✗" when unreachable to make it scannable + still filterable by exact name otherwise) g_cells_owning.push_back(r->reachable ? r->pc_id : (r->pc_id + " (unreachable)")); // 2 self g_cells_owning.push_back(r->is_self ? "yes" : ""); // 3 overall g_cells_owning.push_back(r->overall.empty() ? std::string("unknown") : r->overall); // 4 systemd g_cells_owning.push_back(r->systemd_state.empty() ? std::string("") : r->systemd_state); // 5 port g_cells_owning.push_back(r->port > 0 ? std::to_string(r->port) : std::string("")); // 6 listening (yes/no/—) if (r->port <= 0) g_cells_owning.push_back(""); else g_cells_owning.push_back(r->port_listening ? "yes" : "no"); // 7 http g_cells_owning.push_back(r->http_status > 0 ? std::to_string(r->http_status) : std::string("")); // 8 latency_ms g_cells_owning.push_back(r->http_latency_ms > 0 ? std::to_string(r->http_latency_ms) : std::string("")); // 9 runtime g_cells_owning.push_back(r->runtime); // 10 last_change (age) g_cells_owning.push_back(r->last_change_ts > 0 ? format_age(r->last_change_ts) : std::string("")); // 11 note if (!r->last_error.empty()) g_cells_owning.push_back(r->last_error); else if (!r->health_endpoint.empty()) g_cells_owning.push_back(r->health_endpoint); else g_cells_owning.push_back(""); // 12 status (categorical chip) — same value as overall (col 3), but // rendered with a colored dot to the left for at-a-glance scanning. g_cells_owning.push_back(r->overall.empty() ? std::string("unknown") : r->overall); // 13 action — value used as button label fallback. Button column hides // its label per ColumnSpec.button_label below, so this is just for the // sort/filter pipeline (and to indicate "no unit, nothing to restart"). if (r->systemd_unit.empty()) g_cells_owning.push_back(""); else if (!r->reachable) g_cells_owning.push_back(""); else g_cells_owning.push_back("restart"); } g_cells_ptrs.clear(); g_cells_ptrs.reserve(g_cells_owning.size()); for (const auto& s : g_cells_owning) g_cells_ptrs.push_back(s.c_str()); if (nrows == 0) { ImGui::TextDisabled("No services. Confirm services_api is running on %s:%d.", g_host_buf, g_port); ImGui::End(); return; } data_table::TableInput tbl; tbl.name = "services"; tbl.headers.assign(HEADERS, HEADERS + NCOLS); tbl.types.assign(TYPES, TYPES + NCOLS); tbl.cells = g_cells_ptrs.data(); tbl.rows = nrows; tbl.cols = NCOLS; // ColumnSpecs: col 12 = status (categorical chip dots), col 13 = action button. { std::vector specs(NCOLS); for (int i = 0; i < NCOLS; ++i) specs[i].id = HEADERS[i]; // status — categorical chip: filled circle next to text colored by value. specs[12].renderer = data_table::CellRenderer::CategoricalChip; specs[12].chips = { {"ok", "#5cd99c"}, {"degraded", "#f2bf4d"}, {"down", "#eb6666"}, {"no-route", "#9999a8"}, {"not-installed", "#b380f2"}, {"unknown", "#8b8b95"}, }; specs[12].tooltip = "auto"; specs[12].tooltip_on_hover = true; // action — clickable restart button. // Empty cell value = no button drawn (row has no unit or PC unreachable). specs[13].renderer = data_table::CellRenderer::Button; specs[13].button_action = "restart"; specs[13].button_label = "Restart"; specs[13].button_color_hex = "#10b981"; specs[13].tooltip = "systemctl restart on this PC"; specs[13].tooltip_on_hover = true; tbl.column_specs = std::move(specs); } std::vector events; ImGui::BeginChild("##services_tbl_host", ImVec2(0, 0)); data_table::render("##services_tbl", { tbl }, g_table_state, &events); ImGui::EndChild(); // Dispatch button clicks. row index in events is the index inside the // TableInput we built (same as `ordered[ev.row]`). for (const auto& ev : events) { if (ev.kind != data_table::TableEventKind::ButtonClick) continue; if (ev.action_id != "restart") continue; if (ev.row < 0 || ev.row >= (int)ordered.size()) continue; const ServiceRow* row = ordered[ev.row]; restart_async(row->app_id, row->pc_id); } ImGui::End(); } static void render() { // Auto-refresh. If we have no data yet, retry aggressively every 1s // until the first successful snapshot arrives. After that, fall back to // the user-configured interval (default 5s). if (g_auto_refresh && !g_fetching.load()) { bool have_data; long long age_ms; { std::lock_guard lk(g_mu); have_data = !g_snap.rows.empty(); age_ms = g_snap.fetched_at_ms == 0 ? 1'000'000LL : (now_ms() - g_snap.fetched_at_ms); } const long long interval_ms = have_data ? (long long)g_refresh_seconds * 1000LL : 1000LL; if (age_ms > interval_ms) fetch_async("GET"); } if (g_show_overview) draw_overview(); } int main(int /*argc*/, char** /*argv*/) { static fn_ui::PanelToggle panels[] = { { "Services", nullptr, &g_show_overview }, }; fn::AppConfig cfg; cfg.title = "services_monitor"; cfg.about = { "services_monitor", "0.1.0", "Frontend ImGui de services_api — vista cross-PC de apps con tag service (issue 0106)." }; cfg.log = { "services_monitor.log", 1 }; cfg.panels = panels; cfg.panel_count = sizeof(panels) / sizeof(panels[0]); // Kick off first fetch in background once GL/window is ready. fetch_async("GET"); return fn::run_app(cfg, render); }