feat: agente con métricas de red, swap, top procesos y TCP; dashboard mejorado

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-06-25 08:49:39 -05:00
co-authored by Claude Sonnet 4.6
parent 491e908448
commit 34b9c81262
2 changed files with 197 additions and 25 deletions
+117 -25
View File
@@ -9,6 +9,7 @@ import (
"net/http"
"os"
"runtime"
"sort"
"time"
"github.com/shirou/gopsutil/v3/cpu"
@@ -16,15 +17,17 @@ import (
"github.com/shirou/gopsutil/v3/host"
"github.com/shirou/gopsutil/v3/load"
"github.com/shirou/gopsutil/v3/mem"
pnet "github.com/shirou/gopsutil/v3/net"
"github.com/shirou/gopsutil/v3/process"
"gopkg.in/yaml.v3"
)
// ── Config ────────────────────────────────────────────────────────────────────
type Config struct {
APIURL string `yaml:"api_url"` // https://admin.u-site.app
Token string `yaml:"token"` // token generado desde el panel
Interval int `yaml:"interval"` // segundos entre reportes (default: 30)
APIURL string `yaml:"api_url"`
Token string `yaml:"token"`
Interval int `yaml:"interval"`
Debug bool `yaml:"debug"`
}
@@ -46,16 +49,20 @@ func loadConfig(path string) (*Config, error) {
// ── Métricas ──────────────────────────────────────────────────────────────────
type Metricas struct {
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
Uptime uint64 `json:"uptime_seg"`
UptimeStr string `json:"uptime"`
RAM RAMInfo `json:"ram"`
CPU CPUInfo `json:"cpu"`
Discos []DiscoInfo `json:"discos"`
LoadAvg LoadInfo `json:"load_avg"`
ReportadoEn string `json:"reportado_en"`
Hostname string `json:"hostname"`
OS string `json:"os"`
Arch string `json:"arch"`
Uptime uint64 `json:"uptime_seg"`
UptimeStr string `json:"uptime"`
RAM RAMInfo `json:"ram"`
Swap SwapInfo `json:"swap"`
CPU CPUInfo `json:"cpu"`
Discos []DiscoInfo `json:"discos"`
LoadAvg LoadInfo `json:"load_avg"`
Red []RedInfo `json:"red"`
TCPConns int `json:"tcp_conns"`
TopProcs []ProcesoInfo `json:"top_procs"`
ReportadoEn string `json:"reportado_en"`
}
type RAMInfo struct {
@@ -65,6 +72,12 @@ type RAMInfo struct {
Porcentaje float64 `json:"porcentaje"`
}
type SwapInfo struct {
TotalGB float64 `json:"total_gb"`
UsadoGB float64 `json:"usado_gb"`
Porcentaje float64 `json:"porcentaje"`
}
type CPUInfo struct {
Nucleos int `json:"nucleos"`
Porcentaje float64 `json:"porcentaje"`
@@ -84,6 +97,21 @@ type LoadInfo struct {
Load15 float64 `json:"load15"`
}
type RedInfo struct {
Interface string `json:"interface"`
BytesEnvMB float64 `json:"bytes_env_mb"`
BytesRecMB float64 `json:"bytes_rec_mb"`
PktEnv uint64 `json:"pkt_env"`
PktRec uint64 `json:"pkt_rec"`
}
type ProcesoInfo struct {
PID int32 `json:"pid"`
Nombre string `json:"nombre"`
CPU float64 `json:"cpu"`
RAMMB float64 `json:"ram_mb"`
}
func round2(v float64) float64 {
return float64(int(v*100)) / 100
}
@@ -110,11 +138,11 @@ func collectMetrics() (*Metricas, error) {
m.Hostname = hn
}
// Uptime
// Uptime + OS
if info, err := host.Info(); err == nil {
m.Uptime = info.Uptime
m.UptimeStr = uptimeStr(info.Uptime)
if info.OS != "" {
if info.Platform != "" {
m.OS = info.Platform + " " + info.PlatformVersion
}
}
@@ -130,16 +158,26 @@ func collectMetrics() (*Metricas, error) {
}
}
// Swap
if sv, err := mem.SwapMemory(); err == nil && sv.Total > 0 {
gb := 1024.0 * 1024 * 1024
m.Swap = SwapInfo{
TotalGB: round2(float64(sv.Total) / gb),
UsadoGB: round2(float64(sv.Used) / gb),
Porcentaje: round2(sv.UsedPercent),
}
}
// CPU
m.CPU.Nucleos = runtime.NumCPU()
if pcts, err := cpu.Percent(1*time.Second, false); err == nil && len(pcts) > 0 {
m.CPU.Porcentaje = round2(pcts[0])
}
// Discos: todas las particiones físicas/reales
// Discos
if parts, err := disk.Partitions(false); err == nil {
gb := 1024.0 * 1024 * 1024
skipFSTypes := map[string]bool{
skipFS := map[string]bool{
"proc": true, "sysfs": true, "tmpfs": true, "devtmpfs": true,
"devpts": true, "cgroup": true, "cgroup2": true, "overlay": true,
"hugetlbfs": true, "mqueue": true, "pstore": true, "securityfs": true,
@@ -148,7 +186,7 @@ func collectMetrics() (*Metricas, error) {
"squashfs": true, "ramfs": true, "nsfs": true, "none": true,
}
for _, p := range parts {
if skipFSTypes[p.Fstype] {
if skipFS[p.Fstype] {
continue
}
if d, err := disk.Usage(p.Mountpoint); err == nil {
@@ -162,7 +200,6 @@ func collectMetrics() (*Metricas, error) {
}
}
}
// Fallback: si no se encontraron particiones, usar raíz
if len(m.Discos) == 0 {
root := "/"
if runtime.GOOS == "windows" {
@@ -180,11 +217,69 @@ func collectMetrics() (*Metricas, error) {
}
}
// Load average (Linux/macOS)
// Load average
if la, err := load.Avg(); err == nil {
m.LoadAvg = LoadInfo{Load1: round2(la.Load1), Load5: round2(la.Load5), Load15: round2(la.Load15)}
}
// Red (interfaces físicas, excluir loopback)
if counters, err := pnet.IOCounters(true); err == nil {
mb := 1024.0 * 1024
for _, c := range counters {
if c.Name == "lo" || c.Name == "lo0" {
continue
}
m.Red = append(m.Red, RedInfo{
Interface: c.Name,
BytesEnvMB: round2(float64(c.BytesSent) / mb),
BytesRecMB: round2(float64(c.BytesRecv) / mb),
PktEnv: c.PacketsSent,
PktRec: c.PacketsRecv,
})
}
}
// TCP conexiones activas
if conns, err := pnet.Connections("tcp"); err == nil {
m.TCPConns = len(conns)
}
// Top 10 procesos por CPU (luego por RAM como desempate)
if procs, err := process.Processes(); err == nil {
type pd struct {
pid int32
name string
cpu float64
ramMB float64
}
var lista []pd
for _, p := range procs {
cpuPct, _ := p.CPUPercent()
name, _ := p.Name()
if name == "" {
continue
}
ramMB := 0.0
if mi, err := p.MemoryInfo(); err == nil && mi != nil {
ramMB = round2(float64(mi.RSS) / (1024 * 1024))
}
lista = append(lista, pd{p.Pid, name, round2(cpuPct), ramMB})
}
sort.Slice(lista, func(i, j int) bool {
if lista[i].cpu != lista[j].cpu {
return lista[i].cpu > lista[j].cpu
}
return lista[i].ramMB > lista[j].ramMB
})
limit := 10
if len(lista) < limit {
limit = len(lista)
}
for _, d := range lista[:limit] {
m.TopProcs = append(m.TopProcs, ProcesoInfo{d.pid, d.name, d.cpu, d.ramMB})
}
}
return m, nil
}
@@ -238,14 +333,12 @@ func main() {
cfg, err := loadConfig(*configPath)
if err != nil {
// Si no hay config pero se pasaron flags, usarlos directamente
if *apiURL == "" || *token == "" {
log.Fatalf("Error cargando config: %v\nUso: usite-agent --api-url=https://... --token=...", err)
}
cfg = &Config{APIURL: *apiURL, Token: *token, Interval: 30}
}
// Flags sobreescriben config
if *apiURL != "" {
cfg.APIURL = *apiURL
}
@@ -262,7 +355,6 @@ func main() {
ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second)
defer ticker.Stop()
// Enviar inmediatamente al iniciar
reportar := func() {
m, err := collectMetrics()
if err != nil {
@@ -276,8 +368,8 @@ func main() {
if err := sendHeartbeat(cfg, m); err != nil {
log.Printf("⚠️ Error enviando heartbeat: %v", err)
} else {
log.Printf("✅ Heartbeat enviado | RAM: %.1f%% | CPU: %.1f%% | Discos: %d",
m.RAM.Porcentaje, m.CPU.Porcentaje, len(m.Discos))
log.Printf("✅ Heartbeat | RAM: %.1f%% | CPU: %.1f%% | TCP: %d | Procs: %d",
m.RAM.Porcentaje, m.CPU.Porcentaje, m.TCPConns, len(m.TopProcs))
}
}
+80
View File
@@ -109,6 +109,79 @@
<p class="text-[10px] text-slate-400 mt-0.5" x-text="'+' + (m.discos.length - 3) + ' más'"></p>
</template>
</div>
<!-- Swap -->
<template x-if="m.swap?.total_gb > 0">
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-slate-500 font-medium">🔄 Swap</span>
<span class="font-mono font-bold text-slate-700"
x-text="m.swap.usado_gb + ' / ' + m.swap.total_gb + ' GB (' + m.swap.porcentaje + '%)'"></span>
</div>
<div class="w-full bg-slate-100 rounded-full h-1.5">
<div class="h-1.5 rounded-full transition-all"
:class="m.swap.porcentaje > 85 ? 'bg-red-500' : m.swap.porcentaje > 50 ? 'bg-amber-400' : 'bg-purple-400'"
:style="'width:' + Math.min(m.swap.porcentaje, 100) + '%'"></div>
</div>
</div>
</template>
<!-- Red -->
<template x-if="(m.red || []).length > 0">
<div>
<div class="flex justify-between text-xs mb-1">
<span class="text-slate-500 font-medium">🌐 Red</span>
<span class="text-[10px] text-slate-400" x-text="(m.red || []).length + ' interfaz(es)'"></span>
</div>
<template x-for="r in (m.red || []).slice(0,2)" :key="r.interface">
<div class="flex justify-between text-[10px] text-slate-500 font-mono mb-0.5">
<span x-text="r.interface"></span>
<span>
<span class="text-green-600"><span x-text="fmtMB(r.bytes_rec_mb)"></span></span>
<span class="text-blue-600 ml-1"><span x-text="fmtMB(r.bytes_env_mb)"></span></span>
</span>
</div>
</template>
</div>
</template>
<!-- TCP + Top Procs -->
<div class="flex items-center justify-between text-xs text-slate-500 pt-1 border-t border-slate-100">
<span>🔌 TCP: <strong x-text="m.tcp_conns ?? '—'"></strong></span>
<span>📊 Load: <strong x-text="m.load_avg?.load1 ?? '—'"></strong></span>
</div>
<!-- Top Procesos -->
<template x-if="(m.top_procs || []).length > 0">
<div x-data="{ showProcs: false }">
<button @click="showProcs=!showProcs" class="w-full text-left text-[10px] text-slate-400 hover:text-slate-600 flex items-center gap-1">
<svg class="w-3 h-3 transition-transform" :class="showProcs ? 'rotate-90' : ''" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
Top procesos (<span x-text="(m.top_procs||[]).length"></span>)
</button>
<template x-if="showProcs">
<div class="mt-1.5 bg-slate-50 rounded-lg overflow-hidden">
<table class="w-full text-[10px]">
<thead><tr class="text-slate-400 border-b border-slate-200">
<th class="px-2 py-1 text-left">Proceso</th>
<th class="px-2 py-1 text-right">CPU%</th>
<th class="px-2 py-1 text-right">RAM</th>
</tr></thead>
<tbody>
<template x-for="p in (m.top_procs||[]).slice(0,8)" :key="p.pid">
<tr class="border-b border-slate-100 last:border-0">
<td class="px-2 py-1 font-mono truncate max-w-[90px]" x-text="p.nombre"></td>
<td class="px-2 py-1 text-right font-bold"
:class="p.cpu > 50 ? 'text-red-600' : p.cpu > 20 ? 'text-amber-600' : 'text-slate-600'"
x-text="p.cpu + '%'"></td>
<td class="px-2 py-1 text-right text-slate-500 font-mono" x-text="fmtMB(p.ram_mb)"></td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
</div>
</template>
<!-- Uptime + OS -->
<div class="flex justify-between text-xs text-slate-400 pt-1 border-t border-slate-100">
<span><span x-text="m.uptime || '—'"></span></span>
@@ -654,6 +727,13 @@
catch { return {}; }
},
fmtMB(mb) {
if (mb === undefined || mb === null) return '—';
if (mb >= 1024) return (mb / 1024).toFixed(1) + ' GB';
if (mb >= 1) return mb.toFixed(0) + ' MB';
return (mb * 1024).toFixed(0) + ' KB';
},
copiar(texto) {
navigator.clipboard.writeText(texto).catch(() => {});
},