Files
soft_usite/resources/views/coolify.html
T
Lizandro GuarnizoandCopilot 39f4641540 fix: agrega coma faltante en método openAppDeployments del componente Alpine
El objeto Alpine.js tenía un } sin coma al final de openAppDeployments,
causando un error de sintaxis JS que impedía la inicialización del componente
y hacía que la página /app/coolify apareciera completamente en blanco.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-26 14:20:55 -05:00

1303 lines
78 KiB
HTML

<div x-data="coolifyApp" class="bg-white rounded-lg shadow">
<!-- Overlay de carga -->
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">Coolify</h1>
<p class="text-xs text-slate-500 mt-0.5">Gestión completa de aplicaciones, servicios, bases de datos, servidores y despliegues a través de la API de Coolify.</p>
</div>
<button @click="openConfigModal()"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
style="background-color:#6366f1"
onmouseover="this.style.backgroundColor='#4f46e5'"
onmouseout="this.style.backgroundColor='#6366f1'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.325.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
Configurar API
</button>
</div>
<!-- Alerta sin config -->
<div x-show="!hasConfig" class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700">
<strong>Sin configuración activa.</strong> Haz clic en "Configurar API" para ingresar la URL base y el token de tu instancia de Coolify.
El token lo generas en <strong>Coolify → Keys & Tokens → API tokens</strong>.
</div>
<!-- Alerta flash -->
<div x-show="flashMsg" x-transition
:class="flashOk ? 'bg-green-50 border-green-200 text-green-700' : 'bg-red-50 border-red-200 text-red-700'"
class="mb-4 p-3 border rounded-lg text-sm flex items-center justify-between">
<span x-text="flashMsg"></span>
<button @click="flashMsg=''" class="ml-4 font-bold text-lg leading-none opacity-60 hover:opacity-100">&times;</button>
</div>
<!-- Tabs de navegación -->
<div class="border-b border-gray-200 mb-6">
<nav class="-mb-px flex flex-wrap gap-1 text-sm">
<template x-for="tab in tabs" :key="tab.id">
<button @click="setTab(tab.id)"
:class="activeTab===tab.id ? 'border-b-2 border-indigo-500 text-indigo-600 font-semibold' : 'text-gray-500 hover:text-gray-700'"
class="pb-2 px-3 transition-colors flex items-center gap-1.5"
x-text="tab.label">
</button>
</template>
</nav>
</div>
<!-- Barra de herramientas global -->
<div class="flex items-center gap-3 mb-5">
<button @click="loadTab()" :disabled="loading"
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm hover:bg-gray-50 transition disabled:opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="loading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99" />
</svg>
<span x-text="loading ? 'Cargando...' : 'Actualizar'"></span>
</button>
<!-- Filtro apps por tag -->
<div x-show="activeTab==='apps'" class="flex items-center gap-2">
<input x-model="appTagFilter" @keyup.enter="loadApps()" type="text" placeholder="Filtrar por tag..."
class="text-sm border rounded-lg px-3 py-1.5 w-40 focus:outline-none focus:ring-2 focus:ring-indigo-300">
<button @click="loadApps()" class="text-xs px-2 py-1.5 rounded border text-gray-500 hover:bg-gray-50">Buscar</button>
</div>
<span class="text-xs text-gray-400" x-show="lastFetch" x-text="'Actualizado: ' + lastFetch"></span>
</div>
<!-- ══════════════════════ TAB: APLICACIONES ══════════════════════ -->
<div x-show="activeTab==='apps'">
<div x-show="apps.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin aplicaciones. Haz clic en "Actualizar".</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="app in apps" :key="app.uuid">
<div class="border rounded-xl p-4 flex flex-col gap-3 hover:shadow-md transition">
<!-- Nombre + status -->
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="font-semibold text-sm truncate" x-text="app.name"></p>
<p class="text-xs text-gray-400 truncate" x-text="app.fqdn || app.uuid"></p>
</div>
<span :class="statusClass(app.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
x-text="actionStatusText(app.uuid, app.status)">
</span>
</div>
<!-- Repo -->
<div x-show="app.git_repository" class="text-xs text-gray-500 flex items-center gap-1 truncate">
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 shrink-0" fill="currentColor" viewBox="0 0 16 16">
<path d="M2 2.5A2.5 2.5 0 0 1 4.5 0h8.75a.75.75 0 0 1 .75.75v12.5a.75.75 0 0 1-.75.75h-2.5a.75.75 0 0 1 0-1.5h1.75v-2h-8a1 1 0 0 0-.714 1.7.75.75 0 1 1-1.072 1.05A2.495 2.495 0 0 1 2 11.5zm10.5-1h-8a1 1 0 0 0-1 1v6.708A2.486 2.486 0 0 1 4.5 9h8z"/>
</svg>
<span class="truncate" x-text="app.git_repository"></span>
</div>
<div x-show="app.git_repository" class="text-xs text-gray-400 pl-4" x-text="app.git_branch ? ('Branch: ' + app.git_branch) : ''"></div>
<!-- Build pack -->
<div x-show="app.build_pack" class="text-xs text-gray-400">
Build: <span class="font-medium text-gray-600 capitalize" x-text="app.build_pack"></span>
</div>
<!-- Acciones -->
<div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
<!-- Start: solo cuando está detenida / desconocida -->
<button x-show="!isRunning(app.status) && !isTransitioning(app.status)"
@click="appAction(app.uuid, 'start')"
:disabled="actionLoading[app.uuid]"
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>
Start
</button>
<!-- Stop: solo cuando está corriendo -->
<button x-show="isRunning(app.status) || isTransitioning(app.status)"
@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>
Stop
</button>
<!-- Restart: solo cuando está corriendo -->
<button x-show="isRunning(app.status)"
@click="appAction(app.uuid, 'restart')"
:disabled="actionLoading[app.uuid]"
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>
Restart
</button>
<!-- Deploy: siempre disponible -->
<button @click="deployApp(app.uuid)"
:disabled="actionLoading[app.uuid]"
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>
Deploy
</button>
<button @click="openAppLogs(app)"
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>
Logs
</button>
<button @click="openAppEnvs(app)"
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>
Envs
</button>
<button @click="openAppDeployments(app)"
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>
Historial
</button>
</div>
<!-- Spinner de acción individual -->
<div x-show="actionLoading[app.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>
<span x-text="actionSpinnerText(app.uuid)"></span>
</div>
</div>
</template>
</div>
</div>
<!-- ══════════════════════ TAB: SERVIDORES ══════════════════════ -->
<div x-show="activeTab==='servers'">
<div x-show="servers.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin servidores. Haz clic en "Actualizar".</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="srv in servers" :key="srv.uuid">
<div class="border rounded-xl p-4 flex flex-col gap-3 hover:shadow-md transition">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="font-semibold text-sm" x-text="srv.name"></p>
<p class="text-xs text-gray-400 truncate" x-text="srv.description || srv.uuid"></p>
</div>
<span x-show="srv.settings && srv.settings.force_disabled"
class="text-xs px-2 py-0.5 rounded-full bg-red-100 text-red-700 font-medium shrink-0">Deshabilitado</span>
<span x-show="!srv.settings || !srv.settings.force_disabled"
class="text-xs px-2 py-0.5 rounded-full bg-green-100 text-green-700 font-medium shrink-0">Activo</span>
</div>
<div class="text-xs text-gray-500 space-y-1">
<div>IP: <span class="font-medium text-gray-700" x-text="srv.ip"></span></div>
<div x-show="srv.port">Puerto SSH: <span class="font-medium text-gray-700" x-text="srv.port"></span></div>
<div>Usuario: <span class="font-medium text-gray-700" x-text="srv.user"></span></div>
<div x-show="srv.proxy_type">Proxy: <span class="font-medium capitalize" x-text="srv.proxy_type"></span></div>
</div>
<div x-show="srv.settings" class="text-xs text-gray-400 flex flex-wrap gap-2">
<span x-show="srv.settings && srv.settings.is_build_server" class="px-2 py-0.5 bg-blue-50 text-blue-600 rounded-full">Build Server</span>
<span x-show="srv.settings && srv.settings.is_cloudflare_tunnel" class="px-2 py-0.5 bg-orange-50 text-orange-600 rounded-full">CF Tunnel</span>
<span x-show="srv.settings && srv.settings.is_jump_server" class="px-2 py-0.5 bg-purple-50 text-purple-600 rounded-full">Jump Server</span>
<span x-show="srv.settings && srv.settings.is_swarm_manager" class="px-2 py-0.5 bg-cyan-50 text-cyan-600 rounded-full">Swarm Manager</span>
</div>
<div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
<button @click="validateServer(srv.uuid)"
:disabled="actionLoading[srv.uuid]"
class="text-xs px-2.5 py-1 rounded-lg bg-indigo-50 text-indigo-700 hover:bg-indigo-100 transition disabled:opacity-50">
Validar conexión
</button>
<button @click="openServerResources(srv)"
class="text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition">
Recursos
</button>
<button @click="openServerDomains(srv)"
class="text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition">
Dominios
</button>
</div>
<div x-show="actionLoading[srv.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>
Procesando...
</div>
</div>
</template>
</div>
</div>
<!-- ══════════════════════ TAB: SERVICIOS ══════════════════════ -->
<div x-show="activeTab==='services'">
<div x-show="services.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin servicios. Haz clic en "Actualizar".</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="svc in services" :key="svc.uuid">
<div class="border rounded-xl p-4 flex flex-col gap-3 hover:shadow-md transition">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="font-semibold text-sm" x-text="svc.name"></p>
<p class="text-xs text-gray-400 truncate" x-text="svc.fqdn || svc.uuid"></p>
</div>
<span :class="statusClass(svc.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
x-text="actionStatusText(svc.uuid, svc.status)">
</span>
</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">
<button x-show="!isRunning(svc.status) && !isTransitioning(svc.status)"
@click="serviceAction(svc.uuid, 'start')" :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>
<button x-show="isRunning(svc.status) || isTransitioning(svc.status)"
@click="serviceAction(svc.uuid, 'stop')" :disabled="actionLoading[svc.uuid] || isTransitioning(svc.status)"
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)"
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 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>
<span x-text="actionSpinnerText(svc.uuid)"></span>
</div>
</div>
</template>
</div>
</div>
<!-- ══════════════════════ TAB: BASES DE DATOS ══════════════════════ -->
<div x-show="activeTab==='databases'">
<div x-show="databases.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin bases de datos. Haz clic en "Actualizar".</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="db in databases" :key="db.uuid">
<div class="border rounded-xl p-4 flex flex-col gap-3 hover:shadow-md transition">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<p class="font-semibold text-sm" x-text="db.name"></p>
<p class="text-xs text-gray-400 capitalize" x-text="db.type || db.uuid"></p>
</div>
<span :class="statusClass(db.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
x-text="actionStatusText(db.uuid, db.status)">
</span>
</div>
<div class="text-xs text-gray-500 space-y-0.5">
<div x-show="db.image">Imagen: <span class="font-medium text-gray-700" x-text="db.image"></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 class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
<button x-show="!isRunning(db.status) && !isTransitioning(db.status)"
@click="dbAction(db.uuid, 'start')" :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>
<button x-show="isRunning(db.status) || isTransitioning(db.status)"
@click="dbAction(db.uuid, 'stop')" :disabled="actionLoading[db.uuid] || isTransitioning(db.status)"
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 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>
<span x-text="actionSpinnerText(db.uuid)"></span>
</div>
</div>
</template>
</div>
</div>
<!-- ══════════════════════ TAB: PROYECTOS ══════════════════════ -->
<div x-show="activeTab==='projects'">
<div x-show="projects.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin proyectos. Haz clic en "Actualizar".</div>
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
<template x-for="proj in projects" :key="proj.uuid">
<div class="border rounded-xl p-4 flex flex-col gap-3 hover:shadow-md transition">
<div>
<p class="font-semibold text-sm" x-text="proj.name"></p>
<p class="text-xs text-gray-400 mt-0.5" x-text="proj.description || proj.uuid"></p>
</div>
<div x-show="proj.environments && proj.environments.length > 0" class="flex flex-wrap gap-1">
<template x-for="env in (proj.environments || [])" :key="env.id || env.uuid || env.name || env.environment || env">
<span class="text-xs px-2 py-0.5 rounded-full bg-indigo-50 text-indigo-600" x-text="projectEnvLabel(env)"></span>
</template>
</div>
<div class="pt-1 border-t border-gray-100">
<button @click="openProjectEnvs(proj)"
class="text-xs px-2.5 py-1 rounded-lg bg-gray-50 text-gray-600 hover:bg-gray-100 transition">
Ver entornos
</button>
</div>
</div>
</template>
</div>
</div>
<!-- ══════════════════════ TAB: DESPLIEGUES ══════════════════════ -->
<div x-show="activeTab==='deployments'">
<div x-show="deployments.length === 0 && !loading" class="text-sm text-gray-400 py-8 text-center">Sin despliegues. Haz clic en "Actualizar".</div>
<div class="overflow-x-auto">
<table x-show="deployments.length > 0" class="min-w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
<th class="px-4 py-2 text-left font-medium">Aplicación</th>
<th class="px-4 py-2 text-left font-medium">Status</th>
<th class="px-4 py-2 text-left font-medium">Commit</th>
<th class="px-4 py-2 text-left font-medium">Iniciado</th>
<th class="px-4 py-2 text-left font-medium">Logs</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="dep in deployments" :key="dep.deployment_uuid || dep.id">
<tr class="hover:bg-gray-50 transition">
<td class="px-4 py-2.5 font-medium truncate max-w-xs" x-text="dep.application_name || dep.deployment_uuid || '—'"></td>
<td class="px-4 py-2.5">
<span :class="deployStatusClass(dep.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium"
x-text="dep.status || 'unknown'">
</span>
</td>
<td class="px-4 py-2.5 text-gray-500 font-mono text-xs" x-text="(dep.commit || '').slice(0,7) || '—'"></td>
<td class="px-4 py-2.5 text-gray-400 text-xs" x-text="fmtDate(dep.created_at)"></td>
<td class="px-4 py-2.5">
<button @click="openDeploymentLog(dep)"
class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 hover:bg-gray-200">Ver</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
<!-- ══════════════════════ TAB: EQUIPO ══════════════════════ -->
<div x-show="activeTab==='team'">
<!-- Equipo actual -->
<div x-show="teamInfo" class="mb-6 p-4 bg-indigo-50 border border-indigo-200 rounded-xl text-sm">
<p class="font-semibold text-indigo-800 text-base" x-text="teamInfo && teamInfo.name"></p>
<p class="text-indigo-500 text-xs mt-0.5" x-text="teamInfo && teamInfo.description"></p>
<div class="flex gap-4 mt-2 text-xs text-indigo-600">
<span>ID: <b x-text="teamInfo && teamInfo.id"></b></span>
<span x-show="teamInfo && teamInfo.personal_team">Equipo personal</span>
</div>
</div>
<!-- Miembros -->
<div x-show="teamMembers.length === 0 && !loading" class="text-sm text-gray-400 py-4">Sin miembros cargados.</div>
<div x-show="teamMembers.length > 0">
<h3 class="text-sm font-semibold mb-3 text-gray-700">Miembros del equipo</h3>
<div class="overflow-x-auto">
<table class="min-w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 text-gray-500 text-xs uppercase tracking-wide">
<th class="px-4 py-2 text-left font-medium">Nombre</th>
<th class="px-4 py-2 text-left font-medium">Email</th>
<th class="px-4 py-2 text-left font-medium">Rol</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="member in teamMembers" :key="member.id">
<tr class="hover:bg-gray-50">
<td class="px-4 py-2.5 font-medium" x-text="member.name"></td>
<td class="px-4 py-2.5 text-gray-500" x-text="member.email"></td>
<td class="px-4 py-2.5">
<span class="text-xs px-2 py-0.5 rounded-full capitalize"
:class="member.role === 'admin' ? 'bg-purple-100 text-purple-700' : 'bg-gray-100 text-gray-600'"
x-text="member.role || '—'">
</span>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
</div><!-- /container -->
<!-- ══════════════════════ MODAL: CONFIG ══════════════════════ -->
<div x-show="configModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="configModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-md">
<div class="p-6 border-b">
<h2 class="text-lg font-bold">Configurar Coolify API</h2>
<p class="text-xs text-gray-500 mt-1">Ingresa la URL base de tu instancia y el token de API.</p>
</div>
<div class="p-6 space-y-4">
<div>
<label class="block text-sm font-medium mb-1">Nombre <span class="text-gray-400 font-normal">(referencia)</span></label>
<input x-model="cfgForm.nombre" type="text" placeholder="Mi Coolify"
class="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-300">
</div>
<div>
<label class="block text-sm font-medium mb-1">URL Base <span class="text-red-500">*</span></label>
<input x-model="cfgForm.base_url" type="url" placeholder="http://tu-servidor:8000"
class="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-300">
<p class="text-xs text-gray-400 mt-1">Ej: <code>http://10.0.0.1:8000</code> o <code>https://app.coolify.io</code></p>
</div>
<div>
<label class="block text-sm font-medium mb-1">API Token <span class="text-red-500">*</span></label>
<input x-model="cfgForm.api_token" type="password" placeholder="tok_••••••••"
class="w-full border rounded-lg px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-indigo-300">
<p class="text-xs text-gray-400 mt-1">Genera el token en <strong>Coolify → Keys & Tokens → API tokens</strong>. Deja en blanco para no cambiar el token guardado.</p>
</div>
<div class="flex items-center gap-2">
<input x-model="cfgForm.activo" type="checkbox" id="cfgActivo" class="w-4 h-4 accent-indigo-600">
<label for="cfgActivo" class="text-sm font-medium">Integración activa</label>
</div>
</div>
<div class="p-6 border-t flex items-center gap-3 justify-between">
<button @click="testConnection()" :disabled="cfgLoading"
class="flex items-center gap-1.5 text-sm px-3 py-2 rounded-lg border text-gray-600 hover:bg-gray-50 transition disabled:opacity-50">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M13 10V3L4 14h7v7l9-11h-7z"/>
</svg>
Probar conexión
</button>
<div class="flex gap-2">
<button @click="configModal = false"
class="px-4 py-2 text-sm rounded-lg border text-gray-600 hover:bg-gray-50 transition">Cancelar</button>
<button @click="saveConfig()" :disabled="cfgLoading"
class="px-4 py-2 text-sm rounded-lg text-white font-medium transition disabled:opacity-50"
style="background-color:#6366f1"
onmouseover="this.style.backgroundColor='#4f46e5'"
onmouseout="this.style.backgroundColor='#6366f1'">
<span x-text="cfgLoading ? 'Guardando...' : 'Guardar'"></span>
</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>
</div>
<!-- ══════════════════════ MODAL: LOGS ══════════════════════ -->
<div x-show="logsModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="logsModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold" x-text="'Logs: ' + (logsTarget ? logsTarget.name : '')"></h2>
<div class="flex gap-2">
<button @click="refreshLogs()" class="text-xs px-3 py-1.5 rounded-lg border text-gray-600 hover:bg-gray-50">
Actualizar
</button>
<button @click="logsModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
</div>
<div class="flex-1 overflow-auto bg-gray-950 text-green-400 text-xs font-mono p-4 whitespace-pre-wrap rounded-b-2xl">
<span x-show="logsLoading" class="text-gray-500">Cargando logs...</span>
<span x-show="!logsLoading && !logsContent" class="text-gray-600">Sin logs disponibles.</span>
<span x-show="!logsLoading" x-text="logsContent"></span>
</div>
</div>
</div>
<!-- ══════════════════════ MODAL: ENVS ══════════════════════ -->
<div x-show="envsModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="envsModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold" x-text="'Variables de entorno: ' + (envsTarget ? envsTarget.name : '')"></h2>
<button @click="envsModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto p-5 space-y-2">
<div x-show="envsLoading" class="text-sm text-gray-400 text-center py-4">Cargando variables...</div>
<div x-show="!envsLoading && envsList.length === 0" class="text-sm text-gray-400 text-center py-4">Sin variables de entorno.</div>
<template x-for="env in envsList" :key="env.id || env.uuid || env.key || env.name || env">
<div class="flex items-center gap-3 bg-gray-50 rounded-lg px-3 py-2 text-sm">
<span class="font-mono font-semibold text-gray-700 w-40 shrink-0 truncate" x-text="env.key || env.name || '—'"></span>
<span class="font-mono text-gray-500 flex-1 truncate" x-text="(env.is_shown_once || env.is_masked) ? '••••••••' : (env.value || '')"></span>
<span x-show="env.is_preview" class="text-xs px-1.5 py-0.5 bg-blue-100 text-blue-600 rounded">Preview</span>
</div>
</template>
</div>
<!-- Formulario nuevo env -->
<div class="p-5 border-t">
<p class="text-xs font-semibold text-gray-500 mb-2 uppercase tracking-wide">Agregar variable</p>
<div class="flex gap-2">
<input x-model="newEnv.key" type="text" placeholder="KEY"
class="font-mono text-sm border rounded-lg px-3 py-1.5 w-36 focus:outline-none focus:ring-2 focus:ring-indigo-300">
<input x-model="newEnv.value" type="text" placeholder="value"
class="font-mono text-sm border rounded-lg px-3 py-1.5 flex-1 focus:outline-none focus:ring-2 focus:ring-indigo-300">
<button @click="createEnv()" :disabled="envsLoading"
class="text-sm px-3 py-1.5 rounded-lg text-white font-medium disabled:opacity-50"
style="background-color:#6366f1"
onmouseover="this.style.backgroundColor='#4f46e5'"
onmouseout="this.style.backgroundColor='#6366f1'">
Agregar
</button>
</div>
</div>
</div>
</div>
<!-- ══════════════════════ MODAL: DEPLOYMENTS HISTORIAL ══════════════════════ -->
<div x-show="appDeploymentsModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="appDeploymentsModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold" x-text="'Despliegues: ' + (appDeploymentsTarget ? appDeploymentsTarget.name : '')"></h2>
<button @click="appDeploymentsModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto">
<div x-show="appDeploymentsLoading" class="text-sm text-gray-400 text-center py-8">Cargando...</div>
<div x-show="!appDeploymentsLoading && appDeploymentsList.length === 0" class="text-sm text-gray-400 text-center py-8">Sin despliegues registrados.</div>
<table x-show="!appDeploymentsLoading && appDeploymentsList.length > 0" class="min-w-full text-sm">
<thead class="bg-gray-50 text-gray-500 text-xs uppercase">
<tr>
<th class="px-4 py-2 text-left">UUID</th>
<th class="px-4 py-2 text-left">Status</th>
<th class="px-4 py-2 text-left">Commit</th>
<th class="px-4 py-2 text-left">Fecha</th>
<th class="px-4 py-2 text-left">Logs</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-for="dep in appDeploymentsList" :key="dep.deployment_uuid || dep.id">
<tr>
<td class="px-4 py-2 font-mono text-xs text-gray-500" x-text="(dep.deployment_uuid || '').slice(0,8)"></td>
<td class="px-4 py-2">
<span :class="deployStatusClass(dep.status)"
class="text-xs px-2 py-0.5 rounded-full font-medium"
x-text="dep.status || 'unknown'">
</span>
</td>
<td class="px-4 py-2 font-mono text-xs text-gray-400" x-text="(dep.commit || '').slice(0,7) || '—'"></td>
<td class="px-4 py-2 text-xs text-gray-400" x-text="fmtDate(dep.created_at)"></td>
<td class="px-4 py-2">
<button @click="openDeploymentLog(dep)"
class="text-xs px-2 py-0.5 rounded bg-gray-100 text-gray-600 hover:bg-gray-200">Ver</button>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</div>
<!-- ══════════════════════ MODAL: LOG DE DEPLOYMENT ══════════════════════ -->
<div x-show="deployLogModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="deployLogModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-3xl max-h-[80vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold">Log de despliegue</h2>
<button @click="deployLogModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto bg-gray-950 text-green-400 text-xs font-mono p-4 whitespace-pre-wrap rounded-b-2xl">
<span x-show="!deployLogContent" class="text-gray-500">Sin log disponible.</span>
<span x-text="deployLogContent"></span>
</div>
</div>
</div>
<!-- ══════════════════════ MODAL: SERVER RECURSOS ══════════════════════ -->
<div x-show="serverResourcesModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="serverResourcesModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-2xl max-h-[80vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold" x-text="'Recursos: ' + (serverTarget ? serverTarget.name : '')"></h2>
<button @click="serverResourcesModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto p-5">
<div x-show="serverResourcesLoading" class="text-sm text-gray-400 text-center py-4">Cargando recursos...</div>
<div x-show="!serverResourcesLoading && serverResourcesList.length === 0" class="text-sm text-gray-400 text-center py-4">Sin recursos.</div>
<div class="space-y-2">
<template x-for="res in serverResourcesList" :key="res.id || res.uuid">
<div class="flex items-center justify-between bg-gray-50 rounded-lg px-3 py-2 text-sm">
<span class="font-medium truncate" x-text="res.name"></span>
<div class="flex items-center gap-2">
<span class="text-xs text-gray-400 capitalize" x-text="res.type"></span>
<span :class="statusClass(res.status)" class="text-xs px-2 py-0.5 rounded-full font-medium" x-text="res.status || '—'"></span>
</div>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- ══════════════════════ MODAL: SERVER DOMINIOS ══════════════════════ -->
<div x-show="serverDomainsModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="serverDomainsModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[80vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold" x-text="'Dominios: ' + (serverTarget ? serverTarget.name : '')"></h2>
<button @click="serverDomainsModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto p-5 space-y-2">
<div x-show="serverDomainsLoading" class="text-sm text-gray-400 text-center py-4">Cargando dominios...</div>
<div x-show="!serverDomainsLoading && serverDomainsList.length === 0" class="text-sm text-gray-400 text-center py-4">Sin dominios.</div>
<template x-for="(domain, idx) in serverDomainsList" :key="idx">
<div class="bg-gray-50 rounded-lg px-3 py-2 text-sm">
<p class="font-medium text-gray-700" x-text="domain.domain || domain"></p>
<p x-show="domain.application_name" class="text-xs text-gray-400 mt-0.5" x-text="domain.application_name"></p>
</div>
</template>
</div>
</div>
</div>
<!-- ══════════════════════ MODAL: ENTORNOS PROYECTO ══════════════════════ -->
<div x-show="projectEnvsModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
<div @click.outside="projectEnvsModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-lg max-h-[70vh] flex flex-col">
<div class="p-5 border-b flex items-center justify-between">
<h2 class="text-base font-bold" x-text="'Entornos: ' + (projectTarget ? projectTarget.name : '')"></h2>
<button @click="projectEnvsModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">&times;</button>
</div>
<div class="flex-1 overflow-auto p-5 space-y-2">
<div x-show="projectEnvsLoading" class="text-sm text-gray-400 text-center py-4">Cargando entornos...</div>
<div x-show="!projectEnvsLoading && projectEnvsList.length === 0" class="text-sm text-gray-400 text-center py-4">Sin entornos.</div>
<template x-for="env in projectEnvsList" :key="env.id || env.uuid || env.name || env.environment || env">
<div class="bg-gray-50 rounded-lg px-3 py-2 text-sm">
<p class="font-semibold text-gray-700" x-text="projectEnvLabel(env)"></p>
<p x-show="projectEnvDescription(env)" class="text-xs text-gray-400 mt-0.5" x-text="projectEnvDescription(env)"></p>
</div>
</template>
</div>
</div>
</div>
</div><!-- /x-data -->
<script>
document.addEventListener('alpine:init', () => {
Alpine.data('coolifyApp', () => ({
// Estado global
loading: false,
hasConfig: false,
lastFetch: '',
flashMsg: '',
flashOk: true,
activeTab: 'apps',
tabs: [
{ id: 'apps', label: 'Aplicaciones' },
{ id: 'servers', label: 'Servidores' },
{ id: 'services', label: 'Servicios' },
{ id: 'databases', label: 'Bases de datos' },
{ id: 'projects', label: 'Proyectos' },
{ id: 'deployments', label: 'Despliegues' },
{ id: 'team', label: 'Equipo' },
],
// Datos por tab
apps: [],
servers: [],
services: [],
databases: [],
projects: [],
deployments: [],
teamInfo: null,
teamMembers: [],
// Filtros
appTagFilter: '',
// Action loading by uuid
actionLoading: {},
actionPhase: {},
// ─── Config modal ─────────────────────────────
configModal: false,
cfgLoading: false,
cfgMsg: '',
cfgOk: false,
cfgForm: { nombre: '', base_url: '', api_token: '', activo: true },
// ─── Logs modal ───────────────────────────────
logsModal: false,
logsLoading: false,
logsTarget: null,
logsContent: '',
// ─── Envs modal ───────────────────────────────
envsModal: false,
envsLoading: false,
envsTarget: null,
envsType: 'app', // 'app' | 'service'
envsList: [],
newEnv: { key: '', value: '' },
// ─── App deployments modal ────────────────────
appDeploymentsModal: false,
appDeploymentsLoading: false,
appDeploymentsTarget: null,
appDeploymentsList: [],
// ─── Deployment log modal ─────────────────────
deployLogModal: false,
deployLogContent: '',
// ─── Server resources modal ───────────────────
serverResourcesModal: false,
serverResourcesLoading: false,
serverTarget: null,
serverResourcesList: [],
// ─── Server domains modal ─────────────────────
serverDomainsModal: false,
serverDomainsLoading: false,
serverDomainsList: [],
// ─── Project envs modal ───────────────────────
projectEnvsModal: false,
projectEnvsLoading: false,
projectTarget: null,
projectEnvsList: [],
// ═════════════════════════════════════════════
async init() {
await this.loadConfig();
if (this.hasConfig) this.loadTab();
},
// ─── Helpers ────────────────────────────────────
projectEnvLabel(env) {
if (!env) return '—';
if (typeof env === 'string') return env;
return env.name || env.environment || env.slug || env.uuid || env.branch || 'Entorno';
},
projectEnvDescription(env) {
if (!env || typeof env === 'string') return '';
return env.description || env.branch || env.fqdn || env.url || '';
},
actionTextFor(action) {
const map = {
start: 'iniciando',
stop: 'deteniendo',
restart: 'reiniciando',
deploy: 'desplegando',
};
return map[action] || 'procesando';
},
actionStatusText(uuid, status) {
return this.actionPhase[uuid] || this.statusLabel(status);
},
actionSpinnerText(uuid) {
return this.actionPhase[uuid] || 'procesando';
},
clearActionState(uuid) {
const nextLoading = { ...this.actionLoading };
delete nextLoading[uuid];
this.actionLoading = nextLoading;
const nextPhase = { ...this.actionPhase };
delete nextPhase[uuid];
this.actionPhase = nextPhase;
},
flash(msg, ok = true) {
this.flashMsg = msg;
this.flashOk = ok;
setTimeout(() => { if (this.flashMsg === msg) this.flashMsg = ''; }, 4000);
},
fmtDate(dt) {
if (!dt) return '—';
try { return new Date(dt).toLocaleString('es', { dateStyle: 'short', timeStyle: 'short' }); }
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) {
const s = this.normalizeStatus(status);
if (!s) return 'bg-gray-100 text-gray-500';
if (s === 'running') return 'bg-green-100 text-green-700';
if (s === 'stopped') return 'bg-red-100 text-red-600';
if (s === 'exited') return 'bg-red-100 text-red-600';
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 === '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';
},
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) {
if (!status) return 'bg-gray-100 text-gray-500';
const s = status.toLowerCase();
if (s === 'success' || s === 'finished') return 'bg-green-100 text-green-700';
if (s === 'error' || s === 'failed') return 'bg-red-100 text-red-600';
if (s === 'running' || s === 'in_progress') return 'bg-blue-100 text-blue-700';
if (s === 'queued') return 'bg-yellow-100 text-yellow-700';
if (s === 'cancelled') return 'bg-gray-100 text-gray-500';
return 'bg-gray-100 text-gray-500';
},
// ─── Config ──────────────────────────────────────
async loadConfig() {
const r = await fetch('/app/coolify/config');
const j = await r.json();
if (j.config) {
this.hasConfig = j.config.activo;
this.cfgForm.nombre = j.config.nombre || '';
this.cfgForm.base_url = j.config.base_url || '';
this.cfgForm.activo = j.config.activo;
}
},
openConfigModal() {
this.cfgMsg = '';
this.configModal = true;
},
async saveConfig() {
this.cfgLoading = true;
this.cfgMsg = '';
try {
const r = await fetch('/app/coolify/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.cfgForm)
});
const j = await r.json();
if (j.ok) {
this.cfgOk = true;
this.cfgMsg = 'Configuración guardada correctamente.';
this.hasConfig = this.cfgForm.activo;
if (this.hasConfig) this.loadTab();
setTimeout(() => { this.configModal = false; }, 1200);
} else {
this.cfgOk = false;
this.cfgMsg = j.error || 'Error al guardar.';
}
} catch(e) {
this.cfgOk = false;
this.cfgMsg = 'Error de red: ' + e.message;
} finally {
this.cfgLoading = false;
}
},
async testConnection() {
this.cfgLoading = true;
this.cfgMsg = '';
try {
const r = await fetch('/app/coolify/health');
const j = await r.json();
if (r.ok) {
this.cfgOk = true;
this.cfgMsg = '✓ Conexión exitosa con Coolify.';
} else {
this.cfgOk = false;
this.cfgMsg = 'Error ' + r.status + ': ' + (j.message || j.error || JSON.stringify(j));
}
} catch(e) {
this.cfgOk = false;
this.cfgMsg = 'Error de red: ' + e.message;
} finally {
this.cfgLoading = false;
}
},
// ─── Tab navigation ──────────────────────────────
setTab(tab) {
this.activeTab = tab;
this.loadTab();
},
loadTab() {
if (!this.hasConfig) return;
const map = {
apps: () => this.loadApps(),
servers: () => this.loadServers(),
services: () => this.loadServices(),
databases: () => this.loadDatabases(),
projects: () => this.loadProjects(),
deployments: () => this.loadDeployments(),
team: () => this.loadTeam(),
};
const fn = map[this.activeTab];
if (fn) fn();
},
// ─── Loaders ─────────────────────────────────────
async loadApps() {
this.loading = true;
try {
const qs = this.appTagFilter ? `?tag=${encodeURIComponent(this.appTagFilter)}` : '';
const r = await fetch('/app/coolify/apps' + qs);
const j = await r.json();
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());
} catch(e) { this.flash('Error cargando apps: ' + e.message, false); }
finally { this.loading = false; }
},
async loadServers() {
this.loading = true;
try {
const r = await fetch('/app/coolify/servers');
const j = await r.json();
this.servers = Array.isArray(j) ? j : (j.data || []);
this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando servidores: ' + e.message, false); }
finally { this.loading = false; }
},
async loadServices() {
this.loading = true;
try {
const r = await fetch('/app/coolify/services');
const j = await r.json();
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());
} catch(e) { this.flash('Error cargando servicios: ' + e.message, false); }
finally { this.loading = false; }
},
async loadDatabases() {
this.loading = true;
try {
const r = await fetch('/app/coolify/databases');
const j = await r.json();
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());
} catch(e) { this.flash('Error cargando bases de datos: ' + e.message, false); }
finally { this.loading = false; }
},
async loadProjects() {
this.loading = true;
try {
const r = await fetch('/app/coolify/projects');
const j = await r.json();
this.projects = Array.isArray(j) ? j : (j.data || []);
this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando proyectos: ' + e.message, false); }
finally { this.loading = false; }
},
async loadDeployments() {
this.loading = true;
try {
const r = await fetch('/app/coolify/deployments');
const j = await r.json();
this.deployments = Array.isArray(j) ? j
: (j.deployments || j.items || j.data || j.result || j.deployments_list || []);
this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando despliegues: ' + e.message, false); }
finally { this.loading = false; }
},
async loadTeam() {
this.loading = true;
try {
const [rt, rm] = await Promise.all([
fetch('/app/coolify/team'),
fetch('/app/coolify/team/members'),
]);
const jt = await rt.json();
const jm = await rm.json();
this.teamInfo = Array.isArray(jt) ? (jt[0] || null) : jt;
this.teamMembers = Array.isArray(jm) ? jm : (jm.data || []);
this.lastFetch = this.fmtDate(new Date().toISOString());
} catch(e) { this.flash('Error cargando equipo: ' + e.message, false); }
finally { this.loading = false; }
},
// ─── App actions ─────────────────────────────────
async appAction(uuid, action) {
const prev = { ...this.actionLoading };
prev[uuid] = true;
this.actionLoading = prev;
const phase = this.actionTextFor(action);
this.actionPhase = { ...this.actionPhase, [uuid]: phase };
let clearNow = true;
try {
const r = await fetch(`/app/coolify/apps/${uuid}/${action}`);
const j = await r.json();
if (r.ok) {
clearNow = false;
this.flash(`App ${phase} correctamente.`);
setTimeout(async () => {
await this.loadApps();
this.clearActionState(uuid);
}, 2000);
} else {
this.flash((j.message || j.error || `Error en ${action}`), false);
}
} catch(e) { this.flash('Error: ' + e.message, false); }
finally {
if (clearNow) this.clearActionState(uuid);
}
},
async deployApp(uuid) {
const prev = { ...this.actionLoading };
prev[uuid] = true;
this.actionLoading = prev;
this.actionPhase = { ...this.actionPhase, [uuid]: this.actionTextFor('deploy') };
let clearNow = true;
try {
const r = await fetch(`/app/coolify/apps/${uuid}/deploy`, { method: 'POST' });
const j = await r.json();
if (r.ok) {
clearNow = false;
this.flash('Deploy disparado correctamente.');
setTimeout(() => this.clearActionState(uuid), 3000);
} else {
this.flash((j.message || j.error || 'Error al hacer deploy'), false);
}
} catch(e) { this.flash('Error: ' + e.message, false); }
finally {
if (clearNow) this.clearActionState(uuid);
}
},
// ─── Logs ────────────────────────────────────────
async openAppLogs(app) {
this.logsTarget = app;
this.logsContent = '';
this.logsModal = true;
this.logsLoading = true;
try {
const r = await fetch(`/app/coolify/apps/${app.uuid}/logs`);
const j = await r.json();
this.logsContent = typeof j === 'string' ? j
: (j.logs || j.data || JSON.stringify(j, null, 2));
} catch(e) { this.logsContent = 'Error: ' + e.message; }
finally { this.logsLoading = false; }
},
async refreshLogs() {
if (!this.logsTarget) return;
this.logsLoading = true;
this.logsContent = '';
try {
const r = await fetch(`/app/coolify/apps/${this.logsTarget.uuid}/logs`);
const j = await r.json();
this.logsContent = typeof j === 'string' ? j
: (j.logs || j.data || JSON.stringify(j, null, 2));
} catch(e) { this.logsContent = 'Error: ' + e.message; }
finally { this.logsLoading = false; }
},
// ─── Envs ────────────────────────────────────────
async openAppEnvs(app) {
this.envsTarget = app;
this.envsType = 'app';
this.envsList = [];
this.newEnv = { key: '', value: '' };
this.envsModal = true;
this.envsLoading = true;
try {
const r = await fetch(`/app/coolify/apps/${app.uuid}/envs`);
const j = await r.json();
this.envsList = Array.isArray(j) ? j : (j.data || j.variables || j.envs || j.items || j.environment_variables || []);
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.envsLoading = false; }
},
async openServiceEnvs(svc) {
this.envsTarget = svc;
this.envsType = 'service';
this.envsList = [];
this.newEnv = { key: '', value: '' };
this.envsModal = true;
this.envsLoading = true;
try {
const r = await fetch(`/app/coolify/services/${svc.uuid}/envs`);
const j = await r.json();
this.envsList = Array.isArray(j) ? j : (j.data || j.variables || j.envs || j.items || j.environment_variables || []);
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.envsLoading = false; }
},
async createEnv() {
if (!this.newEnv.key) return;
const uuid = this.envsTarget.uuid;
const endpoint = this.envsType === 'app'
? `/app/coolify/apps/${uuid}/envs`
: `/app/coolify/services/${uuid}/envs`;
this.envsLoading = true;
try {
const r = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: this.newEnv.key, value: this.newEnv.value })
});
const j = await r.json();
if (r.ok) {
this.flash('Variable creada.');
this.newEnv = { key: '', value: '' };
// Recargar lista
const r2 = await fetch(endpoint.replace(/\/envs$/, '/envs'));
const j2 = await r2.json();
this.envsList = Array.isArray(j2) ? j2 : (j2.data || j2.variables || j2.envs || j2.items || j2.environment_variables || []);
} else {
this.flash(j.message || j.error || 'Error al crear variable.', false);
}
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.envsLoading = false; }
},
// ─── App deployments historial ───────────────────
async openAppDeployments(app) {
this.appDeploymentsTarget = app;
this.appDeploymentsList = [];
this.appDeploymentsModal = true;
this.appDeploymentsLoading = true;
try {
const r = await fetch(`/app/coolify/apps/${app.uuid}/deployments`);
const j = await r.json();
let list = Array.isArray(j) ? j
: (j.deployments || j.items || j.data || j.result || j.deployments_list || []);
// Si la respuesta es vacía pero tiene data a nivel superior, prueba parsear como objeto wrapper
if (!Array.isArray(list) && typeof list === 'object' && list !== null) {
list = Object.values(list).filter(v => Array.isArray(v))[0] || [];
}
this.appDeploymentsList = Array.isArray(list) ? list : [];
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.appDeploymentsLoading = false; }
},
// ─── Deployment log ──────────────────────────────
openDeploymentLog(dep) {
this.deployLogContent = dep.logs || dep.log || JSON.stringify(dep, null, 2);
this.deployLogModal = true;
},
// ─── Service actions ─────────────────────────────
async serviceAction(uuid, action) {
const prev = { ...this.actionLoading };
prev[uuid] = true;
this.actionLoading = prev;
const phase = this.actionTextFor(action);
this.actionPhase = { ...this.actionPhase, [uuid]: phase };
let clearNow = true;
try {
const r = await fetch(`/app/coolify/services/${uuid}/${action}`);
const j = await r.json();
if (r.ok) {
clearNow = false;
this.flash(`Servicio ${phase} correctamente.`);
setTimeout(async () => {
await this.loadServices();
this.clearActionState(uuid);
}, 2000);
} else {
this.flash(j.message || j.error || `Error en ${action}`, false);
}
} catch(e) { this.flash('Error: ' + e.message, false); }
finally {
if (clearNow) this.clearActionState(uuid);
}
},
// ─── DB actions ──────────────────────────────────
async dbAction(uuid, action) {
const prev = { ...this.actionLoading };
prev[uuid] = true;
this.actionLoading = prev;
const phase = this.actionTextFor(action);
this.actionPhase = { ...this.actionPhase, [uuid]: phase };
let clearNow = true;
try {
const r = await fetch(`/app/coolify/databases/${uuid}/${action}`);
const j = await r.json();
if (r.ok) {
clearNow = false;
this.flash(`Base de datos ${phase} correctamente.`);
setTimeout(async () => {
await this.loadDatabases();
this.clearActionState(uuid);
}, 2000);
} else {
this.flash(j.message || j.error || `Error en ${action}`, false);
}
} catch(e) { this.flash('Error: ' + e.message, false); }
finally {
if (clearNow) this.clearActionState(uuid);
}
},
// ─── Server actions ──────────────────────────────
async validateServer(uuid) {
const prev = { ...this.actionLoading };
prev[uuid] = true;
this.actionLoading = prev;
try {
const r = await fetch(`/app/coolify/servers/${uuid}/validate`);
const j = await r.json();
if (r.ok) {
this.flash('Servidor validado correctamente.');
} else {
this.flash(j.message || j.error || 'Error al validar', false);
}
} catch(e) { this.flash('Error: ' + e.message, false); }
finally {
const next = { ...this.actionLoading };
next[uuid] = false;
this.actionLoading = next;
}
},
async openServerResources(srv) {
this.serverTarget = srv;
this.serverResourcesList = [];
this.serverResourcesModal = true;
this.serverResourcesLoading = true;
try {
const r = await fetch(`/app/coolify/servers/${srv.uuid}/resources`);
const j = await r.json();
this.serverResourcesList = Array.isArray(j) ? j : (j.data || []);
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.serverResourcesLoading = false; }
},
async openServerDomains(srv) {
this.serverTarget = srv;
this.serverDomainsList = [];
this.serverDomainsModal = true;
this.serverDomainsLoading = true;
try {
const r = await fetch(`/app/coolify/servers/${srv.uuid}/domains`);
const j = await r.json();
this.serverDomainsList = Array.isArray(j) ? j : (j.data || []);
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.serverDomainsLoading = false; }
},
// ─── Project envs ────────────────────────────────
async openProjectEnvs(proj) {
this.projectTarget = proj;
this.projectEnvsList = Array.isArray(proj.environments) ? proj.environments : [];
this.projectEnvsModal = true;
this.projectEnvsLoading = true;
try {
const r = await fetch(`/app/coolify/projects/${proj.uuid}/environments`);
const j = await r.json();
this.projectEnvsList = Array.isArray(j) ? j
: (j.environments || j.data || j.items || j.projects_environments || []);
} catch(e) { this.flash('Error: ' + e.message, false); }
finally { this.projectEnvsLoading = false; }
},
}));
});
</script>