Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4d3e1f03d | ||
|
|
80c29bf0d3 |
+11
-46
@@ -18,19 +18,13 @@ TOKEN=""
|
||||
API_URL=""
|
||||
INTERVAL=30
|
||||
INSTALL_DIR=""
|
||||
CPU_THRESHOLD=90
|
||||
RENICE_ENABLED=true
|
||||
RENICE_CONSECUTIVE=2
|
||||
|
||||
for arg in "$@"; do
|
||||
case $arg in
|
||||
--token=*) TOKEN="${arg#*=}" ;;
|
||||
--url=*) API_URL="${arg#*=}" ;;
|
||||
--interval=*) INTERVAL="${arg#*=}" ;;
|
||||
--dir=*) INSTALL_DIR="${arg#*=}" ;;
|
||||
--cpu-threshold=*) CPU_THRESHOLD="${arg#*=}" ;;
|
||||
--renice=*) RENICE_ENABLED="${arg#*=}" ;;
|
||||
--renice-consecutive=*) RENICE_CONSECUTIVE="${arg#*=}" ;;
|
||||
--token=*) TOKEN="${arg#*=}" ;;
|
||||
--url=*) API_URL="${arg#*=}" ;;
|
||||
--interval=*) INTERVAL="${arg#*=}" ;;
|
||||
--dir=*) INSTALL_DIR="${arg#*=}" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
@@ -80,38 +74,17 @@ info "Directorio: ${INSTALL_DIR}"
|
||||
info "Intervalo: ${INTERVAL}s"
|
||||
|
||||
# ── Instalar binario ──────────────────────────────────────────────────────────
|
||||
mkdir -p "$INSTALL_DIR" || error "No se pudo crear directorio ${INSTALL_DIR} (¿permisos?)"
|
||||
|
||||
# Verificar espacio disponible (necesita al menos 20 MB)
|
||||
AVAIL_KB=$(df -k "$INSTALL_DIR" 2>/dev/null | awk 'NR==2 {print $4}')
|
||||
if [[ -n "$AVAIL_KB" && "$AVAIL_KB" -lt 20480 ]]; then
|
||||
error "Espacio insuficiente en ${INSTALL_DIR}: solo ${AVAIL_KB} KB disponibles (se necesitan ≥20 MB)"
|
||||
fi
|
||||
|
||||
mkdir -p "$INSTALL_DIR"
|
||||
info "Descargando agente desde ${DOWNLOAD_URL}..."
|
||||
DEST="${INSTALL_DIR}/usite-agent"
|
||||
TMP_DEST="/tmp/usite-agent-download-$$"
|
||||
|
||||
if command -v curl &>/dev/null; then
|
||||
curl -fsSL "$DOWNLOAD_URL" -o "$TMP_DEST" 2>/tmp/curl_err || {
|
||||
CURL_ERR=$(cat /tmp/curl_err 2>/dev/null)
|
||||
rm -f "$TMP_DEST"
|
||||
error "No se pudo descargar: ${CURL_ERR}"
|
||||
}
|
||||
curl -fsSL "$DOWNLOAD_URL" -o "${INSTALL_DIR}/usite-agent" || error "No se pudo descargar"
|
||||
elif command -v wget &>/dev/null; then
|
||||
wget -q "$DOWNLOAD_URL" -O "$TMP_DEST" || { rm -f "$TMP_DEST"; error "No se pudo descargar con wget"; }
|
||||
wget -q "$DOWNLOAD_URL" -O "${INSTALL_DIR}/usite-agent" || error "No se pudo descargar"
|
||||
else
|
||||
error "Se requiere curl o wget"
|
||||
fi
|
||||
|
||||
if [[ ! -s "$TMP_DEST" ]]; then
|
||||
rm -f "$TMP_DEST"
|
||||
error "El archivo descargado está vacío"
|
||||
fi
|
||||
|
||||
mv "$TMP_DEST" "$DEST" || { cp "$TMP_DEST" "$DEST" && rm -f "$TMP_DEST"; } || error "No se pudo mover el binario a ${DEST}"
|
||||
chmod +x "$DEST"
|
||||
ok "Binario descargado ($(du -sh "$DEST" | cut -f1))"
|
||||
chmod +x "${INSTALL_DIR}/usite-agent"
|
||||
ok "Binario descargado"
|
||||
|
||||
# ── Configuración ─────────────────────────────────────────────────────────────
|
||||
cat > "${INSTALL_DIR}/agent.yml" <<EOF
|
||||
@@ -119,9 +92,6 @@ api_url: ${API_URL}
|
||||
token: ${TOKEN}
|
||||
interval: ${INTERVAL}
|
||||
debug: false
|
||||
cpu_threshold: ${CPU_THRESHOLD}
|
||||
renice_enabled: ${RENICE_ENABLED}
|
||||
renice_consecutive: ${RENICE_CONSECUTIVE}
|
||||
EOF
|
||||
ok "Configuración creada en ${INSTALL_DIR}/agent.yml"
|
||||
|
||||
@@ -151,13 +121,8 @@ WantedBy=multi-user.target
|
||||
EOF
|
||||
systemctl daemon-reload
|
||||
systemctl enable usite-agent
|
||||
if systemctl is-active --quiet usite-agent; then
|
||||
systemctl restart usite-agent
|
||||
ok "Servicio systemd 'usite-agent' reiniciado con nueva configuración"
|
||||
else
|
||||
systemctl start usite-agent
|
||||
ok "Servicio systemd 'usite-agent' instalado y activo"
|
||||
fi
|
||||
systemctl start usite-agent
|
||||
ok "Servicio systemd 'usite-agent' instalado y activo"
|
||||
STARTED=1
|
||||
|
||||
# 2) openrc (Alpine, algunos VPS)
|
||||
|
||||
+63
-197
@@ -8,9 +8,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/shirou/gopsutil/v3/cpu"
|
||||
@@ -18,21 +16,16 @@ 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"`
|
||||
Token string `yaml:"token"`
|
||||
Interval int `yaml:"interval"`
|
||||
Debug bool `yaml:"debug"`
|
||||
CPUThreshold float64 `yaml:"cpu_threshold"` // % para activar renice (default 90)
|
||||
ReniceEnabled bool `yaml:"renice_enabled"` // activar renice automático
|
||||
ReniceConsecutive int `yaml:"renice_consecutive"` // reportes consecutivos requeridos (default 2)
|
||||
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) {
|
||||
@@ -47,32 +40,22 @@ func loadConfig(path string) (*Config, error) {
|
||||
if cfg.Interval <= 0 {
|
||||
cfg.Interval = 30
|
||||
}
|
||||
if cfg.CPUThreshold <= 0 {
|
||||
cfg.CPUThreshold = 90
|
||||
}
|
||||
if cfg.ReniceConsecutive <= 0 {
|
||||
cfg.ReniceConsecutive = 2
|
||||
}
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
|
||||
type RAMInfo struct {
|
||||
@@ -82,12 +65,6 @@ 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"`
|
||||
@@ -107,21 +84,6 @@ 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
|
||||
}
|
||||
@@ -148,11 +110,11 @@ func collectMetrics() (*Metricas, error) {
|
||||
m.Hostname = hn
|
||||
}
|
||||
|
||||
// Uptime + OS
|
||||
// Uptime
|
||||
if info, err := host.Info(); err == nil {
|
||||
m.Uptime = info.Uptime
|
||||
m.UptimeStr = uptimeStr(info.Uptime)
|
||||
if info.Platform != "" {
|
||||
if info.OS != "" {
|
||||
m.OS = info.Platform + " " + info.PlatformVersion
|
||||
}
|
||||
}
|
||||
@@ -168,149 +130,80 @@ 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 — incluir todo filesystem con espacio real (>100 MB), sin duplicar mountpoints
|
||||
{
|
||||
// Discos: todas las particiones físicas/reales
|
||||
if parts, err := disk.Partitions(false); err == nil {
|
||||
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:\\", ""}}
|
||||
skipFSTypes := 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,
|
||||
"debugfs": true, "tracefs": true, "autofs": true, "fusectl": true,
|
||||
"configfs": true, "efivarfs": true, "bpf": true, "rpc_pipefs": true,
|
||||
"squashfs": true, "ramfs": true, "nsfs": true, "none": true,
|
||||
}
|
||||
for _, p := range parts {
|
||||
entries = append(entries, partEntry{p.Mountpoint, p.Fstype})
|
||||
if skipFSTypes[p.Fstype] {
|
||||
continue
|
||||
}
|
||||
if d, err := disk.Usage(p.Mountpoint); err == nil {
|
||||
m.Discos = append(m.Discos, DiscoInfo{
|
||||
TotalGB: round2(float64(d.Total) / gb),
|
||||
UsadoGB: round2(float64(d.Used) / gb),
|
||||
LibreGB: round2(float64(d.Free) / gb),
|
||||
Porcentaje: round2(d.UsedPercent),
|
||||
Ruta: p.Mountpoint,
|
||||
})
|
||||
}
|
||||
}
|
||||
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),
|
||||
}
|
||||
// Fallback: si no se encontraron particiones, usar raíz
|
||||
if len(m.Discos) == 0 {
|
||||
root := "/"
|
||||
if runtime.GOOS == "windows" {
|
||||
root = "C:\\"
|
||||
}
|
||||
if d, err := disk.Usage(root); err == nil {
|
||||
gb := 1024.0 * 1024 * 1024
|
||||
m.Discos = []DiscoInfo{{
|
||||
TotalGB: round2(float64(d.Total) / gb),
|
||||
UsadoGB: round2(float64(d.Used) / gb),
|
||||
LibreGB: round2(float64(d.Free) / gb),
|
||||
Porcentaje: round2(d.UsedPercent),
|
||||
Ruta: e.mountpoint,
|
||||
})
|
||||
Ruta: root,
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
// Load average
|
||||
// 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)}
|
||||
}
|
||||
|
||||
// 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"`
|
||||
ReniceAction string `json:"renice_action,omitempty"` // descripción si se reniceó algo
|
||||
Token string `json:"token"`
|
||||
Metricas string `json:"metricas"`
|
||||
}
|
||||
|
||||
func sendHeartbeat(cfg *Config, metricas *Metricas, reniceAction string) error {
|
||||
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),
|
||||
ReniceAction: reniceAction,
|
||||
Token: cfg.Token,
|
||||
Metricas: string(metJSON),
|
||||
}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
@@ -345,12 +238,14 @@ 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
|
||||
}
|
||||
@@ -362,14 +257,12 @@ func main() {
|
||||
log.Fatal("api_url y token son requeridos")
|
||||
}
|
||||
|
||||
log.Printf("🚀 usite-agent iniciado | API: %s | Intervalo: %ds | Renice: %v (umbral %.0f%%, %d reportes)",
|
||||
cfg.APIURL, cfg.Interval, cfg.ReniceEnabled, cfg.CPUThreshold, cfg.ReniceConsecutive)
|
||||
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()
|
||||
|
||||
consecAlto := 0 // contador de reportes consecutivos con CPU alta
|
||||
|
||||
// Enviar inmediatamente al iniciar
|
||||
reportar := func() {
|
||||
m, err := collectMetrics()
|
||||
if err != nil {
|
||||
@@ -380,38 +273,11 @@ func main() {
|
||||
b, _ := json.MarshalIndent(m, "", " ")
|
||||
log.Printf("📊 Métricas:\n%s", string(b))
|
||||
}
|
||||
|
||||
// ── Lógica de renice ──────────────────────────────────────────────────
|
||||
var reniceAction string
|
||||
if cfg.ReniceEnabled && m.CPU.Porcentaje >= cfg.CPUThreshold {
|
||||
consecAlto++
|
||||
log.Printf("⚠️ CPU alta: %.1f%% (reporte %d/%d)", m.CPU.Porcentaje, consecAlto, cfg.ReniceConsecutive)
|
||||
if consecAlto >= cfg.ReniceConsecutive && len(m.TopProcs) > 0 {
|
||||
// Ordenar top_procs por CPU para encontrar el culpable real
|
||||
procs := make([]ProcesoInfo, len(m.TopProcs))
|
||||
copy(procs, m.TopProcs)
|
||||
sort.Slice(procs, func(i, j int) bool { return procs[i].CPU > procs[j].CPU })
|
||||
culpable := procs[0]
|
||||
if culpable.CPU > 0 && culpable.PID > 0 {
|
||||
cmd := exec.Command("renice", "-n", "19", "-p", fmt.Sprintf("%d", culpable.PID))
|
||||
if rerr := cmd.Run(); rerr != nil {
|
||||
log.Printf("❌ renice falló para PID %d (%s): %v", culpable.PID, culpable.Nombre, rerr)
|
||||
} else {
|
||||
reniceAction = fmt.Sprintf("renice +19 aplicado a %s (PID %d, CPU %.1f%%)", culpable.Nombre, culpable.PID, culpable.CPU)
|
||||
log.Printf("🔧 %s", reniceAction)
|
||||
consecAlto = 0 // reiniciar tras actuar
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
consecAlto = 0
|
||||
}
|
||||
|
||||
if err := sendHeartbeat(cfg, m, reniceAction); err != nil {
|
||||
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))
|
||||
log.Printf("✅ Heartbeat enviado | RAM: %.1f%% | CPU: %.1f%% | Discos: %d",
|
||||
m.RAM.Porcentaje, m.CPU.Porcentaje, len(m.Discos))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -64,7 +64,7 @@ jwt:
|
||||
expire: 31536000
|
||||
|
||||
template:
|
||||
path: "resources/views"
|
||||
path: "resources/view"
|
||||
extension: ".html"
|
||||
|
||||
log:
|
||||
|
||||
@@ -96,11 +96,6 @@ func Migrate() {
|
||||
&models.VcardApiConfig{},
|
||||
// Integración Coolify
|
||||
&models.CoolifyConfig{},
|
||||
// Historial de métricas del agente
|
||||
&models.ServidorMetricasHistory{},
|
||||
// Monitor de disponibilidad de URLs
|
||||
&models.UrlMonitor{},
|
||||
&models.UrlMonitorLog{},
|
||||
); err != nil {
|
||||
log.Fatalf("Error during main migration: %v", err)
|
||||
}
|
||||
|
||||
@@ -103,49 +103,3 @@ func DeleteServidor(servidor Servidor) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── Historial de métricas ─────────────────────────────────────────────────────
|
||||
|
||||
type ServidorMetricasHistory struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
ServidorID uint `json:"servidor_id" gorm:"column:servidor_id;index"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
|
||||
CpuPct float64 `json:"cpu_pct" gorm:"column:cpu_pct"`
|
||||
RamPct float64 `json:"ram_pct" gorm:"column:ram_pct"`
|
||||
SwapPct float64 `json:"swap_pct" gorm:"column:swap_pct"`
|
||||
DiscoPct float64 `json:"disco_pct" gorm:"column:disco_pct"`
|
||||
TCPConns int `json:"tcp_conns" gorm:"column:tcp_conns"`
|
||||
Load1 float64 `json:"load1" gorm:"column:load1"`
|
||||
}
|
||||
|
||||
func (ServidorMetricasHistory) TableName() string { return "servidor_metricas_history" }
|
||||
|
||||
func InsertMetricasHistory(servidorID uint, cpu, ram, swap, disco float64, tcp int, load1 float64) {
|
||||
app.Http.Database.DB.Create(&ServidorMetricasHistory{
|
||||
ServidorID: servidorID,
|
||||
CreatedAt: time.Now(),
|
||||
CpuPct: cpu,
|
||||
RamPct: ram,
|
||||
SwapPct: swap,
|
||||
DiscoPct: disco,
|
||||
TCPConns: tcp,
|
||||
Load1: load1,
|
||||
})
|
||||
}
|
||||
|
||||
func GetMetricasHistory(servidorID uint, horas int) ([]ServidorMetricasHistory, error) {
|
||||
var items []ServidorMetricasHistory
|
||||
desde := time.Now().Add(-time.Duration(horas) * time.Hour)
|
||||
err := app.Http.Database.DB.
|
||||
Where("servidor_id = ? AND created_at >= ?", servidorID, desde).
|
||||
Order("created_at ASC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func PurgarMetricasHistory(dias int) error {
|
||||
corte := time.Now().AddDate(0, 0, -dias)
|
||||
return app.Http.Database.DB.
|
||||
Where("created_at < ?", corte).
|
||||
Delete(&ServidorMetricasHistory{}).Error
|
||||
}
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UrlMonitor struct {
|
||||
gorm.Model
|
||||
Nombre string `json:"nombre" gorm:"column:nombre;size:100"`
|
||||
URL string `json:"url" gorm:"column:url;size:500"`
|
||||
IntervaloMin int `json:"intervalo_min" gorm:"column:intervalo_min;default:5"`
|
||||
TimeoutSeg int `json:"timeout_seg" gorm:"column:timeout_seg;default:10"`
|
||||
Activo bool `json:"activo" gorm:"column:activo;default:true"`
|
||||
// Estado actual
|
||||
UltimoStatus int `json:"ultimo_status" gorm:"column:ultimo_status"`
|
||||
UltimaLatMs int64 `json:"ultima_lat_ms" gorm:"column:ultima_lat_ms"`
|
||||
UltimoCheckAt *time.Time `json:"ultimo_check_at" gorm:"column:ultimo_check_at"`
|
||||
UltimoOk *bool `json:"ultimo_ok" gorm:"column:ultimo_ok"`
|
||||
}
|
||||
|
||||
func (UrlMonitor) TableName() string { return "url_monitor" }
|
||||
|
||||
type UrlMonitorLog struct {
|
||||
ID uint `json:"id" gorm:"primaryKey;autoIncrement"`
|
||||
MonitorID uint `json:"monitor_id" gorm:"column:monitor_id;index"`
|
||||
CreatedAt time.Time `json:"created_at" gorm:"column:created_at;index"`
|
||||
StatusCode int `json:"status_code" gorm:"column:status_code"`
|
||||
LatenciaMs int64 `json:"latencia_ms" gorm:"column:latencia_ms"`
|
||||
Ok bool `json:"ok" gorm:"column:ok"`
|
||||
Error string `json:"error" gorm:"column:error;type:text"`
|
||||
}
|
||||
|
||||
func (UrlMonitorLog) TableName() string { return "url_monitor_log" }
|
||||
|
||||
func GetAllUrlMonitors() ([]UrlMonitor, error) {
|
||||
var items []UrlMonitor
|
||||
err := app.Http.Database.DB.Order("nombre ASC").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetActiveUrlMonitors() ([]UrlMonitor, error) {
|
||||
var items []UrlMonitor
|
||||
err := app.Http.Database.DB.Where("activo = true").Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func GetUrlMonitorByID(id uint) (*UrlMonitor, error) {
|
||||
var m UrlMonitor
|
||||
err := app.Http.Database.DB.First(&m, id).Error
|
||||
return &m, err
|
||||
}
|
||||
|
||||
func CreateUrlMonitor(m *UrlMonitor) error {
|
||||
return app.Http.Database.DB.Create(m).Error
|
||||
}
|
||||
|
||||
func SaveUrlMonitor(m *UrlMonitor) error {
|
||||
return app.Http.Database.DB.Save(m).Error
|
||||
}
|
||||
|
||||
func DeleteUrlMonitor(id uint) error {
|
||||
return app.Http.Database.DB.Delete(&UrlMonitor{}, id).Error
|
||||
}
|
||||
|
||||
func UpdateUrlMonitorStatus(id uint, statusCode int, latMs int64, ok bool) error {
|
||||
now := time.Now()
|
||||
return app.Http.Database.DB.Model(&UrlMonitor{}).Where("id = ?", id).Updates(map[string]interface{}{
|
||||
"ultimo_status": statusCode,
|
||||
"ultima_lat_ms": latMs,
|
||||
"ultimo_check_at": &now,
|
||||
"ultimo_ok": ok,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func InsertUrlMonitorLog(monitorID uint, statusCode int, latMs int64, ok bool, errStr string) {
|
||||
app.Http.Database.DB.Create(&UrlMonitorLog{
|
||||
MonitorID: monitorID,
|
||||
CreatedAt: time.Now(),
|
||||
StatusCode: statusCode,
|
||||
LatenciaMs: latMs,
|
||||
Ok: ok,
|
||||
Error: errStr,
|
||||
})
|
||||
}
|
||||
|
||||
func GetUrlMonitorLogs(monitorID uint, horas int) ([]UrlMonitorLog, error) {
|
||||
var items []UrlMonitorLog
|
||||
desde := time.Now().Add(-time.Duration(horas) * time.Hour)
|
||||
err := app.Http.Database.DB.
|
||||
Where("monitor_id = ? AND created_at >= ?", monitorID, desde).
|
||||
Order("created_at ASC").
|
||||
Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
func PurgarUrlMonitorLogs(dias int) error {
|
||||
corte := time.Now().AddDate(0, 0, -dias)
|
||||
return app.Http.Database.DB.Where("created_at < ?", corte).Delete(&UrlMonitorLog{}).Error
|
||||
}
|
||||
+10
-169
@@ -4,8 +4,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
@@ -13,12 +11,6 @@ import (
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
)
|
||||
|
||||
type cronProcInfo struct {
|
||||
Nombre string `json:"nombre"`
|
||||
CPU float64 `json:"cpu"`
|
||||
RAMMB float64 `json:"ram_mb"`
|
||||
}
|
||||
|
||||
var cronScheduler *cron.Cron
|
||||
|
||||
// IniciarCron arranca el scheduler de tareas. Llamar desde app.go o main.go.
|
||||
@@ -49,27 +41,6 @@ func IniciarCron() {
|
||||
return
|
||||
}
|
||||
|
||||
// Purga de historial de métricas — cada noche a las 3 AM (retención 7 días)
|
||||
if _, err := cronScheduler.AddFunc("0 3 * * *", func() {
|
||||
if err := models.PurgarMetricasHistory(7); err != nil {
|
||||
log.Printf("[CRON] Error purgando historial métricas: %v", err)
|
||||
} else {
|
||||
log.Println("[CRON] Historial de métricas purgado (>7 días)")
|
||||
}
|
||||
if err := models.PurgarUrlMonitorLogs(7); err != nil {
|
||||
log.Printf("[CRON] Error purgando logs URL monitor: %v", err)
|
||||
}
|
||||
}); err != nil {
|
||||
log.Printf("[CRON] Error registrando tarea purga_metricas: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Monitor de URLs — cada minuto
|
||||
if _, err := cronScheduler.AddFunc("* * * * *", VerificarUrlMonitors); err != nil {
|
||||
log.Printf("[CRON] Error registrando tarea url_monitor: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
cronScheduler.Start()
|
||||
log.Println("[CRON] Scheduler iniciado — vencimientos próximos 8AM, ya vencidos 9AM, Bold polling cada 15min, salud servidores cada 5min")
|
||||
}
|
||||
@@ -280,24 +251,14 @@ func VerificarSaludServidores() {
|
||||
// 2. Recursos (CPU / RAM / Disco) al límite
|
||||
if srv.MetricasJson != "" {
|
||||
var m struct {
|
||||
CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"`
|
||||
RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"`
|
||||
Discos []struct {
|
||||
Porcentaje float64 `json:"porcentaje"`
|
||||
Ruta string `json:"ruta"`
|
||||
} `json:"discos"`
|
||||
TopProcs []cronProcInfo `json:"top_procs"`
|
||||
CPU struct{ Porcentaje float64 `json:"porcentaje"` } `json:"cpu"`
|
||||
RAM struct{ Porcentaje float64 `json:"porcentaje"` } `json:"ram"`
|
||||
Disco struct{ Porcentaje float64 `json:"porcentaje"` } `json:"disco"`
|
||||
}
|
||||
if jsonErr := json.Unmarshal([]byte(srv.MetricasJson), &m); jsonErr == nil {
|
||||
checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU, m.TopProcs, "cpu")
|
||||
checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM, m.TopProcs, "ram")
|
||||
for _, d := range m.Discos {
|
||||
label := "Disco"
|
||||
if d.Ruta != "" && d.Ruta != "/" {
|
||||
label = "Disco " + d.Ruta
|
||||
}
|
||||
checkRecurso(srv.Nombre, label, d.Porcentaje, cfg.UmbralDisco, nil, "")
|
||||
}
|
||||
checkRecurso(srv.Nombre, "CPU", m.CPU.Porcentaje, cfg.UmbralCPU)
|
||||
checkRecurso(srv.Nombre, "RAM", m.RAM.Porcentaje, cfg.UmbralRAM)
|
||||
checkRecurso(srv.Nombre, "Disco", m.Disco.Porcentaje, cfg.UmbralDisco)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,34 +283,15 @@ func VerificarSaludServidores() {
|
||||
}
|
||||
}
|
||||
|
||||
func checkRecurso(nombre, recurso string, valor float64, umbral int, procs []cronProcInfo, ordenar string) {
|
||||
func checkRecurso(nombre, recurso string, valor float64, umbral int) {
|
||||
if valor < float64(umbral) {
|
||||
return
|
||||
}
|
||||
titulo := fmt.Sprintf("⚠️ %s alto en %s (%.0f%%)", recurso, nombre, valor)
|
||||
if !models.YaExisteAlertaServidor(titulo, 1) {
|
||||
cuerpo := fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral)
|
||||
if len(procs) > 0 {
|
||||
cp := make([]cronProcInfo, len(procs))
|
||||
copy(cp, procs)
|
||||
if ordenar == "cpu" {
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i].CPU > cp[j].CPU })
|
||||
} else {
|
||||
sort.Slice(cp, func(i, j int) bool { return cp[i].RAMMB > cp[j].RAMMB })
|
||||
}
|
||||
if len(cp) > 3 {
|
||||
cp = cp[:3]
|
||||
}
|
||||
cuerpo += "\n\nTop procesos:"
|
||||
for i, p := range cp {
|
||||
if ordenar == "cpu" {
|
||||
cuerpo += fmt.Sprintf("\n%d. %s — CPU: %.1f%%, RAM: %.0f MB", i+1, p.Nombre, p.CPU, p.RAMMB)
|
||||
} else {
|
||||
cuerpo += fmt.Sprintf("\n%d. %s — RAM: %.0f MB, CPU: %.1f%%", i+1, p.Nombre, p.RAMMB, p.CPU)
|
||||
}
|
||||
}
|
||||
}
|
||||
crearNotifServidor(titulo, cuerpo, "servidor_recurso_alto")
|
||||
crearNotifServidor(titulo,
|
||||
fmt.Sprintf("%s al %.0f%% (umbral: %d%%)", recurso, valor, umbral),
|
||||
"servidor_recurso_alto")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,104 +314,3 @@ func crearNotifServidor(titulo, cuerpo, evento string) {
|
||||
sendTelegramAdmin(msg)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Monitor de URLs ──────────────────────────────────────────────────────────
|
||||
|
||||
func VerificarUrlMonitors() {
|
||||
monitors, err := models.GetActiveUrlMonitors()
|
||||
if err != nil || len(monitors) == 0 {
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for _, m := range monitors {
|
||||
m := m
|
||||
if m.UltimoCheckAt != nil {
|
||||
if now.Sub(*m.UltimoCheckAt).Minutes() < float64(m.IntervaloMin) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
go ejecutarChequeoURL(m)
|
||||
}
|
||||
}
|
||||
|
||||
func EjecutarChequeoURLPublic(m models.UrlMonitor) { ejecutarChequeoURL(m) }
|
||||
|
||||
func ejecutarChequeoURL(m models.UrlMonitor) {
|
||||
statusCode, latMs, err := doHttpCheck(m.URL, m.TimeoutSeg)
|
||||
ok := err == nil && statusCode >= 200 && statusCode < 400
|
||||
errStr := ""
|
||||
if err != nil {
|
||||
errStr = err.Error()
|
||||
}
|
||||
|
||||
anteriorOk := m.UltimoOk == nil || *m.UltimoOk
|
||||
|
||||
models.UpdateUrlMonitorStatus(m.ID, statusCode, latMs, ok)
|
||||
models.InsertUrlMonitorLog(m.ID, statusCode, latMs, ok, errStr)
|
||||
|
||||
if m.UltimoOk == nil {
|
||||
return // primer chequeo, no alertar
|
||||
}
|
||||
if !ok && anteriorOk {
|
||||
detalle := errStr
|
||||
if detalle == "" {
|
||||
detalle = fmt.Sprintf("HTTP %d", statusCode)
|
||||
}
|
||||
msg := fmt.Sprintf("🔴 <b>Sitio caído</b>\n<b>%s</b>\n%s\n\nError: %s",
|
||||
escapeTelegramHTML(m.Nombre),
|
||||
escapeTelegramHTML(m.URL),
|
||||
escapeTelegramHTML(detalle))
|
||||
sendTelegramAdmin(msg)
|
||||
log.Printf("[URL-MON] DOWN %s — %s", m.Nombre, detalle)
|
||||
} else if ok && !anteriorOk {
|
||||
msg := fmt.Sprintf("✅ <b>Sitio recuperado</b>\n<b>%s</b>\n%s\nLatencia: <b>%dms</b>",
|
||||
escapeTelegramHTML(m.Nombre),
|
||||
escapeTelegramHTML(m.URL),
|
||||
latMs)
|
||||
sendTelegramAdmin(msg)
|
||||
log.Printf("[URL-MON] UP %s — %dms", m.Nombre, latMs)
|
||||
}
|
||||
}
|
||||
|
||||
func doHttpCheck(url string, timeoutSeg int) (int, int64, error) {
|
||||
if timeoutSeg <= 0 {
|
||||
timeoutSeg = 10
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(timeoutSeg) * time.Second,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("demasiadas redirecciones")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
req, err := http.NewRequest(http.MethodHead, url, nil)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "usite-monitor/1.0")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
latMs := time.Since(start).Milliseconds()
|
||||
if err != nil || (resp != nil && resp.StatusCode == http.StatusMethodNotAllowed) {
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
// Reintentar con GET
|
||||
start = time.Now()
|
||||
req2, _ := http.NewRequest(http.MethodGet, url, nil)
|
||||
req2.Header.Set("User-Agent", "usite-monitor/1.0")
|
||||
resp2, err2 := client.Do(req2)
|
||||
latMs = time.Since(start).Milliseconds()
|
||||
if err2 != nil {
|
||||
return 0, latMs, err2
|
||||
}
|
||||
resp2.Body.Close()
|
||||
return resp2.StatusCode, latMs, nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode, latMs, nil
|
||||
}
|
||||
|
||||
@@ -246,14 +246,6 @@ func sendTelegramPortalUser(chatID string, mensaje string) error {
|
||||
return fmt.Errorf("no hay bots de Telegram activos para enviar")
|
||||
}
|
||||
|
||||
// NotificarReniceAction envía Telegram cuando el agente aplica un renice automático.
|
||||
func NotificarReniceAction(servidorNombre, accion string) {
|
||||
msg := fmt.Sprintf("🔧 <b>Renice automático aplicado</b>\nServidor: <b>%s</b>\n%s\n\n<i>CPU sostenida por encima del umbral.</i>",
|
||||
escapeTelegramHTML(servidorNombre),
|
||||
escapeTelegramHTML(accion))
|
||||
sendTelegramAdmin(msg)
|
||||
}
|
||||
|
||||
func sendTelegramAdmin(mensaje string) {
|
||||
configs, err := models.GetAllTelegramConfigs()
|
||||
if err != nil {
|
||||
|
||||
@@ -28,14 +28,6 @@ type WebSmsResponse struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
SubID string `json:"subid"`
|
||||
}
|
||||
|
||||
func (r *WebSmsResponse) MsgID() string {
|
||||
if r.ID != "" {
|
||||
return r.ID
|
||||
}
|
||||
return r.SubID
|
||||
}
|
||||
|
||||
type WebSmsAckPayload struct {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<!-- Servidor Dashboard -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.3/dist/chart.umd.min.js"></script>
|
||||
<div x-data="servidorDashboard()" x-init="init()">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
@@ -110,79 +109,6 @@
|
||||
<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 por RAM (<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,10)" :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>
|
||||
@@ -356,43 +282,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- === Historial de Métricas === -->
|
||||
<template x-if="servidorSeleccionado?.agent_token">
|
||||
<div>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-wider">Historial de Métricas</h3>
|
||||
<div class="flex gap-1">
|
||||
<template x-for="opt in [{h:1,l:'1h'},{h:6,l:'6h'},{h:24,l:'24h'},{h:168,l:'7d'}]" :key="opt.h">
|
||||
<button @click="cargarHistorial(opt.h)"
|
||||
:class="historialHoras === opt.h ? 'bg-blue-600 text-white' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'"
|
||||
class="px-3 py-1 rounded-lg text-xs font-bold transition-colors"
|
||||
x-text="opt.l"></button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<template x-if="historialCargando">
|
||||
<div class="flex items-center justify-center h-40 text-slate-400 text-sm gap-2">
|
||||
<svg class="w-4 h-4 animate-spin" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/></svg>
|
||||
Cargando historial...
|
||||
</div>
|
||||
</template>
|
||||
<template x-if="!historialCargando && historialVacio">
|
||||
<div class="flex items-center justify-center h-40 text-slate-400 text-sm">Sin datos en este período</div>
|
||||
</template>
|
||||
<div x-show="!historialCargando && !historialVacio">
|
||||
<canvas id="metricasChart" height="120"></canvas>
|
||||
</div>
|
||||
<!-- Leyenda -->
|
||||
<div x-show="!historialCargando && !historialVacio" class="flex gap-4 mt-3 justify-center text-xs text-slate-500">
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-blue-500 inline-block"></span>CPU %</span>
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-green-500 inline-block"></span>RAM %</span>
|
||||
<span class="flex items-center gap-1.5"><span class="w-3 h-1 rounded bg-amber-400 inline-block"></span>Disco %</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- === Conexiones BD === -->
|
||||
<div>
|
||||
<h3 class="text-sm font-bold text-slate-500 uppercase tracking-wider mb-4">
|
||||
@@ -639,10 +528,6 @@
|
||||
syncCargando: {},
|
||||
pingCargando: {},
|
||||
pingResultado: {},
|
||||
historialHoras: 24,
|
||||
historialCargando: false,
|
||||
historialVacio: false,
|
||||
_chart: null,
|
||||
|
||||
async init() {
|
||||
await this.cargarServidores();
|
||||
@@ -666,13 +551,12 @@
|
||||
this.servidorSeleccionado = { ...servidor };
|
||||
this.pingCargando = {};
|
||||
this.pingResultado = {};
|
||||
this.historialHoras = 24;
|
||||
this.historialVacio = false;
|
||||
this.cargandoConexiones = true;
|
||||
if (this._chart) { this._chart.destroy(); this._chart = null; }
|
||||
try {
|
||||
const r = await fetch(`/app/servidor-dashboard/${servidor.ID}`);
|
||||
const data = await r.json();
|
||||
// Inyectar estado de ping directamente en cada conexión para que
|
||||
// Alpine v3 lo rastreé correctamente dentro del x-for
|
||||
const conexiones = (data.conexiones || []).map(c => ({
|
||||
...c,
|
||||
_pingCargando: false,
|
||||
@@ -687,112 +571,12 @@
|
||||
} finally {
|
||||
this.cargandoConexiones = false;
|
||||
}
|
||||
if (servidor.agent_token) {
|
||||
await this.$nextTick();
|
||||
this.cargarHistorial(24);
|
||||
}
|
||||
},
|
||||
|
||||
cerrarModal() {
|
||||
if (this._chart) { this._chart.destroy(); this._chart = null; }
|
||||
this.servidorSeleccionado = null;
|
||||
},
|
||||
|
||||
async cargarHistorial(horas) {
|
||||
if (!this.servidorSeleccionado) return;
|
||||
this.historialHoras = horas;
|
||||
this.historialCargando = true;
|
||||
this.historialVacio = false;
|
||||
try {
|
||||
const r = await fetch(`/app/servidor/${this.servidorSeleccionado.ID}/metricas-history?horas=${horas}`);
|
||||
const data = await r.json();
|
||||
if (!data || data.length === 0) { this.historialVacio = true; return; }
|
||||
// Downsample si hay muchos puntos (>300 para fluidez)
|
||||
const pts = data.length > 300
|
||||
? data.filter((_, i) => i % Math.ceil(data.length / 300) === 0)
|
||||
: data;
|
||||
const labels = pts.map(p => {
|
||||
const d = new Date(p.created_at);
|
||||
return horas <= 6
|
||||
? d.toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit', second:'2-digit'})
|
||||
: horas <= 24
|
||||
? d.toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit'})
|
||||
: d.toLocaleDateString('es', {month:'short', day:'numeric'}) + ' ' + d.toLocaleTimeString('es', {hour:'2-digit', minute:'2-digit'});
|
||||
});
|
||||
await this.$nextTick();
|
||||
const canvas = document.getElementById('metricasChart');
|
||||
if (!canvas) return;
|
||||
if (this._chart) this._chart.destroy();
|
||||
this._chart = new Chart(canvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
label: 'CPU %',
|
||||
data: pts.map(p => p.cpu_pct),
|
||||
borderColor: '#3b82f6',
|
||||
backgroundColor: 'rgba(59,130,246,0.08)',
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'RAM %',
|
||||
data: pts.map(p => p.ram_pct),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34,197,94,0.08)',
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.3,
|
||||
fill: true,
|
||||
},
|
||||
{
|
||||
label: 'Disco %',
|
||||
data: pts.map(p => p.disco_pct),
|
||||
borderColor: '#f59e0b',
|
||||
backgroundColor: 'rgba(245,158,11,0.06)',
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
tension: 0.1,
|
||||
fill: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
interaction: { mode: 'index', intersect: false },
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: ctx => `${ctx.dataset.label}: ${ctx.parsed.y?.toFixed(1)}%`
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
ticks: { maxTicksLimit: 8, font: { size: 10 }, color: '#94a3b8' },
|
||||
grid: { color: '#f1f5f9' },
|
||||
},
|
||||
y: {
|
||||
min: 0, max: 100,
|
||||
ticks: { stepSize: 25, font: { size: 10 }, color: '#94a3b8',
|
||||
callback: v => v + '%' },
|
||||
grid: { color: '#f1f5f9' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('Error cargando historial:', e);
|
||||
this.historialVacio = true;
|
||||
} finally {
|
||||
this.historialCargando = false;
|
||||
}
|
||||
},
|
||||
|
||||
abrirAgentModal(servidor) {
|
||||
// Buscar el servidor actualizado en la lista
|
||||
const s = this.servidores.find(sv => sv.ID === servidor.ID) || servidor;
|
||||
@@ -870,13 +654,6 @@
|
||||
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(() => {});
|
||||
},
|
||||
|
||||
@@ -111,111 +111,6 @@
|
||||
<p x-show="copied" x-text="copied" class="text-green-600 text-xs mt-3"></p>
|
||||
</div>
|
||||
|
||||
<!-- Documentación de integración -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6 mb-6" x-data="{ tab: 'curl' }">
|
||||
<div class="flex items-center justify-between mb-5">
|
||||
<div>
|
||||
<h2 class="text-lg font-bold text-slate-800">Documentación de integración</h2>
|
||||
<p class="text-sm text-slate-500 mt-0.5">Cómo enviar SMS desde tu propio sistema</p>
|
||||
</div>
|
||||
<span class="text-xs px-2 py-1 bg-green-100 text-green-700 rounded-full font-bold">REST API</span>
|
||||
</div>
|
||||
|
||||
<!-- Endpoint -->
|
||||
<div class="mb-5">
|
||||
<p class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Endpoint</p>
|
||||
<div class="flex items-center gap-2 bg-slate-50 border border-slate-200 rounded-lg px-3 py-2">
|
||||
<span class="text-xs font-bold bg-blue-600 text-white px-2 py-0.5 rounded font-mono">POST</span>
|
||||
<code class="text-sm font-mono text-slate-700 flex-1">https://admin.u-site.app/api/sms/send</code>
|
||||
<button type="button" @click="copy('https://admin.u-site.app/api/sms/send')" class="btn-icon text-slate-400 hover:text-slate-600" title="Copiar">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" 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>
|
||||
</div>
|
||||
|
||||
<!-- Auth -->
|
||||
<div class="mb-5">
|
||||
<p class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Autenticación</p>
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-800">
|
||||
Incluye tu API Key en el header <code class="bg-amber-100 px-1 rounded font-mono text-xs">Authorization: Bearer {API_KEY}</code>.<br>
|
||||
Encuentra tu API Key en la sección <strong>API Key (envío externo)</strong> de esta página.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Request/Response -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-5">
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Body (JSON)</p>
|
||||
<div class="bg-slate-900 rounded-lg p-4">
|
||||
<pre class="text-green-400 text-xs font-mono leading-relaxed">{
|
||||
"numero": "573001234567",
|
||||
"mensaje": "Hola, este es tu código: 9821"
|
||||
}</pre>
|
||||
</div>
|
||||
<div class="mt-2 space-y-1">
|
||||
<div class="flex gap-2 text-xs"><code class="text-blue-600 font-mono font-bold w-16">numero</code><span class="text-slate-500">Teléfono en formato internacional sin <code class="bg-slate-100 px-1 rounded">+</code> (ej: <code class="bg-slate-100 px-1 rounded">573001234567</code>)</span></div>
|
||||
<div class="flex gap-2 text-xs"><code class="text-blue-600 font-mono font-bold w-16">mensaje</code><span class="text-slate-500">Texto del SMS. Máx 160 caracteres para un crédito.</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Respuestas</p>
|
||||
<div class="space-y-2">
|
||||
<div>
|
||||
<span class="text-xs font-bold text-green-700">200 — Enviado</span>
|
||||
<div class="bg-slate-900 rounded-lg p-3 mt-1">
|
||||
<pre class="text-green-400 text-xs font-mono">{ "ok": true, "id": "6a3d4f155..." }</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-bold text-red-600">401 — No autenticado</span>
|
||||
<div class="bg-slate-900 rounded-lg p-3 mt-1">
|
||||
<pre class="text-red-400 text-xs font-mono">{ "error": "token requerido" }</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-bold text-red-600">400 — Datos inválidos</span>
|
||||
<div class="bg-slate-900 rounded-lg p-3 mt-1">
|
||||
<pre class="text-red-400 text-xs font-mono">{ "error": "numero y mensaje son requeridos" }</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-xs font-bold text-red-600">500 — Error LabsMobile</span>
|
||||
<div class="bg-slate-900 rounded-lg p-3 mt-1">
|
||||
<pre class="text-red-400 text-xs font-mono">{ "error": "descripción del error" }</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Code examples -->
|
||||
<div>
|
||||
<p class="text-xs font-bold text-slate-500 uppercase tracking-wider mb-2">Ejemplos de código</p>
|
||||
<!-- Tabs -->
|
||||
<div class="flex gap-1 mb-3 border-b border-slate-200">
|
||||
<template x-for="t in [{id:'curl',label:'cURL'},{id:'js',label:'JavaScript'},{id:'php',label:'PHP'},{id:'python',label:'Python'}]" :key="t.id">
|
||||
<button type="button" @click="tab=t.id"
|
||||
:class="tab===t.id ? 'border-b-2 border-green-600 text-green-700 font-bold' : 'text-slate-500 hover:text-slate-700'"
|
||||
class="px-3 py-1.5 text-xs transition-colors -mb-px"
|
||||
x-text="t.label"></button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Bloque de código único — el contenido lo genera JS -->
|
||||
<div class="relative group">
|
||||
<div class="bg-slate-900 rounded-lg p-4">
|
||||
<pre class="text-green-400 text-xs font-mono leading-relaxed whitespace-pre-wrap" x-text="getCodeExample(tab)"></pre>
|
||||
</div>
|
||||
<button type="button" @click="copy(getCodeExample(tab))"
|
||||
class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity bg-slate-700 hover:bg-slate-600 text-slate-300 rounded px-2 py-1 text-xs">
|
||||
Copiar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p x-show="copied" x-text="copied" class="text-green-600 text-xs mt-3"></p>
|
||||
</div>
|
||||
|
||||
<!-- Logs -->
|
||||
<div class="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
|
||||
<h2 class="text-lg font-bold text-slate-800 mb-4">Últimos envíos</h2>
|
||||
@@ -301,83 +196,7 @@ function websmsApp() {
|
||||
},
|
||||
|
||||
formatDate(d){ if(!d) return ''; return new Date(d).toLocaleDateString('es-CO',{day:'2-digit',month:'short',year:'numeric',hour:'2-digit',minute:'2-digit'}); },
|
||||
copy(text){ navigator.clipboard.writeText(text); this.copied='Copiado'; setTimeout(()=>this.copied='', 2000); },
|
||||
|
||||
getCodeExample(tab) {
|
||||
const key = this.secretKey || '{API_KEY}';
|
||||
const url = 'https://admin.u-site.app/api/sms/send';
|
||||
if (tab === 'curl') {
|
||||
return [
|
||||
"curl -X POST " + url + " \\",
|
||||
" -H 'Authorization: Bearer " + key + "' \\",
|
||||
" -H 'Content-Type: application/json' \\",
|
||||
" -d '{",
|
||||
' "numero": "573001234567",',
|
||||
' "mensaje": "Hola, este es tu código: 9821"',
|
||||
" }'"
|
||||
].join('\n');
|
||||
}
|
||||
if (tab === 'js') {
|
||||
return [
|
||||
"const response = await fetch('" + url + "', {",
|
||||
" method: 'POST',",
|
||||
" headers: {",
|
||||
" 'Authorization': 'Bearer " + key + "',",
|
||||
" 'Content-Type': 'application/json',",
|
||||
" },",
|
||||
" body: JSON.stringify({",
|
||||
" numero: '573001234567',",
|
||||
" mensaje: 'Hola, este es tu código: 9821',",
|
||||
" }),",
|
||||
"});",
|
||||
"",
|
||||
"const data = await response.json();",
|
||||
"console.log(data); // { ok: true, id: '6a3d...' }"
|
||||
].join('\n');
|
||||
}
|
||||
if (tab === 'php') {
|
||||
return [
|
||||
"<?php",
|
||||
"$ch = curl_init('" + url + "');",
|
||||
"curl_setopt_array($ch, [",
|
||||
" CURLOPT_RETURNTRANSFER => true,",
|
||||
" CURLOPT_POST => true,",
|
||||
" CURLOPT_HTTPHEADER => [",
|
||||
" 'Authorization: Bearer " + key + "',",
|
||||
" 'Content-Type: application/json',",
|
||||
" ],",
|
||||
' CURLOPT_POSTFIELDS => json_encode([',
|
||||
" 'numero' => '573001234567',",
|
||||
" 'mensaje' => 'Hola, este es tu código: 9821',",
|
||||
" ]),",
|
||||
"]);",
|
||||
"$response = json_decode(curl_exec($ch), true);",
|
||||
"curl_close($ch);",
|
||||
"// $response['ok'] === true"
|
||||
].join('\n');
|
||||
}
|
||||
if (tab === 'python') {
|
||||
return [
|
||||
"import requests",
|
||||
"",
|
||||
"response = requests.post(",
|
||||
" '" + url + "',",
|
||||
" headers={",
|
||||
" 'Authorization': 'Bearer " + key + "',",
|
||||
" 'Content-Type': 'application/json',",
|
||||
" },",
|
||||
" json={",
|
||||
" 'numero': '573001234567',",
|
||||
" 'mensaje': 'Hola, este es tu código: 9821',",
|
||||
" },",
|
||||
")",
|
||||
"",
|
||||
"data = response.json()",
|
||||
"print(data) # {'ok': True, 'id': '6a3d...'}"
|
||||
].join('\n');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
copy(text){ navigator.clipboard.writeText(text); this.copied='URL copiada'; setTimeout(()=>this.copied='', 2000); },
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -10,16 +10,14 @@ import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/app"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
// 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"`
|
||||
ReniceAction string `json:"renice_action"`
|
||||
Token string `json:"token"`
|
||||
Metricas string `json:"metricas"` // JSON string ya serializado por el agente
|
||||
}
|
||||
|
||||
var req HeartbeatRequest
|
||||
@@ -91,80 +89,9 @@ func AgentHeartbeat(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"ok": false, "error": "no se pudo guardar"})
|
||||
}
|
||||
|
||||
// Insertar punto en el historial de métricas
|
||||
if req.Metricas != "" {
|
||||
var raw map[string]any
|
||||
if json.Unmarshal([]byte(req.Metricas), &raw) == nil {
|
||||
getFloat := func(obj map[string]any, key string) float64 {
|
||||
if sub, ok := obj[key].(map[string]any); ok {
|
||||
if v, ok := sub["porcentaje"].(float64); ok {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
discoPct := 0.0
|
||||
if discos, ok := raw["discos"].([]any); ok && len(discos) > 0 {
|
||||
if d, ok := discos[0].(map[string]any); ok {
|
||||
if v, ok := d["porcentaje"].(float64); ok {
|
||||
discoPct = v
|
||||
}
|
||||
}
|
||||
}
|
||||
tcpConns := 0
|
||||
if v, ok := raw["tcp_conns"].(float64); ok {
|
||||
tcpConns = int(v)
|
||||
}
|
||||
load1 := 0.0
|
||||
if la, ok := raw["load_avg"].(map[string]any); ok {
|
||||
if v, ok := la["load1"].(float64); ok {
|
||||
load1 = v
|
||||
}
|
||||
}
|
||||
swapPct := 0.0
|
||||
if sw, ok := raw["swap"].(map[string]any); ok {
|
||||
if v, ok := sw["porcentaje"].(float64); ok {
|
||||
swapPct = v
|
||||
}
|
||||
}
|
||||
models.InsertMetricasHistory(servidor.ID,
|
||||
getFloat(raw, "cpu"),
|
||||
getFloat(raw, "ram"),
|
||||
swapPct,
|
||||
discoPct,
|
||||
tcpConns,
|
||||
load1,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Notificar por Telegram si el agente aplicó un renice
|
||||
if req.ReniceAction != "" {
|
||||
go services.NotificarReniceAction(servidor.Nombre, req.ReniceAction)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "servidor_id": servidor.ID})
|
||||
}
|
||||
|
||||
// GetMetricasHistory devuelve el historial de métricas de un servidor.
|
||||
// GET /app/servidor/:id/metricas-history?horas=24
|
||||
func GetMetricasHistory(c *fiber.Ctx) error {
|
||||
id := c.Params("id")
|
||||
horas := c.QueryInt("horas", 24)
|
||||
if horas <= 0 || horas > 168 {
|
||||
horas = 24
|
||||
}
|
||||
var srv models.Servidor
|
||||
if err := app.Http.Database.DB.First(&srv, id).Error; err != nil {
|
||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "servidor no encontrado"})
|
||||
}
|
||||
items, err := models.GetMetricasHistory(srv.ID, horas)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
// 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 {
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package controllers
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/models"
|
||||
"github.com/sujit-baniya/fiber-boilerplate/pkg/services"
|
||||
)
|
||||
|
||||
func UrlMonitorIndex(c *fiber.Ctx) error {
|
||||
return c.Render("url_monitor", fiber.Map{
|
||||
"user": c.Locals("user"),
|
||||
"modules": c.Locals("userModules"),
|
||||
}, "layouts/main")
|
||||
}
|
||||
|
||||
func GetUrlMonitors(c *fiber.Ctx) error {
|
||||
items, err := models.GetAllUrlMonitors()
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
|
||||
func CreateUrlMonitor(c *fiber.Ctx) error {
|
||||
var m models.UrlMonitor
|
||||
if err := c.BodyParser(&m); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
if strings.TrimSpace(m.Nombre) == "" || strings.TrimSpace(m.URL) == "" {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "nombre y url son requeridos"})
|
||||
}
|
||||
if m.IntervaloMin <= 0 {
|
||||
m.IntervaloMin = 5
|
||||
}
|
||||
if m.TimeoutSeg <= 0 {
|
||||
m.TimeoutSeg = 10
|
||||
}
|
||||
m.Activo = true
|
||||
if err := models.CreateUrlMonitor(&m); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.Status(201).JSON(m)
|
||||
}
|
||||
|
||||
func UpdateUrlMonitor(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
existing, err := models.GetUrlMonitorByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
if err := c.BodyParser(existing); err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
existing.ID = uint(id)
|
||||
if err := models.SaveUrlMonitor(existing); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func DeleteUrlMonitorHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
if err := models.DeleteUrlMonitor(uint(id)); err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true})
|
||||
}
|
||||
|
||||
func CheckUrlMonitorNow(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
m, err := models.GetUrlMonitorByID(uint(id))
|
||||
if err != nil {
|
||||
return c.Status(404).JSON(fiber.Map{"error": "no encontrado"})
|
||||
}
|
||||
go services.EjecutarChequeoURLPublic(*m)
|
||||
return c.JSON(fiber.Map{"ok": true, "message": "Chequeo iniciado"})
|
||||
}
|
||||
|
||||
func GetUrlMonitorLogsHandler(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
||||
if err != nil {
|
||||
return c.Status(400).JSON(fiber.Map{"error": "ID inválido"})
|
||||
}
|
||||
horas := c.QueryInt("horas", 24)
|
||||
if horas <= 0 || horas > 168 {
|
||||
horas = 24
|
||||
}
|
||||
items, err := models.GetUrlMonitorLogs(uint(id), horas)
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
return c.JSON(items)
|
||||
}
|
||||
@@ -92,7 +92,7 @@ func TestWebSms(c *fiber.Ctx) error {
|
||||
Para: b.Para,
|
||||
Mensaje: b.Mensaje,
|
||||
Status: resp.Code,
|
||||
MsgID: resp.MsgID(),
|
||||
MsgID: resp.ID,
|
||||
})
|
||||
|
||||
return c.JSON(fiber.Map{"ok": true, "response": resp})
|
||||
@@ -189,14 +189,14 @@ func ApiSendSms(c *fiber.Ctx) error {
|
||||
Para: b.Numero,
|
||||
Mensaje: b.Mensaje,
|
||||
Status: status,
|
||||
MsgID: func() string { if resp != nil { return resp.MsgID() }; return "" }(),
|
||||
MsgID: func() string { if resp != nil { return resp.ID }; return "" }(),
|
||||
Error: errStr,
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return c.Status(500).JSON(fiber.Map{"error": errStr})
|
||||
}
|
||||
return c.JSON(fiber.Map{"ok": true, "id": resp.MsgID()})
|
||||
return c.JSON(fiber.Map{"ok": true, "id": resp.ID})
|
||||
}
|
||||
|
||||
func WebSmsIncomingWebhook(c *fiber.Ctx) error {
|
||||
|
||||
@@ -263,7 +263,7 @@ func AuthAdmin(c *fiber.Ctx) error {
|
||||
func AuthApi() func(*fiber.Ctx) error {
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Excluir rutas públicas
|
||||
if c.Path() == "/api/v1/oauth/token" || c.Path() == "/api/sms/send" {
|
||||
if c.Path() == "/api/v1/oauth/token" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
|
||||
@@ -81,18 +81,8 @@ func UserRoutes(app fiber.Router) {
|
||||
protected.Get("/servidor-dashboard/:id", controllers.GetServidorDashboard)
|
||||
protected.Get("/conx-ping/:id", controllers.PingConexion)
|
||||
protected.Post("/servidor/:id/agent-token", controllers.GenerateAgentToken)
|
||||
protected.Get("/servidor/:id/metricas-history", controllers.GetMetricasHistory)
|
||||
protected.Post("/servidor/:id/sync-hostinger", controllers.SyncServidorFromHostinger)
|
||||
|
||||
// Monitor de URLs
|
||||
protected.Get("/url-monitor", middlewares.MenuMiddleware, controllers.UrlMonitorIndex)
|
||||
protected.Get("/url-monitors", controllers.GetUrlMonitors)
|
||||
protected.Post("/url-monitor", controllers.CreateUrlMonitor)
|
||||
protected.Put("/url-monitor/:id", controllers.UpdateUrlMonitor)
|
||||
protected.Delete("/url-monitor/:id", controllers.DeleteUrlMonitorHandler)
|
||||
protected.Post("/url-monitor/:id/check", controllers.CheckUrlMonitorNow)
|
||||
protected.Get("/url-monitor/:id/logs", controllers.GetUrlMonitorLogsHandler)
|
||||
|
||||
// Rutas de proveedores de servidor
|
||||
protected.Get("/prov_servidor", middlewares.MenuMiddleware, controllers.ProvServidor)
|
||||
protected.Get("/loadprovservidor", controllers.GetProvServidor)
|
||||
|
||||
Reference in New Issue
Block a user