Files
services_monitor/main.cpp
T
egutierrez 37eef4af12 chore: auto-commit (7 archivos)
- CMakeLists.txt
- app.md
- appicon.ico
- http_client.cpp
- http_client.h
- main.cpp
- vendor/

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 00:31:32 +02:00

580 lines
23 KiB
C++

#include <imgui.h>
#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 <algorithm>
#include <atomic>
#include <chrono>
#include <cstdio>
#include <cstring>
#include <mutex>
#include <string>
#include <thread>
#include <vector>
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<ServiceRow> 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<bool> g_fetching{false};
static std::atomic<bool> 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<std::chrono::milliseconds>(
clk::now().time_since_epoch()).count();
}
static long long now_unix() {
return std::chrono::duration_cast<std::chrono::seconds>(
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<std::string>();
if (j.contains("ts") && j["ts"].is_number_integer())
out.ts = j["ts"].get<long long>();
if (!j.contains("services") || !j["services"].is_array()) {
if (j.contains("error") && j["error"].is_string())
out.fetch_error = j["error"].get<std::string>();
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: <app>/<pc>" 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::mutex> 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<std::string> g_cells_owning; // owning storage
static std::vector<const char*> 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<const ServiceRow*> 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<data_table::ColumnSpec> 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<data_table::TableEvent> 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<std::mutex> 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);
}