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>
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
add_imgui_app(services_monitor
|
||||||
|
main.cpp
|
||||||
|
http_client.cpp
|
||||||
|
)
|
||||||
|
target_include_directories(services_monitor PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||||
|
|
||||||
|
# data_table::render provided via fn_module_data_table (issue 0097 modules).
|
||||||
|
if(TARGET fn_module_data_table)
|
||||||
|
target_link_libraries(services_monitor PRIVATE fn_module_data_table)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(WIN32)
|
||||||
|
target_link_libraries(services_monitor PRIVATE ws2_32)
|
||||||
|
set_target_properties(services_monitor PROPERTIES WIN32_EXECUTABLE TRUE)
|
||||||
|
endif()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
---
|
||||||
|
name: services_monitor
|
||||||
|
lang: cpp
|
||||||
|
domain: tools
|
||||||
|
version: 0.1.0
|
||||||
|
description: "Frontend ImGui para services_api: vista cross-PC de apps con tag service. Issue 0106."
|
||||||
|
tags: [imgui, monitoring, dashboard, ssh-status, systemd-status, frontend]
|
||||||
|
uses_functions: []
|
||||||
|
uses_modules: [data_table_cpp]
|
||||||
|
uses_types: []
|
||||||
|
framework: "imgui"
|
||||||
|
entry_point: "main.cpp"
|
||||||
|
dir_path: "apps/services_monitor"
|
||||||
|
repo_url: "https://gitea.organic-machine.com/dataforge/services_monitor"
|
||||||
|
icon:
|
||||||
|
phosphor: "pulse"
|
||||||
|
accent: "#10b981"
|
||||||
|
---
|
||||||
|
|
||||||
|
# services_monitor
|
||||||
|
|
||||||
|
Frontend ImGui para services_api: vista cross-PC de apps con tag service. Issue 0106.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd cpp && cmake --build build --target services_monitor -j
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./cpp/build/services_monitor
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Capability growth log
|
||||||
|
|
||||||
|
Una linea por bump SemVer. Bump-type segun `.claude/commands/version.md`:
|
||||||
|
- `major`: breaking observable (CLI args, schema BBDD propia, formato wire).
|
||||||
|
- `minor`: feature aditiva (nuevo panel, endpoint, opcion).
|
||||||
|
- `patch`: bugfix sin cambio observable.
|
||||||
|
|
||||||
|
- v0.1.0 (2026-05-18) — baseline.
|
||||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
+173
@@ -0,0 +1,173 @@
|
|||||||
|
#include "http_client.h"
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <sstream>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
#include <winsock2.h>
|
||||||
|
#include <ws2tcpip.h>
|
||||||
|
#include <mutex>
|
||||||
|
#pragma comment(lib, "ws2_32.lib")
|
||||||
|
|
||||||
|
// std::call_once para evitar race condition si hay peticiones simultaneas
|
||||||
|
// desde multiples threads (main + runners).
|
||||||
|
static std::once_flag g_wsa_once;
|
||||||
|
static bool g_wsa_ok = false;
|
||||||
|
static bool wsa_init() {
|
||||||
|
std::call_once(g_wsa_once, []() {
|
||||||
|
WSADATA wsa;
|
||||||
|
g_wsa_ok = (WSAStartup(MAKEWORD(2, 2), &wsa) == 0);
|
||||||
|
});
|
||||||
|
return g_wsa_ok;
|
||||||
|
}
|
||||||
|
typedef SOCKET sock_t;
|
||||||
|
#define SOCK_INVALID INVALID_SOCKET
|
||||||
|
#define SOCK_CLOSE closesocket
|
||||||
|
#define SOCK_ERR WSAGetLastError()
|
||||||
|
#else
|
||||||
|
#include <sys/socket.h>
|
||||||
|
#include <netinet/in.h>
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <netdb.h>
|
||||||
|
#include <errno.h>
|
||||||
|
typedef int sock_t;
|
||||||
|
#define SOCK_INVALID (-1)
|
||||||
|
#define SOCK_CLOSE close
|
||||||
|
#define SOCK_ERR errno
|
||||||
|
#endif
|
||||||
|
|
||||||
|
HttpClient::HttpClient(const std::string& host, int port)
|
||||||
|
: host_(host), port_(port) {}
|
||||||
|
|
||||||
|
HttpResponse HttpClient::get(const std::string& path) {
|
||||||
|
return request("GET", path, "", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse HttpClient::post(const std::string& path, const std::string& body,
|
||||||
|
const std::string& content_type) {
|
||||||
|
return request("POST", path, body, content_type);
|
||||||
|
}
|
||||||
|
|
||||||
|
HttpResponse HttpClient::request(const std::string& method, const std::string& path,
|
||||||
|
const std::string& body, const std::string& content_type) {
|
||||||
|
HttpResponse resp;
|
||||||
|
|
||||||
|
#ifdef _WIN32
|
||||||
|
if (!wsa_init()) {
|
||||||
|
fprintf(stderr, "[http] WSAStartup failed\n");
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
sock_t sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
|
||||||
|
if (sock == SOCK_INVALID) {
|
||||||
|
fprintf(stderr, "[http] socket() failed: %d\n", SOCK_ERR);
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Timeout — Windows y POSIX usan formatos distintos para SO_{RCV,SND}TIMEO.
|
||||||
|
// Windows: DWORD milisegundos. POSIX: struct timeval.
|
||||||
|
#ifdef _WIN32
|
||||||
|
DWORD timeout_ms = static_cast<DWORD>(timeout_sec_ * 1000);
|
||||||
|
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms));
|
||||||
|
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&timeout_ms, sizeof(timeout_ms));
|
||||||
|
#else
|
||||||
|
struct timeval tv;
|
||||||
|
tv.tv_sec = timeout_sec_;
|
||||||
|
tv.tv_usec = 0;
|
||||||
|
setsockopt(sock, SOL_SOCKET, SO_RCVTIMEO, (const char*)&tv, sizeof(tv));
|
||||||
|
setsockopt(sock, SOL_SOCKET, SO_SNDTIMEO, (const char*)&tv, sizeof(tv));
|
||||||
|
#endif
|
||||||
|
|
||||||
|
// Connect
|
||||||
|
struct sockaddr_in addr;
|
||||||
|
memset(&addr, 0, sizeof(addr));
|
||||||
|
addr.sin_family = AF_INET;
|
||||||
|
addr.sin_port = htons(static_cast<uint16_t>(port_));
|
||||||
|
addr.sin_addr.s_addr = inet_addr(host_.c_str());
|
||||||
|
|
||||||
|
if (connect(sock, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
|
||||||
|
int err = SOCK_ERR;
|
||||||
|
SOCK_CLOSE(sock);
|
||||||
|
// Reportamos el errno/WSAError en el body para que el toast sea util.
|
||||||
|
char buf[128];
|
||||||
|
std::snprintf(buf, sizeof(buf),
|
||||||
|
"connect() failed to %s:%d (err=%d)",
|
||||||
|
host_.c_str(), port_, err);
|
||||||
|
resp.body = buf;
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request
|
||||||
|
std::ostringstream req;
|
||||||
|
req << method << " " << path << " HTTP/1.1\r\n";
|
||||||
|
req << "Host: " << host_ << ":" << port_ << "\r\n";
|
||||||
|
req << "Connection: close\r\n";
|
||||||
|
if (!body.empty()) {
|
||||||
|
req << "Content-Type: " << content_type << "\r\n";
|
||||||
|
req << "Content-Length: " << body.size() << "\r\n";
|
||||||
|
}
|
||||||
|
req << "\r\n";
|
||||||
|
if (!body.empty()) req << body;
|
||||||
|
|
||||||
|
std::string raw_req = req.str();
|
||||||
|
send(sock, raw_req.c_str(), static_cast<int>(raw_req.size()), 0);
|
||||||
|
|
||||||
|
// Read response
|
||||||
|
std::vector<char> buf(8192);
|
||||||
|
std::string raw;
|
||||||
|
for (;;) {
|
||||||
|
int n = recv(sock, buf.data(), static_cast<int>(buf.size()), 0);
|
||||||
|
if (n <= 0) break;
|
||||||
|
raw.append(buf.data(), n);
|
||||||
|
}
|
||||||
|
SOCK_CLOSE(sock);
|
||||||
|
|
||||||
|
// Parse status line
|
||||||
|
auto hdr_end = raw.find("\r\n\r\n");
|
||||||
|
if (hdr_end == std::string::npos) return resp;
|
||||||
|
|
||||||
|
// "HTTP/1.1 200 OK\r\n..."
|
||||||
|
auto first_line_end = raw.find("\r\n");
|
||||||
|
if (first_line_end == std::string::npos) return resp;
|
||||||
|
std::string status_line = raw.substr(0, first_line_end);
|
||||||
|
auto sp1 = status_line.find(' ');
|
||||||
|
if (sp1 != std::string::npos) {
|
||||||
|
resp.status = std::atoi(status_line.c_str() + sp1 + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
resp.body = raw.substr(hdr_end + 4);
|
||||||
|
|
||||||
|
// Handle chunked transfer encoding
|
||||||
|
std::string headers_str = raw.substr(0, hdr_end);
|
||||||
|
if (headers_str.find("chunked") != std::string::npos) {
|
||||||
|
// Decode chunked body
|
||||||
|
std::string decoded;
|
||||||
|
const char* p = resp.body.c_str();
|
||||||
|
const char* end = p + resp.body.size();
|
||||||
|
while (p < end) {
|
||||||
|
// Read chunk size (hex)
|
||||||
|
char* chunk_end = nullptr;
|
||||||
|
long chunk_size = strtol(p, &chunk_end, 16);
|
||||||
|
if (chunk_size <= 0) break;
|
||||||
|
// Skip \r\n after size
|
||||||
|
p = chunk_end;
|
||||||
|
if (p < end && *p == '\r') p++;
|
||||||
|
if (p < end && *p == '\n') p++;
|
||||||
|
// Read chunk data
|
||||||
|
if (p + chunk_size <= end) {
|
||||||
|
decoded.append(p, chunk_size);
|
||||||
|
}
|
||||||
|
p += chunk_size;
|
||||||
|
// Skip trailing \r\n
|
||||||
|
if (p < end && *p == '\r') p++;
|
||||||
|
if (p < end && *p == '\n') p++;
|
||||||
|
}
|
||||||
|
resp.body = decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
#pragma once
|
||||||
|
// Minimal HTTP client — no threading, no SSL, just plain TCP to localhost.
|
||||||
|
// Works with both win32 and posix MinGW thread models.
|
||||||
|
|
||||||
|
#include <string>
|
||||||
|
|
||||||
|
struct HttpResponse {
|
||||||
|
int status = 0;
|
||||||
|
std::string body;
|
||||||
|
bool ok() const { return status >= 200 && status < 300; }
|
||||||
|
};
|
||||||
|
|
||||||
|
// Simple blocking HTTP GET/POST over TCP sockets.
|
||||||
|
// host: "127.0.0.1", port: 8484
|
||||||
|
class HttpClient {
|
||||||
|
public:
|
||||||
|
HttpClient(const std::string& host, int port);
|
||||||
|
|
||||||
|
HttpResponse get(const std::string& path);
|
||||||
|
HttpResponse post(const std::string& path, const std::string& body,
|
||||||
|
const std::string& content_type = "application/json");
|
||||||
|
|
||||||
|
private:
|
||||||
|
std::string host_;
|
||||||
|
int port_;
|
||||||
|
int timeout_sec_ = 5;
|
||||||
|
|
||||||
|
HttpResponse request(const std::string& method, const std::string& path,
|
||||||
|
const std::string& body, const std::string& content_type);
|
||||||
|
};
|
||||||
@@ -0,0 +1,579 @@
|
|||||||
|
#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);
|
||||||
|
}
|
||||||
Vendored
+24765
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user