Compare commits

..
2 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 f4d3e1f03d feat(ai-config): agregar botón Probar para verificar conectividad del provider
- Endpoint GET /ai-config/:id/test que llama /api/tags (Ollama) o /v1/models (OpenAI-compat/Anthropic)
- Botón "Probar" en cada fila de la tabla con modal de resultado JSON

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 23:38:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 80c29bf0d3 feat(ai): integrar Ollama en AiConfig para ia_controller, query_runner y landing
- Agrega provider 'ollama' al panel de AiConfig con badge azul y hint de URL
- ia_controller: reemplaza webhook n8n hardcodeado por llamada directa a Ollama vía AiConfig (módulo 'ia')
- query_runner: agrega soporte explícito de ollama (model fallback gemma3:1b, Basic Auth para URL pública)
- HTML: agrega módulo 'IA / vCard' en el selector de módulos

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-05 23:35:19 -05:00
16 changed files with 94 additions and 1183 deletions
+11 -46
View File
@@ -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
View File
@@ -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
View File
@@ -64,7 +64,7 @@ jwt:
expire: 31536000
template:
path: "resources/views"
path: "resources/view"
extension: ".html"
log:
-5
View File
@@ -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)
}
-46
View File
@@ -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
}
-102
View File
@@ -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
View File
@@ -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
}
-8
View File
@@ -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 {
-8
View File
@@ -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 {
+2 -225
View File
@@ -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(() => {});
},
+1 -182
View File
@@ -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>
+2 -75
View File
@@ -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 {
-105
View File
@@ -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)
}
+3 -3
View File
@@ -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 {
+1 -1
View File
@@ -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()
}
-10
View File
@@ -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)