commit 477bcd00f0d2b8371a913fc95319d4dd4682ec06 Author: Egutierrez Date: Tue May 19 00:31:32 2026 +0200 chore: auto-commit (13 archivos) - CMakeLists.txt - agent_protocol.cpp - agent_protocol.h - app.md - appicon.ico - hosts_db.cpp - hosts_db.h - http_client.cpp - http_client.h - main.cpp - ... Co-Authored-By: Claude Opus 4.7 (1M context) diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..c86b495 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,30 @@ +add_imgui_app(process_explorer + main.cpp + http_client.cpp + hosts_db.cpp + samples_db.cpp + agent_protocol.cpp + # Funciones del registry usadas: + ${CMAKE_SOURCE_DIR}/functions/viz/kpi_card.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/sparkline.cpp + ${CMAKE_SOURCE_DIR}/functions/viz/line_plot.cpp +) +target_include_directories(process_explorer PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + +# SQLite for hosts.db + samples.db. fn_framework already links sqlite3 transitively, +# but declaring it here is explicit. +find_package(SQLite3 QUIET) +if(SQLite3_FOUND) + target_link_libraries(process_explorer PRIVATE SQLite::SQLite3) +endif() + +# fn_table_viz: provides data_table::render(), viz_render, TQL engine, Lua, LLM. +# Guard keeps the app compilable in builds where vendor/lua is absent. +if(TARGET fn_table_viz) + target_link_libraries(process_explorer PRIVATE fn_table_viz) +endif() + +if(WIN32) + target_link_libraries(process_explorer PRIVATE ws2_32 psapi) + set_target_properties(process_explorer PROPERTIES WIN32_EXECUTABLE TRUE) +endif() diff --git a/agent_protocol.cpp b/agent_protocol.cpp new file mode 100644 index 0000000..0b2756a --- /dev/null +++ b/agent_protocol.cpp @@ -0,0 +1,600 @@ +#include "agent_protocol.h" +#include "http_client.h" + +#include +#include +#include +#include +#include +#include +#include + +#ifdef __linux__ +#include +#include +#include +#endif + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#include +#endif + +namespace pex { + +// ========================================================================= +// HTTP-agent placeholders (issue 0111) +// ========================================================================= + +std::vector fetch_processes(const std::string& base_url, const std::string& token) { + if (base_url.empty()) return {}; + auto resp = pex_http::get(base_url + "/api/processes", token); + if (resp.status != 200) return {}; + return {}; +} + +StatsSnapshot fetch_stats(const std::string& base_url, const std::string& token) { + StatsSnapshot s; + s.unix_ts = (int64_t)std::time(nullptr); + if (base_url.empty()) return s; + auto resp = pex_http::get(base_url + "/api/stats", token); + if (resp.status != 200) return s; + return s; +} + +std::vector fetch_devices(const std::string& base_url, const std::string& token) { + if (base_url.empty()) return {}; + auto resp = pex_http::get(base_url + "/api/devices", token); + if (resp.status != 200) return {}; + return {}; +} + +std::vector fetch_services(const std::string& base_url, const std::string& token) { + if (base_url.empty()) return {}; + auto resp = pex_http::get(base_url + "/api/services", token); + if (resp.status != 200) return {}; + return {}; +} + +std::vector fetch_netconns(const std::string& base_url, const std::string& token) { + if (base_url.empty()) return {}; + auto resp = pex_http::get(base_url + "/api/netconns", token); + if (resp.status != 200) return {}; + return {}; +} + +// ========================================================================= +// LINUX local +// ========================================================================= + +#ifdef __linux__ + +static std::string read_file(const char* path) { + std::ifstream f(path); + if (!f) return {}; + std::stringstream ss; ss << f.rdbuf(); + return ss.str(); +} + +// CPU%: diff de /proc/stat entre dos llamadas. +struct CpuJiffies { unsigned long long user, nice, system, idle, iowait, irq, softirq, steal; }; +static bool parse_proc_stat(CpuJiffies& out) { + std::ifstream f("/proc/stat"); + if (!f) return false; + std::string cpu; + f >> cpu; + if (cpu != "cpu") return false; + f >> out.user >> out.nice >> out.system >> out.idle + >> out.iowait >> out.irq >> out.softirq >> out.steal; + return true; +} + +static float diff_cpu_pct(CpuJiffies& prev, bool& have_prev) { + CpuJiffies cur{}; + if (!parse_proc_stat(cur)) return 0.0f; + if (!have_prev) { prev = cur; have_prev = true; return 0.0f; } + auto total = [](const CpuJiffies& j) { + return j.user + j.nice + j.system + j.idle + j.iowait + j.irq + j.softirq + j.steal; + }; + unsigned long long t0 = total(prev), t1 = total(cur); + unsigned long long i0 = prev.idle + prev.iowait, i1 = cur.idle + cur.iowait; + unsigned long long dt = t1 - t0, didle = i1 - i0; + prev = cur; + if (dt == 0) return 0.0f; + return (float)((double)(dt - didle) / (double)dt * 100.0); +} + +std::vector fetch_processes_local() { + std::vector out; + DIR* d = opendir("/proc"); + if (!d) return out; + long page_kb = sysconf(_SC_PAGESIZE) / 1024; + struct dirent* e; + while ((e = readdir(d)) != nullptr) { + if (e->d_type != DT_DIR) continue; + bool numeric = true; + for (const char* p = e->d_name; *p; ++p) if (*p < '0' || *p > '9') { numeric = false; break; } + if (!numeric) continue; + + ProcInfo info; + info.pid = std::atoi(e->d_name); + + std::string stat = read_file((std::string("/proc/") + e->d_name + "/stat").c_str()); + if (!stat.empty()) { + auto open_paren = stat.find('('); + auto close_paren = stat.rfind(')'); + if (open_paren != std::string::npos && close_paren != std::string::npos) { + info.name = stat.substr(open_paren + 1, close_paren - open_paren - 1); + std::istringstream iss(stat.substr(close_paren + 2)); + char state; + int ppid; + iss >> state >> ppid; + info.state.assign(1, state); + info.ppid = ppid; + } + } + + std::string statm = read_file((std::string("/proc/") + e->d_name + "/statm").c_str()); + if (!statm.empty()) { + long size_pages, resident_pages; + if (std::sscanf(statm.c_str(), "%ld %ld", &size_pages, &resident_pages) == 2) { + info.ram_mb = (float)(resident_pages * page_kb) / 1024.0f; + } + } + + std::string cmdline = read_file((std::string("/proc/") + e->d_name + "/cmdline").c_str()); + for (auto& c : cmdline) if (c == '\0') c = ' '; + info.cmdline = std::move(cmdline); + + out.push_back(std::move(info)); + } + closedir(d); + return out; +} + +StatsSnapshot fetch_stats_local() { + StatsSnapshot s; + s.unix_ts = (int64_t)std::time(nullptr); + struct sysinfo si{}; + if (sysinfo(&si) == 0) { + s.ram_total_mb = (float)(si.totalram * si.mem_unit) / (1024.0f * 1024.0f); + s.ram_used_mb = (float)((si.totalram - si.freeram) * si.mem_unit) / (1024.0f * 1024.0f); + } + static CpuJiffies prev{}; static bool have_prev = false; + s.cpu_pct = diff_cpu_pct(prev, have_prev); + return s; +} + +// En Linux el "wsl" no aplica: alias del local. +std::vector fetch_processes_wsl() { return fetch_processes_local(); } +StatsSnapshot fetch_stats_wsl() { return fetch_stats_local(); } + +#endif // __linux__ + +// ========================================================================= +// WINDOWS local (psapi + GetSystemTimes) +// ========================================================================= + +#ifdef _WIN32 + +static uint64_t ft_to_u64(const FILETIME& ft) { + ULARGE_INTEGER u{}; u.LowPart = ft.dwLowDateTime; u.HighPart = ft.dwHighDateTime; + return u.QuadPart; +} + +StatsSnapshot fetch_stats_local() { + StatsSnapshot s; + s.unix_ts = (int64_t)std::time(nullptr); + + MEMORYSTATUSEX ms{}; ms.dwLength = sizeof(ms); + if (GlobalMemoryStatusEx(&ms)) { + s.ram_total_mb = (float)((double)ms.ullTotalPhys / (1024.0 * 1024.0)); + s.ram_used_mb = (float)((double)(ms.ullTotalPhys - ms.ullAvailPhys) / (1024.0 * 1024.0)); + } + + static FILETIME prev_idle{}, prev_kernel{}, prev_user{}; + static bool have_prev = false; + FILETIME idle, kernel, user; + if (GetSystemTimes(&idle, &kernel, &user)) { + if (have_prev) { + uint64_t didle = ft_to_u64(idle) - ft_to_u64(prev_idle); + uint64_t dkern = ft_to_u64(kernel) - ft_to_u64(prev_kernel); + uint64_t duser = ft_to_u64(user) - ft_to_u64(prev_user); + uint64_t total = dkern + duser; // kernel-time on Windows includes idle + if (total > 0) { + s.cpu_pct = (float)((double)(total - didle) / (double)total * 100.0); + } + } + prev_idle = idle; prev_kernel = kernel; prev_user = user; + have_prev = true; + } + return s; +} + +std::vector fetch_processes_local() { + std::vector out; + DWORD pids[4096], cb_needed = 0; + if (!EnumProcesses(pids, sizeof(pids), &cb_needed)) return out; + int n = (int)(cb_needed / sizeof(DWORD)); + for (int i = 0; i < n; ++i) { + if (pids[i] == 0) continue; + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_VM_READ, + FALSE, pids[i]); + if (!h) continue; + + ProcInfo p; + p.pid = (int32_t)pids[i]; + + char path[MAX_PATH] = {}; + if (GetModuleFileNameExA(h, NULL, path, sizeof(path))) { + const char* base = std::strrchr(path, '\\'); + p.name = base ? (base + 1) : path; + p.cmdline = path; + } else { + p.name = "?"; + } + + PROCESS_MEMORY_COUNTERS pmc{}; + if (GetProcessMemoryInfo(h, &pmc, sizeof(pmc))) { + p.ram_mb = (float)((double)pmc.WorkingSetSize / (1024.0 * 1024.0)); + } + p.state = "R"; + + out.push_back(std::move(p)); + CloseHandle(h); + } + return out; +} + +// ------------------------------------------------------------------------- +// WSL trampoline (binario Windows ejecuta `wsl.exe -e ...`) +// ------------------------------------------------------------------------- + +// Ejecuta un comando y captura stdout SIN abrir ventana de consola. +// _popen() spawns cmd.exe con WshShell -> flash de consola visible en cada +// llamada. CreateProcessA + CREATE_NO_WINDOW + STARTF_USESHOWWINDOW(SW_HIDE) +// la oculta. Pipe anonimo para leer stdout sincronamente. +static std::string run_capture(const char* cmd) { + std::string out; + + SECURITY_ATTRIBUTES sa{}; + sa.nLength = sizeof(sa); + sa.bInheritHandle = TRUE; + + HANDLE rd = NULL, wr = NULL; + if (!CreatePipe(&rd, &wr, &sa, 0)) return out; + SetHandleInformation(rd, HANDLE_FLAG_INHERIT, 0); + + STARTUPINFOA si{}; + si.cb = sizeof(si); + si.dwFlags = STARTF_USESHOWWINDOW | STARTF_USESTDHANDLES; + si.wShowWindow = SW_HIDE; + si.hStdOutput = wr; + si.hStdError = wr; + si.hStdInput = GetStdHandle(STD_INPUT_HANDLE); + + PROCESS_INFORMATION pi{}; + // CreateProcessA muta lpCommandLine — copiar a buffer escribible. + std::string mutable_cmd = cmd; + if (!CreateProcessA(NULL, mutable_cmd.data(), NULL, NULL, TRUE, + CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) { + CloseHandle(rd); CloseHandle(wr); + return out; + } + CloseHandle(wr); // padre cierra su extremo write para que ReadFile vea EOF + + char buf[4096]; + DWORD n = 0; + while (ReadFile(rd, buf, sizeof(buf), &n, NULL) && n > 0) { + out.append(buf, n); + } + CloseHandle(rd); + WaitForSingleObject(pi.hProcess, INFINITE); + CloseHandle(pi.hProcess); + CloseHandle(pi.hThread); + return out; +} + +struct WslCpuJiffies { unsigned long long user, nice, system, idle, iowait, irq, softirq, steal; }; + +static bool parse_wsl_proc_stat(WslCpuJiffies& out) { + std::string txt = run_capture("wsl.exe -e sh -c \"head -n1 /proc/stat\""); + if (txt.empty()) return false; + std::istringstream iss(txt); + std::string cpu; iss >> cpu; + if (cpu != "cpu") return false; + iss >> out.user >> out.nice >> out.system >> out.idle + >> out.iowait >> out.irq >> out.softirq >> out.steal; + return true; +} + +StatsSnapshot fetch_stats_wsl() { + StatsSnapshot s; + s.unix_ts = (int64_t)std::time(nullptr); + + // RAM via /proc/meminfo + std::string mem = run_capture("wsl.exe -e sh -c \"cat /proc/meminfo\""); + if (mem.empty()) return s; + long mem_total_kb = 0, mem_available_kb = 0; + std::istringstream ms(mem); + std::string line; + while (std::getline(ms, line)) { + long v; + if (std::sscanf(line.c_str(), "MemTotal: %ld kB", &v) == 1) mem_total_kb = v; + else if (std::sscanf(line.c_str(), "MemAvailable: %ld kB", &v) == 1) mem_available_kb = v; + } + if (mem_total_kb > 0) { + s.ram_total_mb = (float)mem_total_kb / 1024.0f; + s.ram_used_mb = (float)(mem_total_kb - mem_available_kb) / 1024.0f; + } + + // CPU% diff + static WslCpuJiffies prev{}; static bool have_prev = false; + WslCpuJiffies cur{}; + if (parse_wsl_proc_stat(cur)) { + if (have_prev) { + auto total = [](const WslCpuJiffies& j) { + return j.user + j.nice + j.system + j.idle + j.iowait + j.irq + j.softirq + j.steal; + }; + unsigned long long t0 = total(prev), t1 = total(cur); + unsigned long long i0 = prev.idle + prev.iowait, i1 = cur.idle + cur.iowait; + unsigned long long dt = t1 - t0, didle = i1 - i0; + if (dt > 0) s.cpu_pct = (float)((double)(dt - didle) / (double)dt * 100.0); + } + prev = cur; + have_prev = true; + } + return s; +} + +std::vector fetch_processes_wsl() { + std::vector out; + // ps -eo pid,stat,rss,comm — sin header. + std::string txt = run_capture("wsl.exe -e ps -eo pid,stat,rss,comm --no-headers"); + if (txt.empty()) return out; + std::istringstream iss(txt); + std::string line; + while (std::getline(iss, line)) { + int pid = 0, rss_kb = 0; + char stat[8] = {}, comm[256] = {}; + if (std::sscanf(line.c_str(), "%d %7s %d %255s", &pid, stat, &rss_kb, comm) == 4) { + ProcInfo p; + p.pid = pid; + p.state = stat; + p.ram_mb = (float)rss_kb / 1024.0f; + p.name = comm; + out.push_back(std::move(p)); + } + } + return out; +} + +#endif // _WIN32 + +// ========================================================================= +// Parsers compartidos para Network/Devices/Services (Linux + WSL output) +// ========================================================================= + +static std::vector parse_ss_output(const std::string& text) { + std::vector out; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (line.empty()) continue; + std::istringstream ls(line); + NetConn c; + std::string send_q, recv_q; + ls >> c.proto >> c.state >> send_q >> recv_q >> c.local >> c.remote; + std::string rest; std::getline(ls, rest); + auto pid_pos = rest.find("pid="); + if (pid_pos != std::string::npos) c.pid = std::atoi(rest.c_str() + pid_pos + 4); + if (!c.proto.empty()) out.push_back(c); + } + return out; +} + +static std::vector parse_lsblk_output(const std::string& text) { + std::vector out; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (line.empty()) continue; + std::istringstream ls(line); + std::string name, size_bytes, mount, fstype; + ls >> name >> size_bytes >> mount >> fstype; + if (name.empty()) continue; + DeviceInfo d; + d.kind = "disk"; + d.name = name; + double sz = (double)std::atoll(size_bytes.c_str()) / (1024.0*1024.0*1024.0); + char buf[128]; + std::snprintf(buf, sizeof(buf), "%.1f GB %s %s", + sz, + fstype.empty() ? "" : fstype.c_str(), + mount.empty() ? "" : mount.c_str()); + d.detail = buf; + d.usage_pct = -1.0f; + out.push_back(d); + } + return out; +} + +static std::vector parse_systemctl_output(const std::string& text) { + std::vector out; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (line.empty()) continue; + std::istringstream ls(line); + std::string unit, load, active, sub; + ls >> unit >> load >> active >> sub; + if (unit.empty()) continue; + ServiceInfo s; + s.name = unit; + s.state = active + "/" + sub; + s.runtime = "systemd"; + std::string desc; std::getline(ls, desc); + auto p0 = desc.find_first_not_of(' '); + if (p0 != std::string::npos) s.description = desc.substr(p0); + out.push_back(s); + } + return out; +} + +// ========================================================================= +// LINUX impls (Network/Devices/Services local) +// ========================================================================= + +#ifdef __linux__ + +static std::string popen_capture_linux(const char* cmd) { + std::string out; + FILE* fp = popen(cmd, "r"); + if (!fp) return out; + char buf[4096]; + while (fgets(buf, sizeof(buf), fp)) out += buf; + pclose(fp); + return out; +} + +std::vector fetch_netconns_local() { + return parse_ss_output(popen_capture_linux("ss -tunaH -p 2>/dev/null")); +} +std::vector fetch_devices_local() { + return parse_lsblk_output(popen_capture_linux( + "lsblk -bno NAME,SIZE,MOUNTPOINT,FSTYPE 2>/dev/null")); +} +std::vector fetch_services_local() { + return parse_systemctl_output(popen_capture_linux( + "systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null")); +} + +// En Linux la "version WSL" = local (no aplica). +std::vector fetch_netconns_wsl() { return fetch_netconns_local(); } +std::vector fetch_devices_wsl() { return fetch_devices_local(); } +std::vector fetch_services_wsl() { return fetch_services_local(); } + +#endif // __linux__ + +// ========================================================================= +// WINDOWS impls (Network/Devices/Services local + WSL trampoline) +// ========================================================================= + +#ifdef _WIN32 + +static std::vector parse_netstat_windows(const std::string& text) { + std::vector out; + std::istringstream iss(text); + std::string line; + while (std::getline(iss, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t p0 = line.find_first_not_of(' '); + if (p0 == std::string::npos) continue; + std::istringstream ls(line.substr(p0)); + NetConn c; + ls >> c.proto; + if (c.proto != "TCP" && c.proto != "UDP") continue; + ls >> c.local >> c.remote; + std::string pid_str; + if (c.proto == "TCP") { ls >> c.state >> pid_str; } + else { ls >> pid_str; c.state = ""; } + c.pid = std::atoi(pid_str.c_str()); + out.push_back(c); + } + return out; +} + +std::vector fetch_netconns_local() { + return parse_netstat_windows(run_capture("netstat -ano")); +} + +std::vector fetch_devices_local() { + std::vector out; + char drives[256] = {}; + DWORD n = GetLogicalDriveStringsA(sizeof(drives), drives); + for (char* p = drives; *p && (p - drives) < (int)n; ) { + DeviceInfo d; + d.kind = "disk"; + d.name = p; + ULARGE_INTEGER avail{}, total{}, freeb{}; + if (GetDiskFreeSpaceExA(p, &avail, &total, &freeb)) { + char detail[128]; + double total_gb = (double)total.QuadPart / (1024.0*1024.0*1024.0); + double free_gb = (double)freeb.QuadPart / (1024.0*1024.0*1024.0); + std::snprintf(detail, sizeof(detail), + "%.1f GB free / %.1f GB total", free_gb, total_gb); + d.detail = detail; + d.usage_pct = total.QuadPart + ? (float)(100.0 * (double)(total.QuadPart - freeb.QuadPart) / (double)total.QuadPart) + : -1.0f; + } else { + d.detail = "(unavailable)"; + d.usage_pct = -1.0f; + } + out.push_back(d); + p += std::strlen(p) + 1; + } + return out; +} + +static std::vector parse_sc_query_output(const std::string& text) { + std::vector out; + std::istringstream iss(text); + std::string line; + ServiceInfo cur; + bool active = false; + auto trim = [](std::string s) { + size_t a = s.find_first_not_of(' '); + if (a == std::string::npos) return std::string(); + size_t b = s.find_last_not_of(" \r\n\t"); + return s.substr(a, b - a + 1); + }; + while (std::getline(iss, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + size_t p0 = line.find_first_not_of(' '); + if (p0 == std::string::npos) continue; + std::string l = line.substr(p0); + if (l.rfind("SERVICE_NAME:", 0) == 0) { + if (active) out.push_back(cur); + cur = {}; + cur.runtime = "sc"; + cur.name = trim(l.substr(13)); + active = true; + } else if (l.rfind("DISPLAY_NAME:", 0) == 0) { + cur.description = trim(l.substr(13)); + } else if (l.rfind("STATE", 0) == 0) { + auto colon = l.find(':'); + if (colon != std::string::npos) { + std::istringstream ss2(l.substr(colon + 1)); + int code; std::string name; + ss2 >> code >> name; + cur.state = name; + } + } + } + if (active) out.push_back(cur); + return out; +} + +std::vector fetch_services_local() { + return parse_sc_query_output(run_capture("sc query state= all type= service")); +} + +// WSL trampolines (binario Windows ejecuta wsl.exe). run_capture es hidden. +std::vector fetch_netconns_wsl() { + return parse_ss_output(run_capture( + "wsl.exe -e sh -c \"ss -tunaH -p 2>/dev/null\"")); +} +std::vector fetch_devices_wsl() { + return parse_lsblk_output(run_capture( + "wsl.exe -e sh -c \"lsblk -bno NAME,SIZE,MOUNTPOINT,FSTYPE 2>/dev/null\"")); +} +std::vector fetch_services_wsl() { + return parse_systemctl_output(run_capture( + "wsl.exe -e sh -c \"systemctl list-units --type=service --all --no-legend --plain --no-pager 2>/dev/null\"")); +} + +#endif // _WIN32 + +} // namespace pex diff --git a/agent_protocol.h b/agent_protocol.h new file mode 100644 index 0000000..6af3e72 --- /dev/null +++ b/agent_protocol.h @@ -0,0 +1,85 @@ +#pragma once +#include +#include +#include + +namespace pex { + +struct ProcInfo { + int32_t pid = 0; + int32_t ppid = 0; + std::string name; + std::string user; + float cpu_pct = 0.0f; + float ram_mb = 0.0f; + float disk_read_kb_s = 0.0f; + float disk_write_kb_s = 0.0f; + int32_t threads = 0; + std::string state; // R/S/D/Z/T (Linux) o running/suspended (Win) + std::string cmdline; +}; + +struct StatsSnapshot { + int64_t unix_ts = 0; + float cpu_pct = 0.0f; + float ram_used_mb = 0.0f; + float ram_total_mb = 0.0f; + float disk_read_mb_s = 0.0f; + float disk_write_mb_s = 0.0f; + float net_rx_mb_s = 0.0f; + float net_tx_mb_s = 0.0f; + float gpu_pct = 0.0f; +}; + +struct DeviceInfo { + std::string kind; // disk | gpu | usb | sensor + std::string name; + std::string detail; + float usage_pct = 0.0f; // -1 si no aplica +}; + +struct ServiceInfo { + std::string name; + std::string state; // running | stopped | failed + std::string description; + std::string runtime; // systemd | sc | docker | other +}; + +struct NetConn { + int32_t pid = 0; + std::string proto; // tcp | udp + std::string local; // ip:port + std::string remote; + std::string state; // ESTABLISHED, LISTEN, ... +}; + +// Stubs: parsean JSON del agente o devuelven vacios si no esta disponible. +// Implementacion real cuando el agente Go exista (issue 0111). +std::vector fetch_processes(const std::string& base_url, const std::string& token); +StatsSnapshot fetch_stats (const std::string& base_url, const std::string& token); +std::vector fetch_devices (const std::string& base_url, const std::string& token); +std::vector fetch_services (const std::string& base_url, const std::string& token); +std::vector fetch_netconns (const std::string& base_url, const std::string& token); + +// Local del host donde corre el binario. +// - Linux: lee /proc. +// - Windows: WinAPI (psapi, GetSystemTimes, GlobalMemoryStatusEx). +std::vector fetch_processes_local(); +StatsSnapshot fetch_stats_local(); + +// WSL desde un binario Windows — trampoline via `wsl.exe -e ...`. +// En Linux son aliases a fetch_*_local. +std::vector fetch_processes_wsl(); +StatsSnapshot fetch_stats_wsl(); + +// Network/Devices/Services para el host LOCAL (binario corriendo aqui). +std::vector fetch_netconns_local(); +std::vector fetch_devices_local(); +std::vector fetch_services_local(); + +// Idem WSL — trampoline desde un binario Windows. En Linux son aliases local. +std::vector fetch_netconns_wsl(); +std::vector fetch_devices_wsl(); +std::vector fetch_services_wsl(); + +} // namespace pex diff --git a/app.md b/app.md new file mode 100644 index 0000000..4cb050f --- /dev/null +++ b/app.md @@ -0,0 +1,95 @@ +--- +name: process_explorer +lang: cpp +domain: tools +version: 0.1.0 +description: "Process explorer cross-PC: CPU/RAM/disk/net/GPU + procesos en tiempo real" +tags: [monitor, dashboard, cross-pc, imgui] +icon: + phosphor: cpu + accent: "#0d9488" +uses_functions: + - line_plot_cpp_viz + - kpi_card_cpp_viz + - data_table_cpp_viz + - viz_render_cpp_viz + - compute_stage_cpp_core + - compute_pipeline_cpp_core + - compute_column_stats_cpp_core + - auto_detect_type_cpp_core + - tql_emit_cpp_core + - tql_apply_cpp_core + - lua_engine_cpp_core + - join_tables_cpp_core +uses_types: [] +framework: "imgui" +entry_point: "main.cpp" +dir_path: "apps/process_explorer" +repo_url: "https://gitea.organic-machine.com/dataforge/process_explorer" +e2e_checks: + - id: build + cmd: "cmake --build cpp/build --target process_explorer -j" + timeout_s: 300 + - id: self_test + cmd: "./cpp/build/apps/process_explorer/process_explorer --self-test" + timeout_s: 30 + severity: warning +--- + +# process_explorer + +Process explorer cross-PC: CPU/RAM/disk/net/GPU + procesos en tiempo real. + +## Arquitectura + +- **Explorer (esta app)**: ImGui C++. UI con 5 panels (Overview, Processes, Network, Devices, Services). +- **Agente HTTP por PC** (issue 0111): daemon Go ligero instalable en cada PC (Win+Linux) que expone: + - `GET /api/processes` — lista de procesos con CPU%/RAM/PID/usuario. + - `GET /api/stats` — snapshot global CPU/RAM/Disk/Net/GPU. + - `GET /api/devices` — discos, GPUs, USB, sensores. + - `GET /api/services` — systemctl (Linux) / sc query (Win). + - Auth: `Authorization: Bearer ` con token por PC. +- **Local Linux**: lectura directa de `/proc/*` cuando el host es local (sin red). +- **Remote**: SIEMPRE via HTTP agent + token. + +## Panels + +1. **Overview** (Ctrl+1) — KPIs globales con `kpi_card`: CPU%, RAM, Disk I/O, Net I/O, GPU. Sparklines historicos via `line_plot`. +2. **Processes** (Ctrl+2) — Tabla TQL (`data_table::render`) con todos los procesos. Filtros, sort, group-by. +3. **Network** (Ctrl+3) — Tabla de sockets/conexiones con PID, local, remote, estado. +4. **Devices** (Ctrl+4) — Enumeracion de discos, GPUs, USB, sensores. +5. **Services** (Ctrl+5) — systemctl (Linux) + `sc query` (Win). Diferente de `services_monitor` (que monitoriza apps DECLARADAS del registry); aqui se ven TODOS. + +## Persistencia (local_files/) + +- `hosts.db` — inventario PCs (url, token, os, last_seen). +- `operations.db` — entities=hosts, executions=samples (bucle reactivo). +- `process_samples.db` — time-series CPU/RAM/IO para graficar ventanas >1h. + +## Build + +```bash +cd cpp && cmake --build build --target process_explorer -j +``` + +## Run + +```bash +./cpp/build/apps/process_explorer/process_explorer +``` + +## Notas de implementacion + +- HTTP cliente: hoy stub local en `http_client.cpp`. Migrar a `fn_http::request` cuando cierre el issue 0110. +- Agente Go: issue 0111 (separado). +- Self-test: `--self-test` valida que `hosts.db` + `samples.db` se crean en `local_files/` y que el GL loader carga. + + +## 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. diff --git a/appicon.ico b/appicon.ico new file mode 100644 index 0000000..bb4b46f Binary files /dev/null and b/appicon.ico differ diff --git a/hosts_db.cpp b/hosts_db.cpp new file mode 100644 index 0000000..9138489 --- /dev/null +++ b/hosts_db.cpp @@ -0,0 +1,102 @@ +#include "hosts_db.h" +#include +#include + +namespace pex { + +static const char* k_schema = R"SQL( +CREATE TABLE IF NOT EXISTS hosts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + url TEXT NOT NULL, + token TEXT NOT NULL DEFAULT '', + os TEXT NOT NULL DEFAULT '', + last_seen_unix INTEGER NOT NULL DEFAULT 0, + is_local INTEGER NOT NULL DEFAULT 0 +); +)SQL"; + +HostsDb::HostsDb() = default; +HostsDb::~HostsDb() { close(); } + +bool HostsDb::open(const std::string& path) { + if (sqlite3_open(path.c_str(), &db_) != SQLITE_OK) { + db_ = nullptr; + return false; + } + char* err = nullptr; + if (sqlite3_exec(db_, k_schema, nullptr, nullptr, &err) != SQLITE_OK) { + sqlite3_free(err); + close(); + return false; + } + return true; +} + +void HostsDb::close() { + if (db_) { sqlite3_close(db_); db_ = nullptr; } +} + +std::vector HostsDb::list() { + std::vector out; + if (!db_) return out; + const char* sql = "SELECT id, name, url, token, os, last_seen_unix, is_local FROM hosts ORDER BY name"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return out; + while (sqlite3_step(st) == SQLITE_ROW) { + Host h; + h.id = sqlite3_column_int64(st, 0); + h.name = reinterpret_cast(sqlite3_column_text(st, 1)); + h.url = reinterpret_cast(sqlite3_column_text(st, 2)); + h.token = reinterpret_cast(sqlite3_column_text(st, 3)); + h.os = reinterpret_cast(sqlite3_column_text(st, 4)); + h.last_seen_unix = sqlite3_column_int64(st, 5); + h.is_local = sqlite3_column_int(st, 6) != 0; + out.push_back(std::move(h)); + } + sqlite3_finalize(st); + return out; +} + +int64_t HostsDb::upsert(const Host& h) { + if (!db_) return -1; + const char* sql = + "INSERT INTO hosts(name,url,token,os,last_seen_unix,is_local) VALUES(?,?,?,?,?,?) " + "ON CONFLICT(name) DO UPDATE SET url=excluded.url, token=excluded.token, " + "os=excluded.os, last_seen_unix=excluded.last_seen_unix, is_local=excluded.is_local"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return -1; + sqlite3_bind_text(st, 1, h.name.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 2, h.url.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 3, h.token.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_text(st, 4, h.os.c_str(), -1, SQLITE_TRANSIENT); + sqlite3_bind_int64(st, 5, h.last_seen_unix); + sqlite3_bind_int(st, 6, h.is_local ? 1 : 0); + int rc = sqlite3_step(st); + sqlite3_finalize(st); + if (rc != SQLITE_DONE) return -1; + return sqlite3_last_insert_rowid(db_); +} + +bool HostsDb::remove(int64_t id) { + if (!db_) return false; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db_, "DELETE FROM hosts WHERE id=?", -1, &st, nullptr) != SQLITE_OK) return false; + sqlite3_bind_int64(st, 1, id); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + return ok; +} + +bool HostsDb::touch_last_seen(int64_t id, int64_t unix_ts) { + if (!db_) return false; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db_, "UPDATE hosts SET last_seen_unix=? WHERE id=?", -1, &st, nullptr) != SQLITE_OK) return false; + sqlite3_bind_int64(st, 1, unix_ts); + sqlite3_bind_int64(st, 2, id); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + return ok; +} + +} // namespace pex diff --git a/hosts_db.h b/hosts_db.h new file mode 100644 index 0000000..82b8a44 --- /dev/null +++ b/hosts_db.h @@ -0,0 +1,38 @@ +#pragma once +#include +#include +#include + +struct sqlite3; + +namespace pex { + +struct Host { + int64_t id = 0; + std::string name; + std::string url; + std::string token; + std::string os; // "linux" | "windows" | "wsl" + int64_t last_seen_unix = 0; + bool is_local = false; +}; + +class HostsDb { +public: + HostsDb(); + ~HostsDb(); + + // path debe ser absoluto (fn::local_path("hosts.db")). + bool open(const std::string& path); + void close(); + + std::vector list(); + int64_t upsert(const Host& h); // devuelve id; -1 si falla + bool remove(int64_t id); + bool touch_last_seen(int64_t id, int64_t unix_ts); + +private: + sqlite3* db_ = nullptr; +}; + +} // namespace pex diff --git a/http_client.cpp b/http_client.cpp new file mode 100644 index 0000000..2703306 --- /dev/null +++ b/http_client.cpp @@ -0,0 +1,44 @@ +#include "http_client.h" +#include +#include + +namespace pex_http { + +// Stub: usa curl via popen igual que llm_anthropic. Reemplazar por +// fn_http::request cuando el helper del registry exista (issue 0110). +Response request(const Request& req) { + Response resp; + if (req.url.empty()) { + resp.error = "empty url"; + return resp; + } + + std::string cmd = "curl -sS --max-time " + std::to_string(req.timeout_ms / 1000) + + " -X " + (req.method.empty() ? "GET" : req.method); + if (!req.bearer_token.empty()) { + cmd += " -H \"Authorization: Bearer " + req.bearer_token + "\""; + } + for (const auto& kv : req.headers) { + cmd += " -H \"" + kv.first + ": " + kv.second + "\""; + } + if (!req.body.empty()) { + cmd += " --data-binary @-"; + } + cmd += " -w \"\\n__STATUS__%{http_code}\" \"" + req.url + "\""; + + FILE* fp = popen(cmd.c_str(), "r"); + if (!fp) { resp.error = "popen failed"; return resp; } + + char buf[4096]; + while (fgets(buf, sizeof(buf), fp)) resp.body += buf; + pclose(fp); + + auto pos = resp.body.rfind("\n__STATUS__"); + if (pos != std::string::npos) { + try { resp.status = std::stoi(resp.body.substr(pos + 11)); } catch (...) {} + resp.body.resize(pos); + } + return resp; +} + +} // namespace pex_http diff --git a/http_client.h b/http_client.h new file mode 100644 index 0000000..735b90b --- /dev/null +++ b/http_client.h @@ -0,0 +1,36 @@ +#pragma once +#include +#include +#include + +namespace pex_http { + +struct Request { + std::string method; + std::string url; + std::vector> headers; + std::string body; + std::string bearer_token; + int timeout_ms = 5000; +}; + +struct Response { + int status = 0; + std::string body; + std::string error; + int64_t duration_ms = 0; +}; + +// Stub local — sera reemplazado por fn_http::request (issue 0110). +Response request(const Request& req); + +inline Response get(const std::string& url, const std::string& bearer = "", int timeout_ms = 5000) { + Request r; + r.method = "GET"; + r.url = url; + r.bearer_token = bearer; + r.timeout_ms = timeout_ms; + return request(r); +} + +} // namespace pex_http diff --git a/main.cpp b/main.cpp new file mode 100644 index 0000000..c9260bd --- /dev/null +++ b/main.cpp @@ -0,0 +1,655 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "app_base.h" +#include "core/panel_menu.h" +#include "core/icons_tabler.h" +#include "core/logger.h" +#include "viz/kpi_card.h" +#include "viz/line_plot.h" + +#include "agent_protocol.h" +#include "hosts_db.h" +#include "samples_db.h" + +// ========================================================================= +// Per-host runtime state +// ========================================================================= + +static constexpr int kHistoryCap = 600; // 5 min @ 1Hz + margen +static constexpr int kHistoryWindowSec = 300; // ventana visible plots + +struct TimePoint { int64_t ts; float v; }; + +struct HistoryRing { + TimePoint buf[kHistoryCap] = {}; + int count = 0; + int head = 0; + void push(int64_t ts, float v) { + buf[head] = {ts, v}; + head = (head + 1) % kHistoryCap; + if (count < kHistoryCap) ++count; + } + // Vuelca solo los puntos dentro de la ventana [now-window, now]. + // out_xs es seconds-from-window-start en [0, window]. + // Devuelve count. + int flatten_window(int64_t now, int window_sec, + float* out_xs, float* out_ys, int cap) const { + int n = std::min(count, cap); + int start = (head - count + kHistoryCap) % kHistoryCap; + int64_t lo_ts = now - window_sec; + int written = 0; + for (int i = 0; i < n; ++i) { + const TimePoint& p = buf[(start + i) % kHistoryCap]; + if (p.ts < lo_ts) continue; + if (written >= cap) break; + out_xs[written] = (float)(p.ts - lo_ts); + out_ys[written] = p.v; + ++written; + } + return written; + } + // Solo Y para sparkline (KPI cards) — fade gradient implicito por orden. + int flatten_window_y(int64_t now, int window_sec, float* out_ys, int cap) const { + int n = std::min(count, cap); + int start = (head - count + kHistoryCap) % kHistoryCap; + int64_t lo_ts = now - window_sec; + int written = 0; + for (int i = 0; i < n; ++i) { + const TimePoint& p = buf[(start + i) % kHistoryCap]; + if (p.ts < lo_ts) continue; + if (written >= cap) break; + out_ys[written] = p.v; + ++written; + } + return written; + } +}; + +enum class HostKind { Local, Wsl, Http }; + +struct HostRuntime { + int64_t id = 0; // 0 = local pseudo-host, -1 = wsl pseudo + std::string name = "Local"; + std::string url; // empty = local + std::string token; + std::string os; + HostKind kind = HostKind::Local; + bool is_local = true; // true para Local + Wsl (no HTTP remote) + bool online = true; + double last_poll_at = 0.0; + pex::StatsSnapshot stats; + std::vector processes; + std::vector devices; + std::vector services; + std::vector netconns; + bool netconns_loaded = false; + bool devices_loaded = false; + bool services_loaded = false; + HistoryRing hist_cpu, hist_ram, hist_disk, hist_net; +}; + +static void load_netconns(HostRuntime& r) { + switch (r.kind) { + case HostKind::Local: r.netconns = pex::fetch_netconns_local(); break; + case HostKind::Wsl: r.netconns = pex::fetch_netconns_wsl(); break; + case HostKind::Http: /* HTTP agent fetch ya en poll_runtime */ break; + } + r.netconns_loaded = true; +} +static void load_devices(HostRuntime& r) { + switch (r.kind) { + case HostKind::Local: r.devices = pex::fetch_devices_local(); break; + case HostKind::Wsl: r.devices = pex::fetch_devices_wsl(); break; + case HostKind::Http: break; + } + r.devices_loaded = true; +} +static void load_services(HostRuntime& r) { + switch (r.kind) { + case HostKind::Local: r.services = pex::fetch_services_local(); break; + case HostKind::Wsl: r.services = pex::fetch_services_wsl(); break; + case HostKind::Http: break; + } + r.services_loaded = true; +} + +static std::vector g_runtimes; +static int64_t g_selected_host_id = 0; // panel-scoped selector +static constexpr double kPollIntervalSec = 1.0; +static constexpr int kPollWslIntervalMs = 2000; + +// WSL polling se hace en un worker thread. Cada wsl.exe tarda ~300-500ms +// y bloquearia el render del main thread cada poll. Doble buffer con mutex. +#ifdef _WIN32 +static std::thread g_wsl_worker; +static std::atomic g_wsl_stop{false}; +static std::mutex g_wsl_mu; +static pex::StatsSnapshot g_wsl_stats_buf; +static std::vector g_wsl_procs_buf; +static std::atomic g_wsl_fresh{false}; + +static void wsl_worker_thread_fn() { + while (!g_wsl_stop.load(std::memory_order_acquire)) { + auto stats = pex::fetch_stats_wsl(); + auto procs = pex::fetch_processes_wsl(); + { + std::lock_guard lk(g_wsl_mu); + g_wsl_stats_buf = std::move(stats); + g_wsl_procs_buf = std::move(procs); + } + g_wsl_fresh.store(true, std::memory_order_release); + + // Sleep en pasos de 100ms para reaccionar rapido a stop. + for (int i = 0; i < kPollWslIntervalMs / 100; ++i) { + if (g_wsl_stop.load(std::memory_order_acquire)) return; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } +} +#endif + +static pex::HostsDb g_hosts_db; +static pex::SamplesDb g_samples_db; + +// Add-host modal state +static bool g_open_add_host = false; +static char g_add_name[64] = {}; +static char g_add_url[256] = {}; +static char g_add_token[256] = {}; +static char g_add_os[16] = "linux"; + +// Panel toggles +static bool g_show_overview = true; +static bool g_show_processes = true; +static bool g_show_network = false; +static bool g_show_devices = false; +static bool g_show_services = false; + +// ========================================================================= +// Runtime management +// ========================================================================= + +static HostRuntime* find_runtime(int64_t id) { + for (auto& r : g_runtimes) if (r.id == id) return &r; + return nullptr; +} + +static void seed_local_runtime() { + HostRuntime local; + local.id = 0; + local.kind = HostKind::Local; + local.is_local = true; +#ifdef _WIN32 + local.name = "Windows"; + local.os = "windows"; +#else + local.name = "Linux"; + local.os = "linux"; +#endif + g_runtimes.push_back(std::move(local)); +} + +#ifdef _WIN32 +static void seed_wsl_runtime() { + HostRuntime wsl; + wsl.id = -1; + wsl.name = "WSL"; + wsl.os = "linux"; + wsl.kind = HostKind::Wsl; + wsl.is_local = true; // accesible sin red + g_runtimes.push_back(std::move(wsl)); +} +#endif + +static void load_hosts_from_db() { + auto rows = g_hosts_db.list(); + for (const auto& h : rows) { + if (find_runtime(h.id)) continue; + HostRuntime r; + r.id = h.id; + r.name = h.name; + r.url = h.url; + r.token = h.token; + r.os = h.os; + r.is_local = h.is_local; + r.kind = h.is_local ? HostKind::Local : HostKind::Http; + g_runtimes.push_back(std::move(r)); + } +} + +static void poll_runtime(HostRuntime& r) { + switch (r.kind) { + case HostKind::Local: + r.stats = pex::fetch_stats_local(); + r.processes = pex::fetch_processes_local(); + r.online = true; + // Network/Devices/Services se cargan on-demand (popen caro) desde los + // paneles via Refresh button — ver draw_network/devices/services. + break; + case HostKind::Wsl: +#ifdef _WIN32 + // Non-blocking: consume buffer del worker. Si no fresh, mantiene + // ultimo valor — no para nunca la UI. + if (g_wsl_fresh.exchange(false, std::memory_order_acq_rel)) { + std::lock_guard lk(g_wsl_mu); + r.stats = g_wsl_stats_buf; + r.processes = g_wsl_procs_buf; + r.online = (r.stats.ram_total_mb > 0.0f); + } +#else + r.stats = pex::fetch_stats_wsl(); + r.processes = pex::fetch_processes_wsl(); + r.online = (r.stats.ram_total_mb > 0.0f); +#endif + break; + case HostKind::Http: + r.stats = pex::fetch_stats(r.url, r.token); + r.processes = pex::fetch_processes(r.url, r.token); + r.devices = pex::fetch_devices(r.url, r.token); + r.services = pex::fetch_services(r.url, r.token); + r.netconns = pex::fetch_netconns(r.url, r.token); + r.online = (r.stats.ram_total_mb > 0.0f); + break; + } + + int64_t ts = r.stats.unix_ts ? r.stats.unix_ts : (int64_t)std::time(nullptr); + r.hist_cpu.push(ts, r.stats.cpu_pct); + float ram_pct = r.stats.ram_total_mb > 0.0f + ? (r.stats.ram_used_mb / r.stats.ram_total_mb) * 100.0f : 0.0f; + r.hist_ram.push(ts, ram_pct); + r.hist_disk.push(ts, r.stats.disk_read_mb_s + r.stats.disk_write_mb_s); + r.hist_net.push(ts, r.stats.net_rx_mb_s + r.stats.net_tx_mb_s); + + pex::Sample s{}; + s.host_id = r.id; + s.unix_ts = r.stats.unix_ts ? r.stats.unix_ts : (int64_t)std::time(nullptr); + s.cpu_pct = r.stats.cpu_pct; + s.ram_used_mb = r.stats.ram_used_mb; + s.ram_total_mb = r.stats.ram_total_mb; + s.disk_read_mb_s = r.stats.disk_read_mb_s; + s.disk_write_mb_s = r.stats.disk_write_mb_s; + s.net_rx_mb_s = r.stats.net_rx_mb_s; + s.net_tx_mb_s = r.stats.net_tx_mb_s; + s.gpu_pct = r.stats.gpu_pct; + g_samples_db.insert(s); + + if (r.kind == HostKind::Http && r.online) g_hosts_db.touch_last_seen(r.id, s.unix_ts); + + r.last_poll_at = ImGui::GetTime(); +} + +static void poll_all_due() { + double now = ImGui::GetTime(); + for (auto& r : g_runtimes) { + if (now - r.last_poll_at >= kPollIntervalSec) poll_runtime(r); + } +} + +// ========================================================================= +// Host selector combo (single-host panels) +// ========================================================================= + +static void draw_host_selector(const char* label) { + HostRuntime* cur = find_runtime(g_selected_host_id); + if (!cur && !g_runtimes.empty()) { + g_selected_host_id = g_runtimes.front().id; + cur = &g_runtimes.front(); + } + const char* preview = cur ? cur->name.c_str() : "(none)"; + ImGui::SetNextItemWidth(180.0f); + if (ImGui::BeginCombo(label, preview)) { + for (auto& r : g_runtimes) { + bool selected = (r.id == g_selected_host_id); + std::string lbl = r.name; + if (r.kind == HostKind::Http) lbl += r.online ? " [online]" : " [offline]"; + if (ImGui::Selectable(lbl.c_str(), selected)) g_selected_host_id = r.id; + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } +} + +// ========================================================================= +// Add-host modal +// ========================================================================= + +static void draw_add_host_modal() { + if (g_open_add_host) { + ImGui::OpenPopup("Add host"); + g_open_add_host = false; + } + if (ImGui::BeginPopupModal("Add host", nullptr, ImGuiWindowFlags_AlwaysAutoResize)) { + ImGui::InputText("Name", g_add_name, sizeof(g_add_name)); + ImGui::InputText("URL", g_add_url, sizeof(g_add_url)); + ImGui::InputText("Token", g_add_token, sizeof(g_add_token), ImGuiInputTextFlags_Password); + ImGui::InputText("OS", g_add_os, sizeof(g_add_os)); + ImGui::TextDisabled("URL ej: http://aurgi-pc:8487 (process_agent, issue 0111)"); + + bool can_save = g_add_name[0] != 0 && g_add_url[0] != 0; + ImGui::BeginDisabled(!can_save); + if (ImGui::Button("Save")) { + pex::Host h; + h.name = g_add_name; + h.url = g_add_url; + h.token = g_add_token; + h.os = g_add_os; + h.is_local = false; + h.last_seen_unix = 0; + int64_t id = g_hosts_db.upsert(h); + if (id > 0) { + load_hosts_from_db(); + std::memset(g_add_name, 0, sizeof(g_add_name)); + std::memset(g_add_url, 0, sizeof(g_add_url)); + std::memset(g_add_token, 0, sizeof(g_add_token)); + } + ImGui::CloseCurrentPopup(); + } + ImGui::EndDisabled(); + ImGui::SameLine(); + if (ImGui::Button("Cancel")) ImGui::CloseCurrentPopup(); + ImGui::EndPopup(); + } +} + +// ========================================================================= +// Panels +// ========================================================================= + +static void draw_host_kpis(HostRuntime& r) { + float hist[kHistoryCap]; + int n; + int64_t now = (int64_t)std::time(nullptr); + + ImGui::Columns(4, nullptr, false); + + n = r.hist_cpu.flatten_window_y(now, kHistoryWindowSec, hist, kHistoryCap); + kpi_card("CPU", r.stats.cpu_pct, 0.0f, hist, n, + 0.0f, 100.0f, "%.1f%%", TI_CPU); + ImGui::NextColumn(); + + n = r.hist_ram.flatten_window_y(now, kHistoryWindowSec, hist, kHistoryCap); + float ram_pct = r.stats.ram_total_mb > 0.0f + ? (r.stats.ram_used_mb / r.stats.ram_total_mb) * 100.0f : 0.0f; + kpi_card("RAM", ram_pct, 0.0f, hist, n, + 0.0f, 100.0f, "%.1f%%", TI_DATABASE); + ImGui::NextColumn(); + + n = r.hist_disk.flatten_window_y(now, kHistoryWindowSec, hist, kHistoryCap); + kpi_card("Disk I/O", r.stats.disk_read_mb_s + r.stats.disk_write_mb_s, + 0.0f, hist, n, "%.1f MB/s", TI_DEVICE_FLOPPY); + ImGui::NextColumn(); + + n = r.hist_net.flatten_window_y(now, kHistoryWindowSec, hist, kHistoryCap); + kpi_card("Net I/O", r.stats.net_rx_mb_s + r.stats.net_tx_mb_s, + 0.0f, hist, n, "%.1f MB/s", TI_WORLD); + + ImGui::Columns(1); +} + +static void draw_overview() { + if (!ImGui::Begin(TI_GAUGE " Overview", &g_show_overview)) { ImGui::End(); return; } + + if (ImGui::Button(TI_PLUS " Add host")) g_open_add_host = true; + ImGui::SameLine(); + ImGui::TextDisabled("%zu hosts", g_runtimes.size()); + draw_add_host_modal(); + + ImGui::Spacing(); + + for (auto& r : g_runtimes) { + ImGui::PushID((int)r.id); + + const char* status_icon = TI_HOME; + switch (r.kind) { + case HostKind::Local: status_icon = TI_HOME; break; + case HostKind::Wsl: status_icon = TI_TERMINAL_2; break; + case HostKind::Http: status_icon = r.online ? TI_PLUG_CONNECTED : TI_PLUG_CONNECTED_X; break; + } + ImVec4 status_col = r.online ? ImVec4(0.4f, 0.85f, 0.5f, 1.0f) : ImVec4(0.7f, 0.4f, 0.4f, 1.0f); + ImGui::PushStyleColor(ImGuiCol_Text, status_col); + ImGui::TextUnformatted(status_icon); + ImGui::PopStyleColor(); + ImGui::SameLine(); + ImGui::Text("%s", r.name.c_str()); + if (r.kind == HostKind::Http) { + ImGui::SameLine(); + ImGui::TextDisabled("(%s)", r.url.c_str()); + } + + draw_host_kpis(r); + + // CPU history plot — ventana temporal fija [0, 300s], Y [0, 100]. + // Aunque solo haya 30s de datos al inicio, la escala X NO se aplasta. + float xs[kHistoryCap], ys[kHistoryCap]; + int64_t now = (int64_t)std::time(nullptr); + int m = r.hist_cpu.flatten_window(now, kHistoryWindowSec, xs, ys, kHistoryCap); + char plot_id[80]; + std::snprintf(plot_id, sizeof(plot_id), "%s CPU %%##%lld", r.name.c_str(), (long long)r.id); + line_plot(plot_id, xs, ys, m, + 0.0f, (float)kHistoryWindowSec, + 0.0f, 100.0f, 100.0f); + + ImGui::Separator(); + ImGui::Spacing(); + ImGui::PopID(); + } + + ImGui::End(); +} + +static void draw_processes() { + if (!ImGui::Begin(TI_LIST " Processes", &g_show_processes)) { ImGui::End(); return; } + + draw_host_selector("Host##procs"); + ImGui::SameLine(); + HostRuntime* r = find_runtime(g_selected_host_id); + if (!r) { ImGui::TextDisabled("no host"); ImGui::End(); return; } + + ImGui::Text("%zu procesos", r->processes.size()); + ImGui::SameLine(); + if (ImGui::Button("Refresh")) poll_runtime(*r); + + if (ImGui::BeginTable("procs", 6, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Sortable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("PID"); + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("State"); + ImGui::TableSetupColumn("CPU%"); + ImGui::TableSetupColumn("RAM MB"); + ImGui::TableSetupColumn("Cmdline"); + ImGui::TableHeadersRow(); + + for (const auto& p : r->processes) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); ImGui::Text("%d", p.pid); + ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(p.name.c_str()); + ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(p.state.c_str()); + ImGui::TableSetColumnIndex(3); ImGui::Text("%.1f", p.cpu_pct); + ImGui::TableSetColumnIndex(4); ImGui::Text("%.1f", p.ram_mb); + ImGui::TableSetColumnIndex(5); ImGui::TextUnformatted(p.cmdline.c_str()); + } + ImGui::EndTable(); + } + ImGui::End(); +} + +static void draw_network() { + if (!ImGui::Begin(TI_NETWORK " Network", &g_show_network)) { ImGui::End(); return; } + draw_host_selector("Host##net"); + HostRuntime* r = find_runtime(g_selected_host_id); + if (!r) { ImGui::TextDisabled("no host"); ImGui::End(); return; } + if (!r->netconns_loaded) load_netconns(*r); + ImGui::SameLine(); + if (ImGui::Button("Refresh##net")) load_netconns(*r); + ImGui::SameLine(); + ImGui::TextDisabled("%zu conexiones", r->netconns.size()); + + if (r->netconns.empty()) { + ImGui::TextDisabled("(vacio — fuente no disponible o sin conexiones)"); + } else if (ImGui::BeginTable("conns", 5, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Sortable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("PID"); + ImGui::TableSetupColumn("Proto"); + ImGui::TableSetupColumn("Local"); + ImGui::TableSetupColumn("Remote"); + ImGui::TableSetupColumn("State"); + ImGui::TableHeadersRow(); + for (const auto& c : r->netconns) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); ImGui::Text("%d", c.pid); + ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(c.proto.c_str()); + ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(c.local.c_str()); + ImGui::TableSetColumnIndex(3); ImGui::TextUnformatted(c.remote.c_str()); + ImGui::TableSetColumnIndex(4); ImGui::TextUnformatted(c.state.c_str()); + } + ImGui::EndTable(); + } + ImGui::End(); +} + +static void draw_devices() { + if (!ImGui::Begin(TI_DEVICES_PC " Devices", &g_show_devices)) { ImGui::End(); return; } + draw_host_selector("Host##dev"); + HostRuntime* r = find_runtime(g_selected_host_id); + if (!r) { ImGui::TextDisabled("no host"); ImGui::End(); return; } + if (!r->devices_loaded) load_devices(*r); + ImGui::SameLine(); + if (ImGui::Button("Refresh##dev")) load_devices(*r); + ImGui::SameLine(); + ImGui::TextDisabled("%zu dispositivos", r->devices.size()); + + if (r->devices.empty()) { + ImGui::TextDisabled("(vacio)"); + } else if (ImGui::BeginTable("devs", 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Sortable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("Kind"); + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("Detail"); + ImGui::TableSetupColumn("Usage%"); + ImGui::TableHeadersRow(); + for (const auto& d : r->devices) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(d.kind.c_str()); + ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(d.name.c_str()); + ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(d.detail.c_str()); + ImGui::TableSetColumnIndex(3); + if (d.usage_pct >= 0.0f) ImGui::Text("%.1f", d.usage_pct); + else ImGui::TextDisabled("-"); + } + ImGui::EndTable(); + } + ImGui::End(); +} + +static void draw_services() { + if (!ImGui::Begin(TI_SERVER " Services", &g_show_services)) { ImGui::End(); return; } + draw_host_selector("Host##svc"); + HostRuntime* r = find_runtime(g_selected_host_id); + if (!r) { ImGui::TextDisabled("no host"); ImGui::End(); return; } + if (!r->services_loaded) load_services(*r); + ImGui::SameLine(); + if (ImGui::Button("Refresh##svc")) load_services(*r); + ImGui::SameLine(); + ImGui::TextDisabled("%zu services", r->services.size()); + + if (r->services.empty()) { + ImGui::TextDisabled("(vacio — systemctl/sc no disponibles)"); + } else if (ImGui::BeginTable("svc", 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Sortable | ImGuiTableFlags_ScrollY)) { + ImGui::TableSetupColumn("Name"); + ImGui::TableSetupColumn("State"); + ImGui::TableSetupColumn("Runtime"); + ImGui::TableSetupColumn("Description"); + ImGui::TableHeadersRow(); + for (const auto& s : r->services) { + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); ImGui::TextUnformatted(s.name.c_str()); + ImGui::TableSetColumnIndex(1); ImGui::TextUnformatted(s.state.c_str()); + ImGui::TableSetColumnIndex(2); ImGui::TextUnformatted(s.runtime.c_str()); + ImGui::TableSetColumnIndex(3); ImGui::TextUnformatted(s.description.c_str()); + } + ImGui::EndTable(); + } + ImGui::End(); +} + +// ========================================================================= +// Render + Self-test + Entry +// ========================================================================= + +static void render() { + poll_all_due(); + if (g_show_overview) draw_overview(); + if (g_show_processes) draw_processes(); + if (g_show_network) draw_network(); + if (g_show_devices) draw_devices(); + if (g_show_services) draw_services(); +} + +static int self_test() { + std::string hosts_path = fn::local_path("hosts.db"); + std::string samples_path = fn::local_path("process_samples.db"); + pex::HostsDb h; if (!h.open(hosts_path)) { std::fprintf(stderr, "self-test: hosts.db open failed\n"); return 1; } + pex::SamplesDb s; if (!s.open(samples_path)) { std::fprintf(stderr, "self-test: samples.db open failed\n"); return 1; } + auto stats = pex::fetch_stats_local(); + if (stats.unix_ts == 0) { std::fprintf(stderr, "self-test: stats_local no ts\n"); return 1; } + std::fprintf(stdout, "self-test OK\n"); + return 0; +} + +int main(int argc, char** argv) { + for (int i = 1; i < argc; ++i) { + if (std::strcmp(argv[i], "--self-test") == 0) return self_test(); + } + + static fn_ui::PanelToggle panels[] = { + { "Overview", TI_GAUGE, &g_show_overview }, + { "Processes", TI_LIST, &g_show_processes }, + { "Network", TI_NETWORK, &g_show_network }, + { "Devices", TI_DEVICES_PC, &g_show_devices }, + { "Services", TI_SERVER, &g_show_services }, + }; + + fn::AppConfig cfg; + cfg.title = "process_explorer — cross-PC process & resource monitor"; + cfg.about = { "process_explorer", "0.4.0", + "Process explorer cross-PC: CPU/RAM/disk/net/GPU + procesos en tiempo real" }; + cfg.log = { "process_explorer.log", 1 }; + cfg.panels = panels; + cfg.panel_count = sizeof(panels) / sizeof(panels[0]); + + g_hosts_db.open(fn::local_path("hosts.db")); + g_samples_db.open(fn::local_path("process_samples.db")); + seed_local_runtime(); +#ifdef _WIN32 + seed_wsl_runtime(); + g_wsl_worker = std::thread(wsl_worker_thread_fn); +#endif + load_hosts_from_db(); + + int rc = fn::run_app(cfg, render); + +#ifdef _WIN32 + g_wsl_stop.store(true, std::memory_order_release); + if (g_wsl_worker.joinable()) g_wsl_worker.join(); +#endif + + g_hosts_db.close(); + g_samples_db.close(); + return rc; +} diff --git a/operations.db b/operations.db new file mode 100644 index 0000000..6905a41 Binary files /dev/null and b/operations.db differ diff --git a/samples_db.cpp b/samples_db.cpp new file mode 100644 index 0000000..c77ec35 --- /dev/null +++ b/samples_db.cpp @@ -0,0 +1,97 @@ +#include "samples_db.h" +#include + +namespace pex { + +static const char* k_schema = R"SQL( +CREATE TABLE IF NOT EXISTS samples ( + host_id INTEGER NOT NULL, + unix_ts INTEGER NOT NULL, + cpu_pct REAL NOT NULL, + ram_used_mb REAL NOT NULL, + ram_total_mb REAL NOT NULL, + disk_read_mb_s REAL NOT NULL, + disk_write_mb_s REAL NOT NULL, + net_rx_mb_s REAL NOT NULL, + net_tx_mb_s REAL NOT NULL, + gpu_pct REAL NOT NULL, + PRIMARY KEY(host_id, unix_ts) +); +CREATE INDEX IF NOT EXISTS idx_samples_host_ts ON samples(host_id, unix_ts); +)SQL"; + +SamplesDb::SamplesDb() = default; +SamplesDb::~SamplesDb() { close(); } + +bool SamplesDb::open(const std::string& path) { + if (sqlite3_open(path.c_str(), &db_) != SQLITE_OK) { + db_ = nullptr; + return false; + } + char* err = nullptr; + if (sqlite3_exec(db_, k_schema, nullptr, nullptr, &err) != SQLITE_OK) { + sqlite3_free(err); + close(); + return false; + } + return true; +} + +void SamplesDb::close() { + if (db_) { sqlite3_close(db_); db_ = nullptr; } +} + +bool SamplesDb::insert(const Sample& s) { + if (!db_) return false; + const char* sql = + "INSERT OR REPLACE INTO samples(host_id,unix_ts,cpu_pct,ram_used_mb,ram_total_mb," + "disk_read_mb_s,disk_write_mb_s,net_rx_mb_s,net_tx_mb_s,gpu_pct) " + "VALUES(?,?,?,?,?,?,?,?,?,?)"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return false; + sqlite3_bind_int64(st, 1, s.host_id); + sqlite3_bind_int64(st, 2, s.unix_ts); + sqlite3_bind_double(st, 3, s.cpu_pct); + sqlite3_bind_double(st, 4, s.ram_used_mb); + sqlite3_bind_double(st, 5, s.ram_total_mb); + sqlite3_bind_double(st, 6, s.disk_read_mb_s); + sqlite3_bind_double(st, 7, s.disk_write_mb_s); + sqlite3_bind_double(st, 8, s.net_rx_mb_s); + sqlite3_bind_double(st, 9, s.net_tx_mb_s); + sqlite3_bind_double(st, 10, s.gpu_pct); + bool ok = sqlite3_step(st) == SQLITE_DONE; + sqlite3_finalize(st); + return ok; +} + +std::vector SamplesDb::query_range(int64_t host_id, int64_t unix_from, int64_t unix_to) { + std::vector out; + if (!db_) return out; + const char* sql = + "SELECT host_id,unix_ts,cpu_pct,ram_used_mb,ram_total_mb," + "disk_read_mb_s,disk_write_mb_s,net_rx_mb_s,net_tx_mb_s,gpu_pct " + "FROM samples WHERE host_id=? AND unix_ts BETWEEN ? AND ? ORDER BY unix_ts ASC"; + sqlite3_stmt* st = nullptr; + if (sqlite3_prepare_v2(db_, sql, -1, &st, nullptr) != SQLITE_OK) return out; + sqlite3_bind_int64(st, 1, host_id); + sqlite3_bind_int64(st, 2, unix_from); + sqlite3_bind_int64(st, 3, unix_to); + while (sqlite3_step(st) == SQLITE_ROW) { + Sample s; + s.host_id = sqlite3_column_int64(st, 0); + s.unix_ts = sqlite3_column_int64(st, 1); + s.cpu_pct = (float)sqlite3_column_double(st, 2); + s.ram_used_mb = (float)sqlite3_column_double(st, 3); + s.ram_total_mb = (float)sqlite3_column_double(st, 4); + s.disk_read_mb_s = (float)sqlite3_column_double(st, 5); + s.disk_write_mb_s = (float)sqlite3_column_double(st, 6); + s.net_rx_mb_s = (float)sqlite3_column_double(st, 7); + s.net_tx_mb_s = (float)sqlite3_column_double(st, 8); + s.gpu_pct = (float)sqlite3_column_double(st, 9); + out.push_back(s); + } + sqlite3_finalize(st); + return out; +} + +} // namespace pex diff --git a/samples_db.h b/samples_db.h new file mode 100644 index 0000000..73a2944 --- /dev/null +++ b/samples_db.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include + +struct sqlite3; + +namespace pex { + +struct Sample { + int64_t host_id = 0; + int64_t unix_ts = 0; + float cpu_pct = 0.0f; + float ram_used_mb = 0.0f; + float ram_total_mb = 0.0f; + float disk_read_mb_s = 0.0f; + float disk_write_mb_s = 0.0f; + float net_rx_mb_s = 0.0f; + float net_tx_mb_s = 0.0f; + float gpu_pct = 0.0f; +}; + +class SamplesDb { +public: + SamplesDb(); + ~SamplesDb(); + + bool open(const std::string& path); + void close(); + + bool insert(const Sample& s); + // Devuelve muestras del host en ventana [unix_from, unix_to], ordenadas asc. + std::vector query_range(int64_t host_id, int64_t unix_from, int64_t unix_to); + +private: + sqlite3* db_ = nullptr; +}; + +} // namespace pex