Filter by RAM first (cheap), then call CPUPercent on just the 10 winners. Reduces /proc reads from hundreds to 10 per interval. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
381 lines
10 KiB
Go
381 lines
10 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"runtime"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/shirou/gopsutil/v3/cpu"
|
|
"github.com/shirou/gopsutil/v3/disk"
|
|
"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"`
|
|
Token string `yaml:"token"`
|
|
Interval int `yaml:"interval"`
|
|
Debug bool `yaml:"debug"`
|
|
}
|
|
|
|
func loadConfig(path string) (*Config, error) {
|
|
f, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("no se pudo leer %s: %w", path, err)
|
|
}
|
|
var cfg Config
|
|
if err := yaml.Unmarshal(f, &cfg); err != nil {
|
|
return nil, fmt.Errorf("config YAML inválido: %w", err)
|
|
}
|
|
if cfg.Interval <= 0 {
|
|
cfg.Interval = 30
|
|
}
|
|
return &cfg, nil
|
|
}
|
|
|
|
// ── 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"`
|
|
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 {
|
|
TotalGB float64 `json:"total_gb"`
|
|
UsadoGB float64 `json:"usado_gb"`
|
|
LibreGB float64 `json:"libre_gb"`
|
|
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"`
|
|
}
|
|
|
|
type DiscoInfo struct {
|
|
TotalGB float64 `json:"total_gb"`
|
|
UsadoGB float64 `json:"usado_gb"`
|
|
LibreGB float64 `json:"libre_gb"`
|
|
Porcentaje float64 `json:"porcentaje"`
|
|
Ruta string `json:"ruta"`
|
|
}
|
|
|
|
type LoadInfo struct {
|
|
Load1 float64 `json:"load1"`
|
|
Load5 float64 `json:"load5"`
|
|
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
|
|
}
|
|
|
|
func uptimeStr(secs uint64) string {
|
|
d := secs / 86400
|
|
h := (secs % 86400) / 3600
|
|
m := (secs % 3600) / 60
|
|
if d > 0 {
|
|
return fmt.Sprintf("%dd %dh %dm", d, h, m)
|
|
}
|
|
return fmt.Sprintf("%dh %dm", h, m)
|
|
}
|
|
|
|
func collectMetrics() (*Metricas, error) {
|
|
m := &Metricas{
|
|
OS: runtime.GOOS,
|
|
Arch: runtime.GOARCH,
|
|
ReportadoEn: time.Now().UTC().Format(time.RFC3339),
|
|
}
|
|
|
|
// Hostname
|
|
if hn, err := os.Hostname(); err == nil {
|
|
m.Hostname = hn
|
|
}
|
|
|
|
// Uptime + OS
|
|
if info, err := host.Info(); err == nil {
|
|
m.Uptime = info.Uptime
|
|
m.UptimeStr = uptimeStr(info.Uptime)
|
|
if info.Platform != "" {
|
|
m.OS = info.Platform + " " + info.PlatformVersion
|
|
}
|
|
}
|
|
|
|
// RAM
|
|
if v, err := mem.VirtualMemory(); err == nil {
|
|
gb := 1024.0 * 1024 * 1024
|
|
m.RAM = RAMInfo{
|
|
TotalGB: round2(float64(v.Total) / gb),
|
|
UsadoGB: round2(float64(v.Used) / gb),
|
|
LibreGB: round2(float64(v.Free) / gb),
|
|
Porcentaje: round2(v.UsedPercent),
|
|
}
|
|
}
|
|
|
|
// 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 — incluir todo filesystem con espacio real (>100 MB), sin duplicar mountpoints
|
|
{
|
|
gb := 1024.0 * 1024 * 1024
|
|
skipFS := map[string]bool{
|
|
"proc": true, "sysfs": true, "devtmpfs": true, "devpts": true,
|
|
"cgroup": true, "cgroup2": true, "hugetlbfs": true, "mqueue": true,
|
|
"pstore": true, "securityfs": true, "debugfs": true, "tracefs": true,
|
|
"autofs": true, "fusectl": true, "configfs": true, "efivarfs": true,
|
|
"bpf": true, "rpc_pipefs": true, "nsfs": true, "ramfs": true,
|
|
}
|
|
seen := map[string]bool{}
|
|
parts, _ := disk.Partitions(true)
|
|
// Asegurar que siempre se incluye la raíz
|
|
type partEntry struct{ mountpoint, fstype string }
|
|
entries := []partEntry{{"/", ""}}
|
|
if runtime.GOOS == "windows" {
|
|
entries = []partEntry{{"C:\\", ""}}
|
|
}
|
|
for _, p := range parts {
|
|
entries = append(entries, partEntry{p.Mountpoint, p.Fstype})
|
|
}
|
|
for _, e := range entries {
|
|
if seen[e.mountpoint] || skipFS[e.fstype] {
|
|
continue
|
|
}
|
|
d, err := disk.Usage(e.mountpoint)
|
|
if err != nil || d.Total == 0 {
|
|
continue
|
|
}
|
|
totalGB := float64(d.Total) / gb
|
|
if totalGB < 0.1 { // ignorar < 100 MB (tmpfs pequeños, etc.)
|
|
continue
|
|
}
|
|
seen[e.mountpoint] = true
|
|
m.Discos = append(m.Discos, DiscoInfo{
|
|
TotalGB: round2(totalGB),
|
|
UsadoGB: round2(float64(d.Used) / gb),
|
|
LibreGB: round2(float64(d.Free) / gb),
|
|
Porcentaje: round2(d.UsedPercent),
|
|
Ruta: e.mountpoint,
|
|
})
|
|
}
|
|
}
|
|
|
|
// 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: filtrar por RAM primero (barato), luego leer CPU solo en esos 10
|
|
if procs, err := process.Processes(); err == nil {
|
|
type pd struct {
|
|
proc *process.Process
|
|
name string
|
|
ramMB float64
|
|
}
|
|
var lista []pd
|
|
for _, p := range procs {
|
|
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))
|
|
}
|
|
if ramMB < 1 {
|
|
continue
|
|
}
|
|
lista = append(lista, pd{p, name, ramMB})
|
|
}
|
|
sort.Slice(lista, func(i, j int) bool {
|
|
return lista[i].ramMB > lista[j].ramMB
|
|
})
|
|
limit := 10
|
|
if len(lista) < limit {
|
|
limit = len(lista)
|
|
}
|
|
for _, d := range lista[:limit] {
|
|
cpuPct, _ := d.proc.CPUPercent() // solo 10 lecturas, no cientos
|
|
m.TopProcs = append(m.TopProcs, ProcesoInfo{d.proc.Pid, d.name, round2(cpuPct), d.ramMB})
|
|
}
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// ── Reporte ───────────────────────────────────────────────────────────────────
|
|
|
|
type HeartbeatRequest struct {
|
|
Token string `json:"token"`
|
|
Metricas string `json:"metricas"`
|
|
}
|
|
|
|
func sendHeartbeat(cfg *Config, metricas *Metricas) error {
|
|
metJSON, err := json.Marshal(metricas)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
payload := HeartbeatRequest{
|
|
Token: cfg.Token,
|
|
Metricas: string(metJSON),
|
|
}
|
|
body, _ := json.Marshal(payload)
|
|
|
|
url := cfg.APIURL + "/agent/heartbeat"
|
|
req, err := http.NewRequest("POST", url, bytes.NewReader(body))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("User-Agent", "usite-agent/1.0")
|
|
|
|
client := &http.Client{Timeout: 10 * time.Second}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("API respondió %d", resp.StatusCode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────────
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "agent.yml", "ruta al archivo de configuración")
|
|
apiURL := flag.String("api-url", "", "URL del API (sobreescribe config)")
|
|
token := flag.String("token", "", "token del servidor (sobreescribe config)")
|
|
flag.Parse()
|
|
|
|
cfg, err := loadConfig(*configPath)
|
|
if err != nil {
|
|
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}
|
|
}
|
|
|
|
if *apiURL != "" {
|
|
cfg.APIURL = *apiURL
|
|
}
|
|
if *token != "" {
|
|
cfg.Token = *token
|
|
}
|
|
|
|
if cfg.APIURL == "" || cfg.Token == "" {
|
|
log.Fatal("api_url y token son requeridos")
|
|
}
|
|
|
|
log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds", cfg.APIURL, cfg.Interval)
|
|
|
|
ticker := time.NewTicker(time.Duration(cfg.Interval) * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
reportar := func() {
|
|
m, err := collectMetrics()
|
|
if err != nil {
|
|
log.Printf("⚠️ Error recolectando métricas: %v", err)
|
|
return
|
|
}
|
|
if cfg.Debug {
|
|
b, _ := json.MarshalIndent(m, "", " ")
|
|
log.Printf("📊 Métricas:\n%s", string(b))
|
|
}
|
|
if err := sendHeartbeat(cfg, m); err != nil {
|
|
log.Printf("⚠️ Error enviando heartbeat: %v", err)
|
|
} else {
|
|
log.Printf("✅ Heartbeat | RAM: %.1f%% | CPU: %.1f%% | TCP: %d | Procs: %d",
|
|
m.RAM.Porcentaje, m.CPU.Porcentaje, m.TCPConns, len(m.TopProcs))
|
|
}
|
|
}
|
|
|
|
reportar()
|
|
for range ticker.C {
|
|
reportar()
|
|
}
|
|
}
|