Es la base del cobro por uso: hasta ahora no había ninguna medición de consumo en todo el repo. - UmindUso registra cada evento facturable con el costo YA calculado al precio vigente del plan. Congelarlo evita que subir un precio revalúe consumo pasado, que haría indefendible una factura ante un reclamo. - callAI devuelve los tokens que reportó el proveedor (campo usage, igual en todos los OpenAI-compatibles; input+output en Anthropic). Se mide cada ronda de tool-calling, no solo la última: todas gastan tokens. - ExtraerTextoOCR y TranscribirAudioSelfHosted reciben agenteID; 0 = no medir, que es lo que pasan los botones "Probar" del panel de staff. - Aviso al superar el tope del plan, una vez por mes y sin cortar el servicio. El flag de "ya avisé" es en memoria a propósito. - GET /app/umind/uso con filtros de fecha: resumen por tipo + detalle. - Test del cálculo de costo por tipo, incluida fracción de 1k tokens y tenant sin plan. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
421 lines
12 KiB
Go
Executable File
421 lines
12 KiB
Go
Executable File
package controllers
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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"
|
|
)
|
|
|
|
// RENDER
|
|
func Servidor(c *fiber.Ctx) error {
|
|
data := fiber.Map{
|
|
"user": c.Locals("user").(map[string]interface{}),
|
|
"modules": c.Locals("userModules"),
|
|
}
|
|
|
|
if err := c.Render("servidor", data, "layouts/main"); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error rendering template",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ServidorDashboard renders the server dashboard view
|
|
func ServidorDashboard(c *fiber.Ctx) error {
|
|
data := fiber.Map{
|
|
"user": c.Locals("user").(map[string]interface{}),
|
|
"modules": c.Locals("userModules"),
|
|
}
|
|
|
|
if err := c.Render("servidor_dashboard", data, "layouts/main"); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error rendering template",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func GetServidor(c *fiber.Ctx) error {
|
|
pageStr := c.Query("page", "1")
|
|
page, err := strconv.Atoi(pageStr)
|
|
if err != nil || page < 1 {
|
|
page = 1
|
|
}
|
|
searchQuery := c.Query("search", "")
|
|
limitStr := c.Query("limit", "10")
|
|
limit, _ := strconv.Atoi(limitStr)
|
|
offset := (page - 1) * limit
|
|
|
|
records, total, err := models.GetAllServidores(limit, offset, searchQuery)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error retrieving records",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Build connection count map per server
|
|
type conxCount struct {
|
|
ServidorID uint `gorm:"column:servidor_id"`
|
|
Count int64 `gorm:"column:count"`
|
|
}
|
|
var counts []conxCount
|
|
serverIDs := make([]uint, len(records))
|
|
for i, s := range records {
|
|
serverIDs[i] = s.ID
|
|
}
|
|
if len(serverIDs) > 0 {
|
|
app.Http.Database.DB.Table("conx_db").
|
|
Select("servidor_id, count(*) as count").
|
|
Where("servidor_id IN ? AND deleted_at IS NULL", serverIDs).
|
|
Group("servidor_id").
|
|
Scan(&counts)
|
|
}
|
|
countMap := make(map[uint]int64)
|
|
for _, cc := range counts {
|
|
countMap[cc.ServidorID] = cc.Count
|
|
}
|
|
|
|
// Attach conx_count to each record
|
|
type servidorWithCount struct {
|
|
models.Servidor
|
|
ConxCount int64 `json:"conx_count"`
|
|
}
|
|
enriched := make([]servidorWithCount, len(records))
|
|
for i, s := range records {
|
|
enriched[i] = servidorWithCount{Servidor: s, ConxCount: countMap[s.ID]}
|
|
}
|
|
|
|
totalPages := int(math.Ceil(float64(total) / float64(limit)))
|
|
return c.JSON(fiber.Map{
|
|
"registros": enriched,
|
|
"total": total,
|
|
"totalPages": totalPages,
|
|
"page": page,
|
|
"limit": limit,
|
|
})
|
|
}
|
|
|
|
func GetServidorSelect(c *fiber.Ctx) error {
|
|
records, err := models.GetAllServidoresSelect()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error retrieving records",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
return c.JSON(fiber.Map{
|
|
"registros": records,
|
|
})
|
|
}
|
|
|
|
func CreateServidor(c *fiber.Ctx) error {
|
|
var m models.Servidor
|
|
|
|
if err := c.BodyParser(&m); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"message": "Error parsing request body",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
if err := models.CreateServidor(m); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error creating servidor",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusCreated).JSON(m)
|
|
}
|
|
|
|
func UpdateServidor(c *fiber.Ctx) error {
|
|
uid, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"message": "ID inválido",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
var m models.Servidor
|
|
|
|
if err := c.BodyParser(&m); err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"message": "Error al parsear el cuerpo de la solicitud",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
m.ID = uint(uid)
|
|
|
|
// Cargar el registro actual para detectar cambios que deben propagarse a Hostinger
|
|
var actual models.Servidor
|
|
app.Http.Database.DB.First(&actual, uid)
|
|
nombreAnterior := actual.Nombre
|
|
|
|
// Usar map para Updates y así no perder campos con valor cero (incluido hostinger_vps_id=nil)
|
|
updateMap := map[string]interface{}{
|
|
"nombre": m.Nombre,
|
|
"ip_servidor": m.IpServidor,
|
|
"so": m.So,
|
|
"vencimiento": m.Vencimiento,
|
|
"ram": m.Ram,
|
|
"nucleos": m.Nucleos,
|
|
"disco": m.Disco,
|
|
"ultimo_ping": m.UltimoPing,
|
|
"prov_servidor_id": m.ProvServidorID,
|
|
"tipo_servidor_id": m.TipoServidorID,
|
|
"hostinger_vps_id": m.HostingerVpsID,
|
|
"hostinger_subscription_id": m.HostingerSubscriptionID,
|
|
}
|
|
if err := app.Http.Database.DB.Model(&models.Servidor{}).Where("id = ?", uid).Updates(updateMap).Error; err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error al actualizar el servidor",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
// Sincronizar hostname en Hostinger si está vinculado y el nombre cambió
|
|
if actual.HostingerVpsID != nil && m.Nombre != "" && m.Nombre != nombreAnterior {
|
|
if client, hErr := hostingerClient(); hErr == nil {
|
|
_ = client.SetVPSHostname(*actual.HostingerVpsID, m.Nombre)
|
|
}
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(m)
|
|
}
|
|
|
|
// SyncServidorFromHostinger sincroniza los datos del VPS de Hostinger hacia el servidor local.
|
|
// Actualiza: ip_servidor, ram, nucleos, disco, hostinger_state, vencimiento (si hay suscripción).
|
|
// La verdad de las specs físicas siempre viene de Hostinger.
|
|
func SyncServidorFromHostinger(c *fiber.Ctx) error {
|
|
id := c.Params("id")
|
|
db := app.Http.Database.DB
|
|
|
|
var servidor models.Servidor
|
|
if err := db.First(&servidor, id).Error; err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "servidor no encontrado"})
|
|
}
|
|
if servidor.HostingerVpsID == nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "el servidor no tiene hostinger_vps_id configurado"})
|
|
}
|
|
|
|
client, err := hostingerClient()
|
|
if err != nil {
|
|
return c.Status(fiber.StatusFailedDependency).JSON(fiber.Map{"error": "No se encontró configuración activa de Hostinger"})
|
|
}
|
|
|
|
vps, err := client.GetVPSByID(*servidor.HostingerVpsID)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": err.Error()})
|
|
}
|
|
if vps.ID == 0 {
|
|
return c.Status(fiber.StatusUnprocessableEntity).JSON(fiber.Map{"error": "Hostinger no devolvio datos para ese VPS ID"})
|
|
}
|
|
|
|
updates := map[string]interface{}{
|
|
"hostinger_state": vps.State,
|
|
}
|
|
|
|
// IP principal (primer IPv4)
|
|
if len(vps.IPV4) > 0 && vps.IPV4[0].Address != "" {
|
|
updates["ip_servidor"] = vps.IPV4[0].Address
|
|
}
|
|
|
|
// RAM: Hostinger devuelve en MB → convertir a GB
|
|
if vps.RAMBytes > 0 {
|
|
gbRam := float64(vps.RAMBytes) / 1024.0
|
|
updates["ram"] = fmt.Sprintf("%.0f GB", gbRam)
|
|
}
|
|
|
|
// CPU cores
|
|
if vps.CPU > 0 {
|
|
updates["nucleos"] = fmt.Sprintf("%d", vps.CPU)
|
|
}
|
|
|
|
// Disco: Hostinger devuelve en MB (igual que memory) → convertir a GB
|
|
if vps.DiskBytes > 0 {
|
|
gbDisk := vps.DiskBytes / 1024
|
|
updates["disco"] = fmt.Sprintf("%d GB", gbDisk)
|
|
}
|
|
|
|
// Vencimiento: resolución en cascada
|
|
// 1) subscription_id que viene directo del VPS (el más confiable)
|
|
// 2) subscription_id ya guardado en el servidor local
|
|
// 3) fallback fuzzy por nombre
|
|
subID := vps.SubscriptionID
|
|
if subID == "" && servidor.HostingerSubscriptionID != "" {
|
|
subID = servidor.HostingerSubscriptionID
|
|
}
|
|
|
|
if subID != "" {
|
|
// Lookup directo — sin ambigüedad
|
|
if sub, sErr := client.GetSubscriptionByID(subID); sErr == nil {
|
|
// Para suscripciones activas con auto-renovación, expires_at viene vacío;
|
|
// en ese caso usar next_billing_at como fecha de vencimiento.
|
|
venc := sub.ExpiresAt
|
|
if venc == "" {
|
|
venc = sub.NextBillingAt
|
|
}
|
|
if venc != "" && len(venc) >= 10 {
|
|
updates["vencimiento"] = venc[:10]
|
|
}
|
|
updates["hostinger_subscription_id"] = subID
|
|
}
|
|
} else {
|
|
// Fallback: buscar por nombre si aún no tenemos subscription_id
|
|
if subs, sErr := client.GetOrders(); sErr == nil {
|
|
for _, sub := range subs {
|
|
venc := sub.ExpiresAt
|
|
if venc == "" {
|
|
venc = sub.NextBillingAt
|
|
}
|
|
if venc != "" && len(venc) >= 10 &&
|
|
strings.Contains(strings.ToLower(sub.Name), strings.ToLower(vps.Hostname)) {
|
|
updates["vencimiento"] = venc[:10]
|
|
updates["hostinger_subscription_id"] = sub.ID
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := db.Model(&servidor).Updates(updates).Error; err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "no se pudo guardar", "detalle": err.Error()})
|
|
}
|
|
|
|
db.First(&servidor, id)
|
|
return c.JSON(fiber.Map{"ok": true, "servidor": servidor, "campos_actualizados": updates})
|
|
}
|
|
|
|
func DeleteServidor(c *fiber.Ctx) error {
|
|
uid, err := strconv.ParseUint(c.Params("id"), 10, 32)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"message": "ID inválido",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
var m models.Servidor
|
|
m.ID = uint(uid)
|
|
|
|
if err := models.DeleteServidor(m); err != nil {
|
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
|
"message": "Error al eliminar el servidor",
|
|
"error": err.Error(),
|
|
})
|
|
}
|
|
|
|
return c.Status(fiber.StatusOK).JSON(fiber.Map{
|
|
"message": "Servidor eliminado exitosamente",
|
|
})
|
|
}
|
|
|
|
// GetServidorDashboard retorna un servidor con todas sus conexiones de BD
|
|
func GetServidorDashboard(c *fiber.Ctx) error {
|
|
servidorID := c.Params("id")
|
|
uid, err := strconv.ParseUint(servidorID, 10, 32)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"error": "ID inválido",
|
|
})
|
|
}
|
|
|
|
// Obtener servidor
|
|
var servidor models.Servidor
|
|
db := app.Http.Database.DB
|
|
if err := db.Preload("ProvServidor").Preload("TipoServidor").First(&servidor, uid).Error; err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
|
"error": "Servidor no encontrado",
|
|
})
|
|
}
|
|
|
|
// Obtener conexiones de BD asociadas
|
|
var conexiones []models.ConxDb
|
|
if err := db.Preload("Servidor").Preload("TipoDb").Where("servidor_id = ?", uid).Find(&conexiones).Error; err != nil {
|
|
conexiones = []models.ConxDb{}
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"servidor": servidor,
|
|
"conexiones": conexiones,
|
|
})
|
|
}
|
|
|
|
// PingConexion tests the connection to a database and returns the response time
|
|
func PingConexion(c *fiber.Ctx) error {
|
|
conexionID := c.Params("id")
|
|
uid, err := strconv.ParseUint(conexionID, 10, 32)
|
|
if err != nil {
|
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
|
"exitoso": false,
|
|
"error": "ID inválido",
|
|
})
|
|
}
|
|
|
|
var conexion models.ConxDb
|
|
db := app.Http.Database.DB
|
|
if err := db.Preload("TipoDb").Preload("Servidor").First(&conexion, uid).Error; err != nil {
|
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
|
"exitoso": false,
|
|
"error": "Conexión no encontrada",
|
|
})
|
|
}
|
|
|
|
host := conexion.HostEfectivo()
|
|
puerto := conexion.Puerto
|
|
|
|
// Primero intentar conexión real a la BD (más fiable que solo TCP)
|
|
inicio := time.Now()
|
|
testErr := services.TestDBConnection(conexion)
|
|
if testErr == nil {
|
|
return c.JSON(fiber.Map{
|
|
"exitoso": true,
|
|
"tiempo": time.Since(inicio).Milliseconds(),
|
|
"host": host,
|
|
"puerto": puerto,
|
|
"tipo": conexion.TipoDb.Nombre,
|
|
"error": "",
|
|
})
|
|
}
|
|
|
|
// Si la BD no conecta, probar al menos TCP (puerto abierto)
|
|
tcpAddr := net.JoinHostPort(host, puerto)
|
|
inicioTCP := time.Now()
|
|
conn, errTCP := net.DialTimeout("tcp", tcpAddr, 3*time.Second)
|
|
if errTCP == nil {
|
|
conn.Close()
|
|
return c.JSON(fiber.Map{
|
|
"exitoso": false,
|
|
"tiempo": time.Since(inicioTCP).Milliseconds(),
|
|
"host": host,
|
|
"puerto": puerto,
|
|
"tipo": conexion.TipoDb.Nombre,
|
|
// Puerto abierto pero la BD rechaza la conexión
|
|
"error": fmt.Sprintf("Puerto %s abierto pero la BD no responde: %s", puerto, testErr.Error()),
|
|
})
|
|
}
|
|
|
|
return c.JSON(fiber.Map{
|
|
"exitoso": false,
|
|
"tiempo": int64(0),
|
|
"host": host,
|
|
"puerto": puerto,
|
|
"tipo": conexion.TipoDb.Nombre,
|
|
"error": fmt.Sprintf("No se puede alcanzar %s:%s — verifica que el host sea accesible desde este servidor. Tip: si el DB solo escucha en localhost del servidor, configura el campo 'Host' de la conexión.", host, puerto),
|
|
})
|
|
}
|