This commit is contained in:
Lizandro Guarnizo
2026-05-26 10:05:01 -05:00
parent 6bcbe58a9a
commit a489844c73
4 changed files with 159 additions and 109 deletions
-11
View File
@@ -49,10 +49,6 @@ func main() {
migrations.MigratePortal() migrations.MigratePortal()
// Crear tablas de integraciones y pasarelas si no existen // Crear tablas de integraciones y pasarelas si no existen
app.Http.Database.DB.AutoMigrate( app.Http.Database.DB.AutoMigrate(
// Tablas base del sistema (columnas nuevas)
&models.Users{},
&models.Roles{},
&models.Submodules{},
&models.QueryHistory{}, &models.QueryHistory{},
&models.HostingerConfig{}, &models.HostingerConfig{},
&models.CloudflareConfig{}, &models.CloudflareConfig{},
@@ -70,7 +66,6 @@ func main() {
// Notificaciones // Notificaciones
&models.NotifEventoConfig{}, &models.NotifEventoConfig{},
&models.SistemaNotificacion{}, &models.SistemaNotificacion{},
&models.ServidorAlertaUmbral{},
// Shield // Shield
&models.ShieldConfig{}, &models.ShieldConfig{},
// Partner // Partner
@@ -80,12 +75,6 @@ func main() {
&models.PortalPasswordResetToken{}, &models.PortalPasswordResetToken{},
// Integración VCard API Admin // Integración VCard API Admin
&models.VcardApiConfig{}, &models.VcardApiConfig{},
// Integración Coolify
&models.CoolifyConfig{},
// Servidores: nuevos campos de agente + tabla join de integraciones
&models.Servidor{},
&models.ConxDb{},
&models.ProvServidor{},
) )
// Seed automático (idempotente) de módulos del sistema // Seed automático (idempotente) de módulos del sistema
migrations.SeedRenovaciones() migrations.SeedRenovaciones()
+86 -45
View File
@@ -87,7 +87,7 @@
</div> </div>
<span :class="statusClass(app.status)" <span :class="statusClass(app.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0" class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
x-text="app.status || 'unknown'"> x-text="statusLabel(app.status)">
</span> </span>
</div> </div>
@@ -107,42 +107,49 @@
<!-- Acciones --> <!-- Acciones -->
<div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100"> <div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
<button @click="appAction(app.uuid, 'start')" <!-- Start: solo cuando está detenida / desconocida -->
<button x-show="!isRunning(app.status) && !isTransitioning(app.status)"
@click="appAction(app.uuid, 'start')"
:disabled="actionLoading[app.uuid]" :disabled="actionLoading[app.uuid]"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition disabled:opacity-50"> class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-green-100 text-green-700 hover:bg-green-200 transition disabled:opacity-50 font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 16 16"><path d="M11.596 8.697l-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 16 16"><path d="M11.596 8.697l-6.363 3.692c-.54.313-1.233-.066-1.233-.697V4.308c0-.63.692-1.01 1.233-.696l6.363 3.692a.802.802 0 0 1 0 1.393z"/></svg>
Start Start
</button> </button>
<button @click="appAction(app.uuid, 'stop')" <!-- Stop: solo cuando está corriendo -->
:disabled="actionLoading[app.uuid]" <button x-show="isRunning(app.status) || isTransitioning(app.status)"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-50"> @click="appAction(app.uuid, 'stop')"
:disabled="actionLoading[app.uuid] || isTransitioning(app.status)"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition disabled:opacity-50 font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 16 16"><path d="M5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5A1.5 1.5 0 0 1 5 3.5z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="currentColor" viewBox="0 0 16 16"><path d="M5 3.5h6A1.5 1.5 0 0 1 12.5 5v6a1.5 1.5 0 0 1-1.5 1.5H5A1.5 1.5 0 0 1 3.5 11V5A1.5 1.5 0 0 1 5 3.5z"/></svg>
Stop Stop
</button> </button>
<button @click="appAction(app.uuid, 'restart')" <!-- Restart: solo cuando está corriendo -->
<button x-show="isRunning(app.status)"
@click="appAction(app.uuid, 'restart')"
:disabled="actionLoading[app.uuid]" :disabled="actionLoading[app.uuid]"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-yellow-50 text-yellow-700 hover:bg-yellow-100 transition disabled:opacity-50"> class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-yellow-100 text-yellow-700 hover:bg-yellow-200 transition disabled:opacity-50 font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 0 0 4.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 0 1-15.357-2m15.357 2H15"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 0 0 4.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 0 1-15.357-2m15.357 2H15"/></svg>
Restart Restart
</button> </button>
<!-- Deploy: siempre disponible -->
<button @click="deployApp(app.uuid)" <button @click="deployApp(app.uuid)"
:disabled="actionLoading[app.uuid]" :disabled="actionLoading[app.uuid]"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-700 hover:bg-indigo-100 transition disabled:opacity-50"> class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-indigo-100 text-indigo-700 hover:bg-indigo-200 transition disabled:opacity-50 font-medium">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-6l-2 2m0 0l2-2m-2 2h14M3 9l3-3 3 3"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 17v-6l-2 2m0 0l2-2m-2 2h14M3 9l3-3 3 3"/></svg>
Deploy Deploy
</button> </button>
<button @click="openAppLogs(app)" <button @click="openAppLogs(app)"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition"> class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5.586a1 1 0 0 1 .707.293l5.414 5.414a1 1 0 0 1 .293.707V19a2 2 0 0 1-2 2z"/></svg>
Logs Logs
</button> </button>
<button @click="openAppEnvs(app)" <button @click="openAppEnvs(app)"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition"> class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 0 0 2.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 0 0 1.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 0 0-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 0 0-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 0 0-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 0 0-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 0 0 1.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/></svg>
Envs Envs
</button> </button>
<button @click="openAppDeployments(app)" <button @click="openAppDeployments(app)"
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition"> class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg> <svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M12 8v4l3 3m6-3a9 9 0 1 1-18 0 9 9 0 0 1 18 0z"/></svg>
Historial Historial
</button> </button>
@@ -223,19 +230,22 @@
</div> </div>
<span :class="statusClass(svc.status)" <span :class="statusClass(svc.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0" class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
x-text="svc.status || 'unknown'"> x-text="statusLabel(svc.status)">
</span> </span>
</div> </div>
<div x-show="svc.description" class="text-xs text-gray-500" x-text="svc.description"></div> <div x-show="svc.description" class="text-xs text-gray-500" x-text="svc.description"></div>
<div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100"> <div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
<button @click="serviceAction(svc.uuid, 'start')" :disabled="actionLoading[svc.uuid]" <button x-show="!isRunning(svc.status) && !isTransitioning(svc.status)"
class="text-xs px-2.5 py-1 rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition disabled:opacity-50">Start</button> @click="serviceAction(svc.uuid, 'start')" :disabled="actionLoading[svc.uuid]"
<button @click="serviceAction(svc.uuid, 'stop')" :disabled="actionLoading[svc.uuid]" class="text-xs px-2.5 py-1 rounded-lg bg-green-100 text-green-700 hover:bg-green-200 transition disabled:opacity-50 font-medium">Start</button>
class="text-xs px-2.5 py-1 rounded-lg bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-50">Stop</button> <button x-show="isRunning(svc.status) || isTransitioning(svc.status)"
<button @click="serviceAction(svc.uuid, 'restart')" :disabled="actionLoading[svc.uuid]" @click="serviceAction(svc.uuid, 'stop')" :disabled="actionLoading[svc.uuid] || isTransitioning(svc.status)"
class="text-xs px-2.5 py-1 rounded-lg bg-yellow-50 text-yellow-700 hover:bg-yellow-100 transition disabled:opacity-50">Restart</button> class="text-xs px-2.5 py-1 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition disabled:opacity-50 font-medium">Stop</button>
<button x-show="isRunning(svc.status)"
@click="serviceAction(svc.uuid, 'restart')" :disabled="actionLoading[svc.uuid]"
class="text-xs px-2.5 py-1 rounded-lg bg-yellow-100 text-yellow-700 hover:bg-yellow-200 transition disabled:opacity-50 font-medium">Restart</button>
<button @click="openServiceEnvs(svc)" <button @click="openServiceEnvs(svc)"
class="text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition">Envs</button> class="text-xs px-2.5 py-1 rounded-lg bg-gray-100 text-gray-600 hover:bg-gray-200 transition">Envs</button>
</div> </div>
<div x-show="actionLoading[svc.uuid]" class="text-xs text-indigo-500 flex items-center gap-1"> <div x-show="actionLoading[svc.uuid]" class="text-xs text-indigo-500 flex items-center gap-1">
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> <svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
@@ -259,7 +269,7 @@
</div> </div>
<span :class="statusClass(db.status)" <span :class="statusClass(db.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0" class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
x-text="db.status || 'unknown'"> x-text="statusLabel(db.status)">
</span> </span>
</div> </div>
<div class="text-xs text-gray-500 space-y-0.5"> <div class="text-xs text-gray-500 space-y-0.5">
@@ -267,12 +277,15 @@
<div x-show="db.name">DB: <span class="font-medium text-gray-700" x-text="db.postgres_db || db.mysql_database || db.mongo_database || ''"></span></div> <div x-show="db.name">DB: <span class="font-medium text-gray-700" x-text="db.postgres_db || db.mysql_database || db.mongo_database || ''"></span></div>
</div> </div>
<div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100"> <div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
<button @click="dbAction(db.uuid, 'start')" :disabled="actionLoading[db.uuid]" <button x-show="!isRunning(db.status) && !isTransitioning(db.status)"
class="text-xs px-2.5 py-1 rounded-lg bg-green-50 text-green-700 hover:bg-green-100 transition disabled:opacity-50">Start</button> @click="dbAction(db.uuid, 'start')" :disabled="actionLoading[db.uuid]"
<button @click="dbAction(db.uuid, 'stop')" :disabled="actionLoading[db.uuid]" class="text-xs px-2.5 py-1 rounded-lg bg-green-100 text-green-700 hover:bg-green-200 transition disabled:opacity-50 font-medium">Start</button>
class="text-xs px-2.5 py-1 rounded-lg bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-50">Stop</button> <button x-show="isRunning(db.status) || isTransitioning(db.status)"
<button @click="dbAction(db.uuid, 'restart')" :disabled="actionLoading[db.uuid]" @click="dbAction(db.uuid, 'stop')" :disabled="actionLoading[db.uuid] || isTransitioning(db.status)"
class="text-xs px-2.5 py-1 rounded-lg bg-yellow-50 text-yellow-700 hover:bg-yellow-100 transition disabled:opacity-50">Restart</button> class="text-xs px-2.5 py-1 rounded-lg bg-red-100 text-red-700 hover:bg-red-200 transition disabled:opacity-50 font-medium">Stop</button>
<button x-show="isRunning(db.status)"
@click="dbAction(db.uuid, 'restart')" :disabled="actionLoading[db.uuid]"
class="text-xs px-2.5 py-1 rounded-lg bg-yellow-100 text-yellow-700 hover:bg-yellow-200 transition disabled:opacity-50 font-medium">Restart</button>
</div> </div>
<div x-show="actionLoading[db.uuid]" class="text-xs text-indigo-500 flex items-center gap-1"> <div x-show="actionLoading[db.uuid]" class="text-xs text-indigo-500 flex items-center gap-1">
<svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg> <svg class="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24"><circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"/><path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
@@ -442,17 +455,6 @@
</button> </button>
</div> </div>
</div> </div>
<!-- Webhook URL para notificaciones -->
<div class="px-6 pb-4 border-t pt-4">
<p class="text-xs text-gray-500 mb-1 font-medium">URL Webhook (notificaciones)</p>
<p class="text-xs text-gray-400 mb-2">Copia esta URL en <strong>Coolify → Settings → Notifications → Webhook</strong> para recibir eventos de deployment/restart.</p>
<div class="flex items-center gap-2">
<code class="flex-1 text-xs bg-gray-50 border rounded px-3 py-1.5 truncate select-all"
x-text="window.location.origin + '/webhooks/coolify'"></code>
<button @click="navigator.clipboard.writeText(window.location.origin + '/webhooks/coolify').then(() => flash('URL copiada'))"
class="text-xs px-3 py-1.5 rounded-lg border text-gray-600 hover:bg-gray-50 shrink-0">Copiar</button>
</div>
</div>
<div x-show="cfgMsg" class="px-6 pb-4 text-sm" :class="cfgOk ? 'text-green-600' : 'text-red-600'" x-text="cfgMsg"></div> <div x-show="cfgMsg" class="px-6 pb-4 text-sm" :class="cfgOk ? 'text-green-600' : 'text-red-600'" x-text="cfgMsg"></div>
</div> </div>
</div> </div>
@@ -743,18 +745,53 @@ document.addEventListener('alpine:init', () => {
catch { return dt; } catch { return dt; }
}, },
// Coolify puede devolver "running:healthy", "exited:0", etc. — extrae la parte base.
normalizeStatus(status) {
if (!status) return '';
return status.toLowerCase().split(':')[0].trim();
},
isRunning(status) {
const s = this.normalizeStatus(status);
return s === 'running';
},
isStopped(status) {
const s = this.normalizeStatus(status);
return s === 'stopped' || s === 'exited' || s === 'idle';
},
isTransitioning(status) {
const s = this.normalizeStatus(status);
return s === 'starting' || s === 'stopping' || s === 'restarting';
},
statusClass(status) { statusClass(status) {
if (!status) return 'bg-gray-100 text-gray-500'; const s = this.normalizeStatus(status);
const s = status.toLowerCase(); if (!s) return 'bg-gray-100 text-gray-500';
if (s === 'running') return 'bg-green-100 text-green-700'; if (s === 'running') return 'bg-green-100 text-green-700';
if (s === 'stopped') return 'bg-red-100 text-red-600'; if (s === 'stopped') return 'bg-red-100 text-red-600';
if (s === 'exited') return 'bg-red-100 text-red-600'; if (s === 'exited') return 'bg-red-100 text-red-600';
if (s === 'starting') return 'bg-yellow-100 text-yellow-700'; if (s === 'idle') return 'bg-gray-100 text-gray-500';
if (s === 'starting') return 'bg-yellow-100 text-yellow-700';
if (s === 'stopping') return 'bg-yellow-100 text-yellow-700';
if (s === 'restarting') return 'bg-yellow-100 text-yellow-700'; if (s === 'restarting') return 'bg-yellow-100 text-yellow-700';
if (s === 'degraded') return 'bg-orange-100 text-orange-700'; if (s === 'degraded') return 'bg-orange-100 text-orange-700';
if (s === 'unhealthy') return 'bg-orange-100 text-orange-700';
return 'bg-gray-100 text-gray-500'; return 'bg-gray-100 text-gray-500';
}, },
statusLabel(status) {
if (!status) return 'desconocido';
// Muestra el status original sin el sufijo de health check
const [base, extra] = status.toLowerCase().split(':');
const labels = { running:'corriendo', stopped:'detenido', exited:'detenido', idle:'inactivo',
starting:'iniciando', stopping:'deteniendo', restarting:'reiniciando',
degraded:'degradado', unhealthy:'no saludable' };
const baseLabel = labels[base] || base;
return extra ? `${baseLabel} (${extra})` : baseLabel;
},
deployStatusClass(status) { deployStatusClass(status) {
if (!status) return 'bg-gray-100 text-gray-500'; if (!status) return 'bg-gray-100 text-gray-500';
const s = status.toLowerCase(); const s = status.toLowerCase();
@@ -861,6 +898,8 @@ document.addEventListener('alpine:init', () => {
const r = await fetch('/app/coolify/apps' + qs); const r = await fetch('/app/coolify/apps' + qs);
const j = await r.json(); const j = await r.json();
this.apps = Array.isArray(j) ? j : (j.data || []); this.apps = Array.isArray(j) ? j : (j.data || []);
// Pre-init actionLoading para reactividad garantizada en Alpine.js
const al = {}; this.apps.forEach(a => { al[a.uuid] = false; }); this.actionLoading = al;
this.lastFetch = this.fmtDate(new Date().toISOString()); this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando apps: ' + e.message, false); } } catch(e) { this.flash('Error cargando apps: ' + e.message, false); }
finally { this.loading = false; } finally { this.loading = false; }
@@ -883,6 +922,7 @@ document.addEventListener('alpine:init', () => {
const r = await fetch('/app/coolify/services'); const r = await fetch('/app/coolify/services');
const j = await r.json(); const j = await r.json();
this.services = Array.isArray(j) ? j : (j.data || []); this.services = Array.isArray(j) ? j : (j.data || []);
const sl = { ...this.actionLoading }; this.services.forEach(s => { if (!(s.uuid in sl)) sl[s.uuid] = false; }); this.actionLoading = sl;
this.lastFetch = this.fmtDate(new Date().toISOString()); this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando servicios: ' + e.message, false); } } catch(e) { this.flash('Error cargando servicios: ' + e.message, false); }
finally { this.loading = false; } finally { this.loading = false; }
@@ -894,6 +934,7 @@ document.addEventListener('alpine:init', () => {
const r = await fetch('/app/coolify/databases'); const r = await fetch('/app/coolify/databases');
const j = await r.json(); const j = await r.json();
this.databases = Array.isArray(j) ? j : (j.data || []); this.databases = Array.isArray(j) ? j : (j.data || []);
const dl = { ...this.actionLoading }; this.databases.forEach(d => { if (!(d.uuid in dl)) dl[d.uuid] = false; }); this.actionLoading = dl;
this.lastFetch = this.fmtDate(new Date().toISOString()); this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando bases de datos: ' + e.message, false); } } catch(e) { this.flash('Error cargando bases de datos: ' + e.message, false); }
finally { this.loading = false; } finally { this.loading = false; }
+5 -24
View File
@@ -446,31 +446,12 @@
<tbody> <tbody>
<template x-for="m in items" :key="m.id || m._id"> <template x-for="m in items" :key="m.id || m._id">
<tr class="hover:bg-gray-50 border-b border-gray-100"> <tr class="hover:bg-gray-50 border-b border-gray-100">
<td class="py-2 px-3 font-medium" x-text="m.titulo || m.title || '—'"></td>
<td class="py-2 px-3 text-xs text-gray-500" x-text="(m.user && m.user.email) ? m.user.email : (m.user_id || '—')"></td>
<td class="py-2 px-3 text-xs text-gray-500" x-text="(m.vcard && m.vcard.nombre) ? m.vcard.nombre : '—'"></td>
<td class="py-2 px-3"> <td class="py-2 px-3">
<div class="font-medium" x-text="m.titulo || m.title || '—'"></div> <span :class="m.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
<a x-show="m.url || m.unico" :href="m.url || ('https://web.u-site.app/w/' + m.unico)" target="_blank" class="text-xs px-2 py-0.5 rounded-full" x-text="m.activo ? 'Activa' : 'Inactiva'"></span>
class="text-xs text-blue-500 hover:underline font-mono" x-text="m.unico || m.url || ''"></a>
</td>
<td class="py-2 px-3 text-xs text-gray-500">
<div x-text="(m.user && m.user.name) ? m.user.name : '—'"></div>
<div class="text-gray-400" x-text="(m.user && m.user.email) ? m.user.email : ''"></div>
</td>
<td class="py-2 px-3 text-xs text-gray-500">
<div x-text="(m.vcard && m.vcard.nombre) ? m.vcard.nombre : '—'"></div>
<span x-show="m.vcard" :class="(m.vcard && (m.vcard.estado === true || m.vcard.estado === 'true')) ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'"
class="text-xs px-1.5 py-0.5 rounded-full"
x-text="(m.vcard && (m.vcard.estado === true || m.vcard.estado === 'true')) ? 'VCard activa' : 'VCard inactiva'"></span>
</td>
<td class="py-2 px-3">
<template x-if="m.activo !== undefined && m.activo !== null">
<span :class="m.activo ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'"
class="text-xs px-2 py-0.5 rounded-full" x-text="m.activo ? 'Activa' : 'Inactiva'"></span>
</template>
<template x-if="m.activo === undefined || m.activo === null">
<span :class="(m.vcard && (m.vcard.estado === true || m.vcard.estado === 'true')) ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="text-xs px-2 py-0.5 rounded-full"
x-text="(m.vcard && (m.vcard.estado === true || m.vcard.estado === 'true')) ? 'Activa' : 'Sin estado'"></span>
</template>
</td> </td>
</tr> </tr>
</template> </template>
+68 -29
View File
@@ -12,9 +12,13 @@ import (
"github.com/sujit-baniya/fiber-boilerplate/pkg/models" "github.com/sujit-baniya/fiber-boilerplate/pkg/models"
) )
// ─── helpers internos ────────────────────────────────────────────────────────
// coolifyDo ejecuta una petición a la API de Coolify y devuelve el body crudo.
func coolifyDo(method, endpoint string, reqBody io.Reader, contentType string, cfg *models.CoolifyConfig) ([]byte, int, error) { func coolifyDo(method, endpoint string, reqBody io.Reader, contentType string, cfg *models.CoolifyConfig) ([]byte, int, error) {
base := strings.TrimRight(cfg.BaseURL, "/") base := strings.TrimRight(cfg.BaseURL, "/")
url := fmt.Sprintf("%s/api/v1%s", base, endpoint) url := fmt.Sprintf("%s/api/v1%s", base, endpoint)
client := &http.Client{Timeout: 30 * time.Second} client := &http.Client{Timeout: 30 * time.Second}
req, err := http.NewRequest(method, url, reqBody) req, err := http.NewRequest(method, url, reqBody)
if err != nil { if err != nil {
@@ -25,6 +29,7 @@ func coolifyDo(method, endpoint string, reqBody io.Reader, contentType string, c
if contentType != "" { if contentType != "" {
req.Header.Set("Content-Type", contentType) req.Header.Set("Content-Type", contentType)
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
@@ -34,6 +39,7 @@ func coolifyDo(method, endpoint string, reqBody io.Reader, contentType string, c
return body, resp.StatusCode, nil return body, resp.StatusCode, nil
} }
// coolifyProxy resuelve config, aplica qs de la request y responde al frontend.
func coolifyProxy(c *fiber.Ctx, method, endpoint string) error { func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
cfg, err := models.GetCoolifyConfig() cfg, err := models.GetCoolifyConfig()
if err != nil { if err != nil {
@@ -42,33 +48,34 @@ func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
if !cfg.Activo { if !cfg.Activo {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La integración Coolify está inactiva"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "La integración Coolify está inactiva"})
} }
ep := endpoint ep := endpoint
if qs := string(c.Request().URI().QueryString()); qs != "" { if qs := string(c.Request().URI().QueryString()); qs != "" {
ep = endpoint + "?" + qs ep = endpoint + "?" + qs
} }
var bodyReader io.Reader var bodyReader io.Reader
ct := "" ct := ""
if raw := c.Body(); len(raw) > 0 { if raw := c.Body(); len(raw) > 0 {
bodyReader = strings.NewReader(string(raw)) bodyReader = strings.NewReader(string(raw))
ct = c.Get("Content-Type", "application/json") ct = c.Get("Content-Type", "application/json")
} }
body, status, err := coolifyDo(method, ep, bodyReader, ct, cfg) body, status, err := coolifyDo(method, ep, bodyReader, ct, cfg)
if err != nil { if err != nil {
// No devolver 502: Cloudflare lo intercepta y oculta el error al frontend return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No se pudo conectar a Coolify: " + err.Error()})
} }
var result json.RawMessage var result json.RawMessage
if err := json.Unmarshal(body, &result); err != nil { if err := json.Unmarshal(body, &result); err != nil {
result = json.RawMessage(fmt.Sprintf(`{"raw":%q}`, string(body))) result = json.RawMessage(fmt.Sprintf(`{"raw":%q}`, string(body)))
} }
// Si Coolify devuelve 5xx, bajar a 400 para que Cloudflare no lo intercepte
if status >= 500 {
status = fiber.StatusBadRequest
}
c.Status(status) c.Status(status)
return c.JSON(result) return c.JSON(result)
} }
// ─── Página principal ─────────────────────────────────────────────────────────
func CoolifyIndex(c *fiber.Ctx) error { func CoolifyIndex(c *fiber.Ctx) error {
cfg, _ := models.GetCoolifyConfig() cfg, _ := models.GetCoolifyConfig()
return c.Render("coolify", fiber.Map{ return c.Render("coolify", fiber.Map{
@@ -78,6 +85,8 @@ func CoolifyIndex(c *fiber.Ctx) error {
}, "layouts/main") }, "layouts/main")
} }
// ─── Configuración ────────────────────────────────────────────────────────────
func CoolifyGetConfig(c *fiber.Ctx) error { func CoolifyGetConfig(c *fiber.Ctx) error {
cfg, err := models.GetCoolifyConfig() cfg, err := models.GetCoolifyConfig()
if err != nil { if err != nil {
@@ -118,6 +127,7 @@ func CoolifySaveConfig(c *fiber.Ctx) error {
return c.JSON(fiber.Map{"ok": true, "config": cfg}) return c.JSON(fiber.Map{"ok": true, "config": cfg})
} }
// CoolifyTestConnection verifica conectividad con /health (no requiere auth).
func CoolifyTestConnection(c *fiber.Ctx) error { func CoolifyTestConnection(c *fiber.Ctx) error {
cfg, err := models.GetCoolifyConfig() cfg, err := models.GetCoolifyConfig()
if err != nil { if err != nil {
@@ -127,7 +137,7 @@ func CoolifyTestConnection(c *fiber.Ctx) error {
client := &http.Client{Timeout: 10 * time.Second} client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(base + "/api/health") resp, err := client.Get(base + "/api/health")
if err != nil { if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "No se pudo conectar a Coolify: " + err.Error()}) return c.Status(fiber.StatusBadGateway).JSON(fiber.Map{"error": err.Error()})
} }
defer resp.Body.Close() defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
@@ -139,142 +149,171 @@ func CoolifyTestConnection(c *fiber.Ctx) error {
return c.JSON(result) return c.JSON(result)
} }
// ─── Applications ─────────────────────────────────────────────────────────────
func CoolifyListApplications(c *fiber.Ctx) error { func CoolifyListApplications(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/applications") return coolifyProxy(c, http.MethodGet, "/applications")
} }
func CoolifyGetApplication(c *fiber.Ctx) error { func CoolifyGetApplication(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")) return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid"))
} }
func CoolifyApplicationLogs(c *fiber.Ctx) error { func CoolifyApplicationLogs(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/logs") return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/logs")
} }
func CoolifyApplicationEnvs(c *fiber.Ctx) error { func CoolifyApplicationEnvs(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/envs") return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/envs")
} }
func CoolifyApplicationEnvCreate(c *fiber.Ctx) error { func CoolifyApplicationEnvCreate(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodPost, "/applications/"+c.Params("uuid")+"/envs") return coolifyProxy(c, http.MethodPost, "/applications/"+c.Params("uuid")+"/envs")
} }
func CoolifyApplicationEnvUpdateBulk(c *fiber.Ctx) error { func CoolifyApplicationEnvUpdateBulk(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodPatch, "/applications/"+c.Params("uuid")+"/envs") return coolifyProxy(c, http.MethodPatch, "/applications/"+c.Params("uuid")+"/envs")
} }
func CoolifyApplicationEnvDelete(c *fiber.Ctx) error { func CoolifyApplicationEnvDelete(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodDelete, "/applications/"+c.Params("uuid")+"/envs/"+c.Params("env_id")) return coolifyProxy(c, http.MethodDelete, "/applications/"+c.Params("uuid")+"/envs/"+c.Params("env_id"))
} }
func CoolifyApplicationStart(c *fiber.Ctx) error { func CoolifyApplicationStart(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodPost, "/applications/"+c.Params("uuid")+"/start") return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/start")
} }
func CoolifyApplicationStop(c *fiber.Ctx) error { func CoolifyApplicationStop(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodPost, "/applications/"+c.Params("uuid")+"/stop") return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/stop")
} }
func CoolifyApplicationRestart(c *fiber.Ctx) error { func CoolifyApplicationRestart(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodPost, "/applications/"+c.Params("uuid")+"/restart") return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/restart")
} }
func CoolifyApplicationDeployments(c *fiber.Ctx) error { func CoolifyApplicationDeployments(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/deployments") return coolifyProxy(c, http.MethodGet, "/applications/"+c.Params("uuid")+"/deployments")
} }
func CoolifyApplicationDeploy(c *fiber.Ctx) error { func CoolifyApplicationDeploy(c *fiber.Ctx) error {
// POST /api/v1/deploy?uuid={uuid}&force=false
return coolifyProxy(c, http.MethodPost, "/deploy") return coolifyProxy(c, http.MethodPost, "/deploy")
} }
// ─── Servers ──────────────────────────────────────────────────────────────────
func CoolifyListServers(c *fiber.Ctx) error { func CoolifyListServers(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/servers") return coolifyProxy(c, http.MethodGet, "/servers")
} }
func CoolifyGetServer(c *fiber.Ctx) error { func CoolifyGetServer(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")) return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid"))
} }
func CoolifyServerResources(c *fiber.Ctx) error { func CoolifyServerResources(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/resources") return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/resources")
} }
func CoolifyServerDomains(c *fiber.Ctx) error { func CoolifyServerDomains(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/domains") return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/domains")
} }
func CoolifyServerValidate(c *fiber.Ctx) error { func CoolifyServerValidate(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/validate") return coolifyProxy(c, http.MethodGet, "/servers/"+c.Params("uuid")+"/validate")
} }
// ─── Services ─────────────────────────────────────────────────────────────────
func CoolifyListServices(c *fiber.Ctx) error { func CoolifyListServices(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/services") return coolifyProxy(c, http.MethodGet, "/services")
} }
func CoolifyGetService(c *fiber.Ctx) error { func CoolifyGetService(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")) return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid"))
} }
func CoolifyServiceStart(c *fiber.Ctx) error { func CoolifyServiceStart(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/start") return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/start")
} }
func CoolifyServiceStop(c *fiber.Ctx) error { func CoolifyServiceStop(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/stop") return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/stop")
} }
func CoolifyServiceRestart(c *fiber.Ctx) error { func CoolifyServiceRestart(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/restart") return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/restart")
} }
func CoolifyServiceEnvs(c *fiber.Ctx) error { func CoolifyServiceEnvs(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/envs") return coolifyProxy(c, http.MethodGet, "/services/"+c.Params("uuid")+"/envs")
} }
func CoolifyServiceEnvUpdateBulk(c *fiber.Ctx) error { func CoolifyServiceEnvUpdateBulk(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodPatch, "/services/"+c.Params("uuid")+"/envs") return coolifyProxy(c, http.MethodPatch, "/services/"+c.Params("uuid")+"/envs")
} }
// ─── Databases ────────────────────────────────────────────────────────────────
func CoolifyListDatabases(c *fiber.Ctx) error { func CoolifyListDatabases(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/databases") return coolifyProxy(c, http.MethodGet, "/databases")
} }
func CoolifyGetDatabase(c *fiber.Ctx) error { func CoolifyGetDatabase(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")) return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid"))
} }
func CoolifyDatabaseStart(c *fiber.Ctx) error { func CoolifyDatabaseStart(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/start") return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/start")
} }
func CoolifyDatabaseStop(c *fiber.Ctx) error { func CoolifyDatabaseStop(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/stop") return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/stop")
} }
func CoolifyDatabaseRestart(c *fiber.Ctx) error { func CoolifyDatabaseRestart(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/restart") return coolifyProxy(c, http.MethodGet, "/databases/"+c.Params("uuid")+"/restart")
} }
// ─── Projects ─────────────────────────────────────────────────────────────────
func CoolifyListProjects(c *fiber.Ctx) error { func CoolifyListProjects(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/projects") return coolifyProxy(c, http.MethodGet, "/projects")
} }
func CoolifyGetProject(c *fiber.Ctx) error { func CoolifyGetProject(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/projects/"+c.Params("uuid")) return coolifyProxy(c, http.MethodGet, "/projects/"+c.Params("uuid"))
} }
func CoolifyProjectEnvironments(c *fiber.Ctx) error { func CoolifyProjectEnvironments(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/projects/"+c.Params("uuid")+"/environments") return coolifyProxy(c, http.MethodGet, "/projects/"+c.Params("uuid")+"/environments")
} }
// ─── Deployments ──────────────────────────────────────────────────────────────
func CoolifyListDeployments(c *fiber.Ctx) error { func CoolifyListDeployments(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/deployments") return coolifyProxy(c, http.MethodGet, "/deployments")
} }
func CoolifyGetDeployment(c *fiber.Ctx) error { func CoolifyGetDeployment(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/deployments/"+c.Params("uuid")) return coolifyProxy(c, http.MethodGet, "/deployments/"+c.Params("uuid"))
} }
// ─── Teams ────────────────────────────────────────────────────────────────────
func CoolifyListTeams(c *fiber.Ctx) error { func CoolifyListTeams(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/teams") return coolifyProxy(c, http.MethodGet, "/teams")
} }
func CoolifyCurrentTeam(c *fiber.Ctx) error { func CoolifyCurrentTeam(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/teams/current") return coolifyProxy(c, http.MethodGet, "/teams/current")
} }
func CoolifyTeamMembers(c *fiber.Ctx) error { func CoolifyTeamMembers(c *fiber.Ctx) error {
return coolifyProxy(c, http.MethodGet, "/teams/current/members") return coolifyProxy(c, http.MethodGet, "/teams/current/members")
} }
// CoolifyWebhook recibe notificaciones push de Coolify (deployment events, etc.) // CoolifyWebhook recibe notificaciones push de Coolify (deployments, etc.)
// Configura en Coolify UI → Settings → Notifications → Webhook → URL: https://admin.u-site.app/app/coolify/webhook // Acepta el payload y devuelve 200; el procesamiento puede extenderse aquí.
func CoolifyWebhook(c *fiber.Ctx) error { func CoolifyWebhook(c *fiber.Ctx) error {
var payload map[string]interface{} return c.SendStatus(fiber.StatusOK)
if err := c.BodyParser(&payload); err != nil {
// acepta también body vacío o texto plano
payload = map[string]interface{}{"raw": string(c.Body())}
}
// Log del evento para debug
eventType := ""
if t, ok := payload["type"].(string); ok {
eventType = t
} else if t, ok := payload["event"].(string); ok {
eventType = t
}
_ = eventType // en el futuro: guardar en DB, enviar push, etc.
return c.JSON(fiber.Map{"ok": true})
} }