feat: sistema de agente ligero para monitoreo de servidores
Backend: - Servidor model: AgentToken, AgentLastSeen, MetricasJson nuevos campos - AgentHeartbeat: endpoint público POST /agent/heartbeat (auth por token) - GenerateAgentToken: endpoint protegido POST /app/servidor/:id/agent-token - Ruta pública /agent/install.sh sirve el script de instalación Agente Go (agent/): - agent/main.go: binario independiente con gopsutil - Recolecta RAM, CPU, Disco, Uptime, OS, Load average - Lee agent.yml o flags --api-url / --token - Envía POST a /agent/heartbeat cada N segundos - Instala como servicio systemd via install.sh - agent/go.mod: módulo independiente (usite-agent) - agent/agent.yml.sample: configuración de ejemplo - agent/install.sh: instalador en 1 curl para Linux (systemd) Frontend servidor_dashboard.html: - Cards: badge ● En línea / ● Fuera / ○ Sin agente - Barras de progreso live: RAM%, CPU%, Disco% - Valores: GB usado/total, núcleos, uptime, OS real - Tiempo desde último reporte - Botón 'Agente' en cada card → modal de configuración - Modal agente: estado, token con copy, comando curl 1 línea, instalación manual/avanzada con collapse, métricas actuales Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot
parent
f52e266527
commit
d9d3f8a5eb
@@ -0,0 +1,14 @@
|
|||||||
|
# Configuración del agente de monitoreo u-site
|
||||||
|
# Copia este archivo como agent.yml y edítalo.
|
||||||
|
|
||||||
|
# URL del panel de administración (sin barra al final)
|
||||||
|
api_url: https://admin.u-site.app
|
||||||
|
|
||||||
|
# Token generado desde el panel en Servidor → Configurar Agente
|
||||||
|
token: PEGAR_TOKEN_AQUI
|
||||||
|
|
||||||
|
# Intervalo de reporte en segundos (mínimo recomendado: 30)
|
||||||
|
interval: 30
|
||||||
|
|
||||||
|
# Activar logs detallados (true para debug)
|
||||||
|
debug: false
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
module usite-agent
|
||||||
|
|
||||||
|
go 1.21
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.4
|
||||||
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 // indirect
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
||||||
|
github.com/tklauser/numcpus v0.6.1 // indirect
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
|
golang.org/x/sys v0.20.0 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
|
||||||
|
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
||||||
|
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
||||||
|
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU=
|
||||||
|
github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
|
||||||
|
github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
|
||||||
|
github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
|
||||||
|
github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
||||||
|
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
||||||
|
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
||||||
|
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — Instalador del agente usite-agent
|
||||||
|
# Uso:
|
||||||
|
# curl -sL https://admin.u-site.app/agent/install.sh | bash -s -- --token=TU_TOKEN --url=https://admin.u-site.app
|
||||||
|
# o descargando primero:
|
||||||
|
# bash install.sh --token=TU_TOKEN --url=https://admin.u-site.app [--interval=30] [--dir=/opt/usite-agent]
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ── Colores ───────────────────────────────────────────────────────────────────
|
||||||
|
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; NC='\033[0m'
|
||||||
|
info() { echo -e "${BLUE}[INFO]${NC} $*"; }
|
||||||
|
ok() { echo -e "${GREEN}[OK]${NC} $*"; }
|
||||||
|
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
|
||||||
|
error() { echo -e "${RED}[ERROR]${NC} $*"; exit 1; }
|
||||||
|
|
||||||
|
# ── Parámetros ────────────────────────────────────────────────────────────────
|
||||||
|
TOKEN=""
|
||||||
|
API_URL=""
|
||||||
|
INTERVAL=30
|
||||||
|
INSTALL_DIR="/opt/usite-agent"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case $arg in
|
||||||
|
--token=*) TOKEN="${arg#*=}" ;;
|
||||||
|
--url=*) API_URL="${arg#*=}" ;;
|
||||||
|
--interval=*) INTERVAL="${arg#*=}" ;;
|
||||||
|
--dir=*) INSTALL_DIR="${arg#*=}" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ -z "$TOKEN" ]] && error "Falta --token=TU_TOKEN"
|
||||||
|
[[ -z "$API_URL" ]] && error "Falta --url=https://admin.u-site.app"
|
||||||
|
|
||||||
|
# ── Detectar arquitectura ─────────────────────────────────────────────────────
|
||||||
|
OS="$(uname -s | tr '[:upper:]' '[:lower:]')"
|
||||||
|
ARCH="$(uname -m)"
|
||||||
|
case $ARCH in
|
||||||
|
x86_64) ARCH="amd64" ;;
|
||||||
|
aarch64) ARCH="arm64" ;;
|
||||||
|
armv7l) ARCH="arm" ;;
|
||||||
|
*) error "Arquitectura no soportada: $ARCH" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
BINARY_NAME="usite-agent-${OS}-${ARCH}"
|
||||||
|
DOWNLOAD_URL="${API_URL}/agent/download/${BINARY_NAME}"
|
||||||
|
|
||||||
|
info "Sistema: ${OS}/${ARCH}"
|
||||||
|
info "Directorio de instalación: ${INSTALL_DIR}"
|
||||||
|
info "Intervalo de reporte: ${INTERVAL}s"
|
||||||
|
|
||||||
|
# ── Crear directorio ──────────────────────────────────────────────────────────
|
||||||
|
mkdir -p "$INSTALL_DIR"
|
||||||
|
|
||||||
|
# ── Descargar binario ─────────────────────────────────────────────────────────
|
||||||
|
info "Descargando agente desde ${DOWNLOAD_URL}..."
|
||||||
|
if command -v curl &>/dev/null; then
|
||||||
|
curl -fsSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/usite-agent" || error "No se pudo descargar el binario"
|
||||||
|
elif command -v wget &>/dev/null; then
|
||||||
|
wget -q "$DOWNLOAD_URL" -O "${INSTALL_DIR}/usite-agent" || error "No se pudo descargar el binario"
|
||||||
|
else
|
||||||
|
error "Se requiere curl o wget para descargar el agente"
|
||||||
|
fi
|
||||||
|
chmod +x "${INSTALL_DIR}/usite-agent"
|
||||||
|
ok "Binario descargado y listo"
|
||||||
|
|
||||||
|
# ── Crear configuración ───────────────────────────────────────────────────────
|
||||||
|
cat > "${INSTALL_DIR}/agent.yml" <<EOF
|
||||||
|
api_url: ${API_URL}
|
||||||
|
token: ${TOKEN}
|
||||||
|
interval: ${INTERVAL}
|
||||||
|
debug: false
|
||||||
|
EOF
|
||||||
|
ok "Configuración creada en ${INSTALL_DIR}/agent.yml"
|
||||||
|
|
||||||
|
# ── Crear servicio systemd ────────────────────────────────────────────────────
|
||||||
|
if command -v systemctl &>/dev/null; then
|
||||||
|
cat > /etc/systemd/system/usite-agent.service <<EOF
|
||||||
|
[Unit]
|
||||||
|
Description=u-site Server Monitoring Agent
|
||||||
|
After=network.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
ExecStart=${INSTALL_DIR}/usite-agent --config=${INSTALL_DIR}/agent.yml
|
||||||
|
WorkingDirectory=${INSTALL_DIR}
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=usite-agent
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
EOF
|
||||||
|
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable usite-agent
|
||||||
|
systemctl start usite-agent
|
||||||
|
ok "Servicio systemd 'usite-agent' instalado y activo"
|
||||||
|
info "Comandos útiles:"
|
||||||
|
info " Ver estado: systemctl status usite-agent"
|
||||||
|
info " Ver logs: journalctl -u usite-agent -f"
|
||||||
|
info " Detener: systemctl stop usite-agent"
|
||||||
|
info " Desinstalar: systemctl disable usite-agent && rm /etc/systemd/system/usite-agent.service"
|
||||||
|
else
|
||||||
|
warn "systemd no disponible. Ejecuta manualmente:"
|
||||||
|
warn " ${INSTALL_DIR}/usite-agent --config=${INSTALL_DIR}/agent.yml &"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
ok "✅ usite-agent instalado correctamente"
|
||||||
|
info "El agente empezará a reportar métricas a ${API_URL} cada ${INTERVAL} segundos."
|
||||||
+260
@@ -0,0 +1,260 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"runtime"
|
||||||
|
"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"
|
||||||
|
"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)
|
||||||
|
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"`
|
||||||
|
CPU CPUInfo `json:"cpu"`
|
||||||
|
Disco DiscoInfo `json:"disco"`
|
||||||
|
LoadAvg LoadInfo `json:"load_avg"`
|
||||||
|
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 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"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if info, err := host.Info(); err == nil {
|
||||||
|
m.Uptime = info.Uptime
|
||||||
|
m.UptimeStr = uptimeStr(info.Uptime)
|
||||||
|
if info.OS != "" {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disco (raíz /)
|
||||||
|
root := "/"
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
root = "C:\\"
|
||||||
|
}
|
||||||
|
if d, err := disk.Usage(root); err == nil {
|
||||||
|
gb := 1024.0 * 1024 * 1024
|
||||||
|
m.Disco = DiscoInfo{
|
||||||
|
TotalGB: round2(float64(d.Total) / gb),
|
||||||
|
UsadoGB: round2(float64(d.Used) / gb),
|
||||||
|
LibreGB: round2(float64(d.Free) / gb),
|
||||||
|
Porcentaje: round2(d.UsedPercent),
|
||||||
|
Ruta: root,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load average (Linux/macOS)
|
||||||
|
if la, err := load.Avg(); err == nil {
|
||||||
|
m.LoadAvg = LoadInfo{Load1: round2(la.Load1), Load5: round2(la.Load5), Load15: round2(la.Load15)}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Enviar inmediatamente al iniciar
|
||||||
|
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 enviado | RAM: %.1f%% | CPU: %.1f%% | Disco: %.1f%%",
|
||||||
|
m.RAM.Porcentaje, m.CPU.Porcentaje, m.Disco.Porcentaje)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
reportar()
|
||||||
|
for range ticker.C {
|
||||||
|
reportar()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package models
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"log"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -21,6 +22,10 @@ type Servidor struct {
|
|||||||
ProvServidor ProvServidor `json:"prov_servidor" gorm:"foreignKey:ProvServidorID"`
|
ProvServidor ProvServidor `json:"prov_servidor" gorm:"foreignKey:ProvServidorID"`
|
||||||
TipoServidorID uint `json:"tipo_servidor_id" gorm:"column:tipo_servidor_id;foreignKey:TipoServidorID"`
|
TipoServidorID uint `json:"tipo_servidor_id" gorm:"column:tipo_servidor_id;foreignKey:TipoServidorID"`
|
||||||
TipoServidor TipoServidor `json:"tipo_servidor" gorm:"foreignKey:TipoServidorID"`
|
TipoServidor TipoServidor `json:"tipo_servidor" gorm:"foreignKey:TipoServidorID"`
|
||||||
|
// Campos del agente de monitoreo
|
||||||
|
AgentToken string `json:"agent_token" gorm:"column:agent_token;uniqueIndex"`
|
||||||
|
AgentLastSeen *time.Time `json:"agent_last_seen" gorm:"column:agent_last_seen"`
|
||||||
|
MetricasJson string `json:"metricas_json" gorm:"column:metricas_json;type:text"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (Servidor) TableName() string {
|
func (Servidor) TableName() string {
|
||||||
|
|||||||
@@ -36,61 +36,124 @@
|
|||||||
<h3 class="text-white font-bold text-base" x-text="servidor.nombre"></h3>
|
<h3 class="text-white font-bold text-base" x-text="servidor.nombre"></h3>
|
||||||
<p class="text-slate-300 text-xs mt-1 font-mono" x-text="servidor.ip_servidor"></p>
|
<p class="text-slate-300 text-xs mt-1 font-mono" x-text="servidor.ip_servidor"></p>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs px-2 py-1 rounded-full font-semibold"
|
<div class="flex flex-col items-end gap-1.5">
|
||||||
:class="servidor.tipo_servidor?.nombre ? 'bg-blue-500 text-white' : 'bg-slate-600 text-slate-300'"
|
<span class="text-xs px-2 py-1 rounded-full font-semibold"
|
||||||
x-text="servidor.tipo_servidor?.nombre || 'Servidor'"></span>
|
:class="servidor.tipo_servidor?.nombre ? 'bg-blue-500 text-white' : 'bg-slate-600 text-slate-300'"
|
||||||
|
x-text="servidor.tipo_servidor?.nombre || 'Servidor'"></span>
|
||||||
|
<!-- Badge estado agente -->
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full font-bold"
|
||||||
|
:class="agentOnline(servidor) ? 'bg-green-400 text-green-900' : (servidor.agent_token ? 'bg-red-400 text-red-900' : 'bg-slate-500 text-slate-200')">
|
||||||
|
<span x-show="agentOnline(servidor)">● En línea</span>
|
||||||
|
<span x-show="!agentOnline(servidor) && servidor.agent_token">● Fuera</span>
|
||||||
|
<span x-show="!servidor.agent_token">○ Sin agente</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Card Body -->
|
<!-- Card Body -->
|
||||||
<div class="p-4 space-y-3">
|
<div class="p-4 space-y-3">
|
||||||
<!-- Info grid -->
|
|
||||||
<div class="grid grid-cols-3 gap-2 text-xs">
|
|
||||||
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
|
||||||
<p class="text-slate-400 font-medium">RAM</p>
|
|
||||||
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.ram || '—'"></p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
|
||||||
<p class="text-slate-400 font-medium">CPU</p>
|
|
||||||
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.nucleos || '—'"></p>
|
|
||||||
</div>
|
|
||||||
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
|
||||||
<p class="text-slate-400 font-medium">Disco</p>
|
|
||||||
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.disco || '—'"></p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Provider & OS -->
|
<!-- Métricas live (si hay agente) -->
|
||||||
<div class="flex items-center justify-between text-xs text-slate-500">
|
<template x-if="servidor.metricas_json">
|
||||||
|
<div x-data="{ m: parseMetricas(servidor.metricas_json) }" class="space-y-2">
|
||||||
|
<!-- RAM -->
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-xs mb-1">
|
||||||
|
<span class="text-slate-500 font-medium">🧠 RAM</span>
|
||||||
|
<span class="font-mono font-bold text-slate-700"
|
||||||
|
x-text="m.ram?.usado_gb + ' / ' + m.ram?.total_gb + ' GB (' + m.ram?.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.ram?.porcentaje > 85 ? 'bg-red-500' : m.ram?.porcentaje > 65 ? 'bg-amber-400' : 'bg-green-500'"
|
||||||
|
:style="'width:' + Math.min(m.ram?.porcentaje, 100) + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- CPU -->
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-xs mb-1">
|
||||||
|
<span class="text-slate-500 font-medium">⚙️ CPU</span>
|
||||||
|
<span class="font-mono font-bold text-slate-700"
|
||||||
|
x-text="m.cpu?.porcentaje + '% · ' + m.cpu?.nucleos + ' núcleos'"></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.cpu?.porcentaje > 85 ? 'bg-red-500' : m.cpu?.porcentaje > 65 ? 'bg-amber-400' : 'bg-blue-500'"
|
||||||
|
:style="'width:' + Math.min(m.cpu?.porcentaje, 100) + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Disco -->
|
||||||
|
<div>
|
||||||
|
<div class="flex justify-between text-xs mb-1">
|
||||||
|
<span class="text-slate-500 font-medium">💾 Disco</span>
|
||||||
|
<span class="font-mono font-bold text-slate-700"
|
||||||
|
x-text="m.disco?.usado_gb + ' / ' + m.disco?.total_gb + ' GB (' + m.disco?.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.disco?.porcentaje > 85 ? 'bg-red-500' : m.disco?.porcentaje > 65 ? 'bg-amber-400' : 'bg-indigo-500'"
|
||||||
|
:style="'width:' + Math.min(m.disco?.porcentaje, 100) + '%'"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 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>
|
||||||
|
<span x-text="m.os || servidor.so || '—'"></span>
|
||||||
|
</div>
|
||||||
|
<!-- Último reporte -->
|
||||||
|
<div class="text-xs text-slate-400 text-right">
|
||||||
|
Actualizado <span x-text="tiempoDesde(servidor.agent_last_seen)"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Sin métricas (agente no instalado) -->
|
||||||
|
<template x-if="!servidor.metricas_json">
|
||||||
|
<div class="grid grid-cols-3 gap-2 text-xs">
|
||||||
|
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
||||||
|
<p class="text-slate-400 font-medium">RAM</p>
|
||||||
|
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.ram || '—'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
||||||
|
<p class="text-slate-400 font-medium">CPU</p>
|
||||||
|
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.nucleos || '—'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-slate-50 rounded-lg p-2 text-center">
|
||||||
|
<p class="text-slate-400 font-medium">Disco</p>
|
||||||
|
<p class="text-slate-800 font-bold mt-0.5" x-text="servidor.disco || '—'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Provider & Conexiones -->
|
||||||
|
<div class="flex items-center justify-between text-xs text-slate-500 pt-1">
|
||||||
<span x-text="servidor.prov_servidor?.nombre || 'Sin proveedor'"></span>
|
<span x-text="servidor.prov_servidor?.nombre || 'Sin proveedor'"></span>
|
||||||
<span x-text="servidor.so || 'Sin OS'"></span>
|
<span>
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Ultimo ping -->
|
|
||||||
<div x-show="servidor.ultimo_ping" class="text-xs text-slate-400">
|
|
||||||
Último ping: <span class="font-mono" x-text="servidor.ultimo_ping"></span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Conexiones count -->
|
|
||||||
<div class="flex items-center gap-2 pt-1">
|
|
||||||
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582 4 8 4"/>
|
|
||||||
</svg>
|
|
||||||
<span class="text-sm text-slate-600">
|
|
||||||
<span class="font-bold text-blue-600" x-text="servidor.conx_count ?? servidor._conexiones?.length ?? 0"></span>
|
<span class="font-bold text-blue-600" x-text="servidor.conx_count ?? servidor._conexiones?.length ?? 0"></span>
|
||||||
conexiones BD
|
BD
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action Button -->
|
<!-- Action Buttons -->
|
||||||
<button @click="abrirModal(servidor)"
|
<div class="flex gap-2 mt-2">
|
||||||
class="w-full px-4 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 active:bg-blue-800 transition-colors flex items-center justify-center gap-2 text-sm mt-2">
|
<button @click="abrirModal(servidor)"
|
||||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
class="flex-1 px-3 py-2 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 transition-colors flex items-center justify-center gap-1.5 text-sm">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
|
||||||
Ver Detalles y Conexiones
|
</svg>
|
||||||
</button>
|
Ver
|
||||||
|
</button>
|
||||||
|
<button @click="abrirAgentModal(servidor)" title="Configurar agente de monitoreo"
|
||||||
|
class="px-3 py-2 rounded-lg font-medium transition-colors text-sm border flex items-center gap-1"
|
||||||
|
:class="agentOnline(servidor) ? 'border-green-300 text-green-700 hover:bg-green-50' : 'border-slate-300 text-slate-600 hover:bg-slate-50'">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 3H5a2 2 0 00-2 2v4m6-6h10a2 2 0 012 2v4M9 3v18m0 0h10a2 2 0 002-2V9M9 21H5a2 2 0 01-2-2V9m0 0h18"/>
|
||||||
|
</svg>
|
||||||
|
Agente
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -274,11 +337,145 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ===== Modal Agente ===== -->
|
||||||
|
<div x-show="agentModalServidor" x-cloak
|
||||||
|
class="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4"
|
||||||
|
@click.self="agentModalServidor = null">
|
||||||
|
<div class="bg-white rounded-xl w-full max-w-2xl shadow-2xl overflow-hidden" x-data>
|
||||||
|
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-gradient-to-r from-emerald-600 to-teal-700 p-5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-white font-bold text-lg">⚡ Agente de Monitoreo</h2>
|
||||||
|
<p class="text-emerald-100 text-sm mt-0.5" x-text="agentModalServidor?.nombre"></p>
|
||||||
|
</div>
|
||||||
|
<button @click="agentModalServidor = null" class="text-emerald-200 hover:text-white p-1.5 rounded-lg hover:bg-white/10">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6 space-y-5 max-h-[75vh] overflow-y-auto">
|
||||||
|
|
||||||
|
<!-- Estado actual -->
|
||||||
|
<div class="rounded-xl border p-4"
|
||||||
|
:class="agentOnline(agentModalServidor) ? 'bg-green-50 border-green-200' : (agentModalServidor?.agent_token ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200')">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<div class="w-3 h-3 rounded-full animate-pulse"
|
||||||
|
:class="agentOnline(agentModalServidor) ? 'bg-green-500' : (agentModalServidor?.agent_token ? 'bg-amber-400' : 'bg-slate-400')"></div>
|
||||||
|
<div>
|
||||||
|
<p class="font-bold text-sm"
|
||||||
|
:class="agentOnline(agentModalServidor) ? 'text-green-800' : (agentModalServidor?.agent_token ? 'text-amber-800' : 'text-slate-700')">
|
||||||
|
<span x-show="agentOnline(agentModalServidor)">✅ Agente en línea</span>
|
||||||
|
<span x-show="!agentOnline(agentModalServidor) && agentModalServidor?.agent_token">⚠️ Agente instalado pero sin reportes recientes</span>
|
||||||
|
<span x-show="!agentModalServidor?.agent_token">🔧 Agente no configurado</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs mt-0.5"
|
||||||
|
:class="agentOnline(agentModalServidor) ? 'text-green-600' : 'text-slate-500'"
|
||||||
|
x-text="agentModalServidor?.agent_last_seen ? 'Último reporte: ' + tiempoDesde(agentModalServidor.agent_last_seen) : 'Nunca ha reportado'">
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Métricas actuales si está online -->
|
||||||
|
<template x-if="agentModalServidor?.metricas_json">
|
||||||
|
<div x-data="{ m: parseMetricas(agentModalServidor?.metricas_json) }" class="mt-3 grid grid-cols-3 gap-3 text-xs">
|
||||||
|
<div class="bg-white rounded-lg p-2 text-center border">
|
||||||
|
<p class="text-slate-400">RAM</p>
|
||||||
|
<p class="font-bold text-slate-800" x-text="m.ram?.porcentaje + '%'"></p>
|
||||||
|
<p class="text-slate-500" x-text="m.ram?.usado_gb + '/' + m.ram?.total_gb + 'GB'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg p-2 text-center border">
|
||||||
|
<p class="text-slate-400">CPU</p>
|
||||||
|
<p class="font-bold text-slate-800" x-text="m.cpu?.porcentaje + '%'"></p>
|
||||||
|
<p class="text-slate-500" x-text="m.cpu?.nucleos + ' núcleos'"></p>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white rounded-lg p-2 text-center border">
|
||||||
|
<p class="text-slate-400">Disco</p>
|
||||||
|
<p class="font-bold text-slate-800" x-text="m.disco?.porcentaje + '%'"></p>
|
||||||
|
<p class="text-slate-500" x-text="m.disco?.usado_gb + '/' + m.disco?.total_gb + 'GB'"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Token section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h3 class="font-bold text-slate-700 text-sm">🔑 Token del servidor</h3>
|
||||||
|
<button @click="generarToken()" :disabled="agentTokenCargando"
|
||||||
|
class="text-xs px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors">
|
||||||
|
<span x-show="!agentTokenCargando" x-text="agentModalServidor?.agent_token ? '🔄 Regenerar' : '✨ Generar Token'"></span>
|
||||||
|
<span x-show="agentTokenCargando">Generando...</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<template x-if="agentModalServidor?.agent_token">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<code class="flex-1 bg-slate-100 rounded-lg px-3 py-2 text-xs font-mono text-slate-700 break-all"
|
||||||
|
x-text="agentModalServidor.agent_token"></code>
|
||||||
|
<button @click="copiar(agentModalServidor.agent_token)" title="Copiar token"
|
||||||
|
class="p-2 rounded-lg bg-slate-100 hover:bg-slate-200 text-slate-600 transition-colors shrink-0">
|
||||||
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!agentModalServidor?.agent_token">
|
||||||
|
<p class="text-xs text-slate-400 bg-slate-50 rounded-lg px-3 py-2">Genera un token para poder instalar el agente en este servidor.</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Comando de instalación -->
|
||||||
|
<template x-if="agentModalServidor?.agent_token">
|
||||||
|
<div>
|
||||||
|
<h3 class="font-bold text-slate-700 text-sm mb-2">📦 Instalación en 1 comando (Linux)</h3>
|
||||||
|
<div class="bg-slate-900 rounded-xl p-4 relative group">
|
||||||
|
<pre class="text-green-400 text-xs font-mono whitespace-pre-wrap break-all leading-relaxed"
|
||||||
|
x-text="`curl -sL https://admin.u-site.app/agent/install.sh | bash -s -- \\\n --token=${agentModalServidor.agent_token} \\\n --url=https://admin.u-site.app \\\n --interval=30`"></pre>
|
||||||
|
<button @click="copiar(`curl -sL https://admin.u-site.app/agent/install.sh | bash -s -- --token=${agentModalServidor.agent_token} --url=https://admin.u-site.app --interval=30`)"
|
||||||
|
class="absolute top-3 right-3 opacity-0 group-hover:opacity-100 transition-opacity bg-slate-700 hover:bg-slate-600 text-slate-300 rounded-lg p-1.5">
|
||||||
|
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-slate-400 mt-2">⚡ El script descarga el binario, crea el archivo de configuración e instala el servicio systemd automáticamente.</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Pasos manuales -->
|
||||||
|
<template x-if="agentModalServidor?.agent_token">
|
||||||
|
<details class="group">
|
||||||
|
<summary class="cursor-pointer text-sm font-bold text-slate-600 hover:text-slate-800 select-none flex items-center gap-2">
|
||||||
|
<svg class="w-4 h-4 transition-transform group-open: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>
|
||||||
|
Instalación manual / configuración avanzada
|
||||||
|
</summary>
|
||||||
|
<div class="mt-3 space-y-3 pl-6 text-xs text-slate-600">
|
||||||
|
<p><span class="font-bold">1.</span> Descarga el binario para tu arquitectura desde el panel</p>
|
||||||
|
<p><span class="font-bold">2.</span> Crea el archivo <code class="bg-slate-100 px-1 rounded">agent.yml</code>:</p>
|
||||||
|
<pre class="bg-slate-900 text-green-400 rounded-lg p-3 text-xs font-mono"
|
||||||
|
x-text="`api_url: https://admin.u-site.app\ntoken: ${agentModalServidor.agent_token}\ninterval: 30\ndebug: false`"></pre>
|
||||||
|
<p><span class="font-bold">3.</span> Ejecuta: <code class="bg-slate-100 px-1 rounded">./usite-agent --config=agent.yml</code></p>
|
||||||
|
<p><span class="font-bold">Ver logs:</span> <code class="bg-slate-100 px-1 rounded">journalctl -u usite-agent -f</code></p>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function servidorDashboard() {
|
function servidorDashboard() {
|
||||||
return {
|
return {
|
||||||
servidores: [],
|
servidores: [],
|
||||||
servidorSeleccionado: null,
|
servidorSeleccionado: null,
|
||||||
|
agentModalServidor: null,
|
||||||
|
agentTokenCargando: false,
|
||||||
cargando: true,
|
cargando: true,
|
||||||
cargandoConexiones: false,
|
cargandoConexiones: false,
|
||||||
pingCargando: {},
|
pingCargando: {},
|
||||||
@@ -311,7 +508,6 @@
|
|||||||
const r = await fetch(`/app/servidor-dashboard/${servidor.ID}`);
|
const r = await fetch(`/app/servidor-dashboard/${servidor.ID}`);
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
this.servidorSeleccionado._conexiones = data.conexiones || [];
|
this.servidorSeleccionado._conexiones = data.conexiones || [];
|
||||||
// Actualizar también el contador en la card
|
|
||||||
const idx = this.servidores.findIndex(s => s.ID === servidor.ID);
|
const idx = this.servidores.findIndex(s => s.ID === servidor.ID);
|
||||||
if (idx >= 0) this.servidores[idx]._conexiones = data.conexiones || [];
|
if (idx >= 0) this.servidores[idx]._conexiones = data.conexiones || [];
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -326,12 +522,61 @@
|
|||||||
this.servidorSeleccionado = null;
|
this.servidorSeleccionado = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
abrirAgentModal(servidor) {
|
||||||
|
// Buscar el servidor actualizado en la lista
|
||||||
|
const s = this.servidores.find(sv => sv.ID === servidor.ID) || servidor;
|
||||||
|
this.agentModalServidor = { ...s };
|
||||||
|
},
|
||||||
|
|
||||||
|
async generarToken() {
|
||||||
|
if (!this.agentModalServidor) return;
|
||||||
|
this.agentTokenCargando = true;
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/app/servidor/${this.agentModalServidor.ID}/agent-token`, { method: 'POST' });
|
||||||
|
const data = await r.json();
|
||||||
|
if (data.ok) {
|
||||||
|
this.agentModalServidor.agent_token = data.agent_token;
|
||||||
|
// Actualizar también en la lista de cards
|
||||||
|
const idx = this.servidores.findIndex(s => s.ID === this.agentModalServidor.ID);
|
||||||
|
if (idx >= 0) this.servidores[idx].agent_token = data.agent_token;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error generando token:', e);
|
||||||
|
} finally {
|
||||||
|
this.agentTokenCargando = false;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
agentOnline(servidor) {
|
||||||
|
if (!servidor?.agent_last_seen) return false;
|
||||||
|
const last = new Date(servidor.agent_last_seen);
|
||||||
|
const diffMs = Date.now() - last.getTime();
|
||||||
|
return diffMs < 3 * 60 * 1000; // online si reportó en últimos 3 minutos
|
||||||
|
},
|
||||||
|
|
||||||
|
tiempoDesde(isoStr) {
|
||||||
|
if (!isoStr) return '—';
|
||||||
|
const diff = Math.floor((Date.now() - new Date(isoStr).getTime()) / 1000);
|
||||||
|
if (diff < 60) return 'hace ' + diff + 's';
|
||||||
|
if (diff < 3600) return 'hace ' + Math.floor(diff / 60) + 'm';
|
||||||
|
if (diff < 86400) return 'hace ' + Math.floor(diff / 3600) + 'h';
|
||||||
|
return 'hace ' + Math.floor(diff / 86400) + 'd';
|
||||||
|
},
|
||||||
|
|
||||||
|
parseMetricas(jsonStr) {
|
||||||
|
try { return JSON.parse(jsonStr) || {}; }
|
||||||
|
catch { return {}; }
|
||||||
|
},
|
||||||
|
|
||||||
|
copiar(texto) {
|
||||||
|
navigator.clipboard.writeText(texto).catch(() => {});
|
||||||
|
},
|
||||||
|
|
||||||
async hacerPing(cx) {
|
async hacerPing(cx) {
|
||||||
const id = cx.ID || cx.id;
|
const id = cx.ID || cx.id;
|
||||||
if (!id) return;
|
if (!id) return;
|
||||||
// Usar mutación directa para garantizar reactividad en Alpine.js
|
|
||||||
this.pingCargando[id] = true;
|
this.pingCargando[id] = true;
|
||||||
this.pingResultado[id] = undefined; // resetear resultado previo
|
this.pingResultado[id] = undefined;
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`/app/conx-ping/${id}`);
|
const r = await fetch(`/app/conx-ping/${id}`);
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||||
|
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentHeartbeat recibe métricas del agente instalado en un servidor.
|
||||||
|
// Ruta pública: POST /agent/heartbeat — autenticada por token en JSON body.
|
||||||
|
func AgentHeartbeat(c *fiber.Ctx) error {
|
||||||
|
type HeartbeatRequest struct {
|
||||||
|
Token string `json:"token"`
|
||||||
|
Metricas string `json:"metricas"` // JSON string ya serializado por el agente
|
||||||
|
}
|
||||||
|
|
||||||
|
var req HeartbeatRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"ok": false, "error": "body inválido"})
|
||||||
|
}
|
||||||
|
if req.Token == "" {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "token requerido"})
|
||||||
|
}
|
||||||
|
|
||||||
|
db := app.Http.Database.DB
|
||||||
|
var servidor models.Servidor
|
||||||
|
if err := db.Where("agent_token = ?", req.Token).First(&servidor).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{"ok": false, "error": "token inválido"})
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"agent_last_seen": &now,
|
||||||
|
}
|
||||||
|
if req.Metricas != "" {
|
||||||
|
updates["metricas_json"] = req.Metricas
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Model(&servidor).Updates(updates).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": "no se pudo guardar"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{"ok": true, "servidor_id": servidor.ID})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateAgentToken genera o regenera el token de agente para un servidor.
|
||||||
|
// Ruta protegida: POST /app/servidor/:id/agent-token
|
||||||
|
func GenerateAgentToken(c *fiber.Ctx) error {
|
||||||
|
id := c.Params("id")
|
||||||
|
db := app.Http.Database.DB
|
||||||
|
|
||||||
|
var servidor models.Servidor
|
||||||
|
if err := db.First(&servidor, id).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "servidor no encontrado"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generar token aleatorio de 32 bytes (64 hex chars)
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo generar token"})
|
||||||
|
}
|
||||||
|
token := hex.EncodeToString(tokenBytes)
|
||||||
|
|
||||||
|
if err := db.Model(&servidor).Update("agent_token", token).Error; err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar token"})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"ok": true,
|
||||||
|
"agent_token": token,
|
||||||
|
"servidor_id": servidor.ID,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -34,7 +34,15 @@ func RutasPublicas(web fiber.Router) {
|
|||||||
// ─── Webhook de Telegram para vinculación del portal ──────────────────────
|
// ─── Webhook de Telegram para vinculación del portal ──────────────────────
|
||||||
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal
|
// Configurar en el bot: POST https://api.telegram.org/bot{TOKEN}/setWebhook?url={HOST}/webhooks/telegram-portal
|
||||||
web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook)
|
web.Post("/webhooks/telegram-portal", controllers.TelegramPortalWebhook)
|
||||||
|
|
||||||
|
// ─── Agente de monitoreo de servidores ────────────────────────────────────
|
||||||
|
// El agente instalado en cada servidor reporta métricas aquí (sin sesión, auth por token).
|
||||||
|
web.Post("/agent/heartbeat", controllers.AgentHeartbeat)
|
||||||
|
// Servir el script de instalación del agente
|
||||||
|
web.Get("/agent/install.sh", func(c *fiber.Ctx) error {
|
||||||
|
c.Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
|
return c.SendFile("./agent/install.sh")
|
||||||
|
})
|
||||||
// ─── Página de estado del sistema (Atlassian Statuspage) ────────────────
|
// ─── Página de estado del sistema (Atlassian Statuspage) ────────────────
|
||||||
web.Get("/status", controllers.StatusPage)
|
web.Get("/status", controllers.StatusPage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ func UserRoutes(app fiber.Router) {
|
|||||||
protected.Delete("/servidor/:id", controllers.DeleteServidor)
|
protected.Delete("/servidor/:id", controllers.DeleteServidor)
|
||||||
protected.Get("/servidor-dashboard/:id", controllers.GetServidorDashboard)
|
protected.Get("/servidor-dashboard/:id", controllers.GetServidorDashboard)
|
||||||
protected.Get("/conx-ping/:id", controllers.PingConexion)
|
protected.Get("/conx-ping/:id", controllers.PingConexion)
|
||||||
|
protected.Post("/servidor/:id/agent-token", controllers.GenerateAgentToken)
|
||||||
|
|
||||||
// Rutas de proveedores de servidor
|
// Rutas de proveedores de servidor
|
||||||
protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor)
|
protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor)
|
||||||
|
|||||||
Reference in New Issue
Block a user