37eef4af12
- 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>
580 lines
23 KiB
C++
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);
|
|
}
|