1480 lines
90 KiB
HTML
1480 lines
90 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>
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
<!-- Selector de instancia -->
|
|
<select x-show="configs.length > 1" x-model="selectedConfigId" @change="onInstanceChange()"
|
|
class="text-sm border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
|
|
<template x-for="cfg in configs" :key="cfg.ID">
|
|
<option :value="cfg.ID" x-text="cfg.Nombre || cfg.nombre || cfg.base_url"></option>
|
|
</template>
|
|
</select>
|
|
<button @click="setTab('instancias')"
|
|
class="flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-lg border border-indigo-200 text-indigo-600 hover:bg-indigo-50 transition">
|
|
+ Nueva instancia
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Alerta sin config -->
|
|
<div x-show="!hasConfig && activeTab !== 'instancias'" class="mb-6 p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-sm text-yellow-700">
|
|
<strong>Sin instancia activa.</strong> Haz clic en "+ Nueva instancia" para agregar tu primera 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">×</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">×</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">×</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>
|
|
|
|
<!-- ══════════════════════ TAB: INSTANCIAS ══════════════════════ -->
|
|
<div x-show="activeTab==='instancias'">
|
|
<div class="flex items-center justify-between mb-4">
|
|
<h2 class="text-base font-semibold text-gray-700">Instancias de Coolify</h2>
|
|
<button @click="openInstModal()"
|
|
class="flex items-center gap-2 text-sm font-medium px-4 py-2 rounded-lg text-white"
|
|
style="background-color:#6366f1"
|
|
onmouseover="this.style.backgroundColor='#4f46e5'"
|
|
onmouseout="this.style.backgroundColor='#6366f1'">
|
|
+ Nueva instancia
|
|
</button>
|
|
</div>
|
|
<div x-show="configs.length === 0" class="text-sm text-gray-400 py-8 text-center">
|
|
No hay instancias configuradas. Haz clic en "+ Nueva instancia" para agregar la primera.
|
|
</div>
|
|
<div class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
<template x-for="cfg in configs" :key="cfg.ID || cfg.id">
|
|
<div class="border rounded-xl p-4 flex flex-col gap-3 hover:shadow-md transition"
|
|
:class="selectedConfigId === (cfg.ID || cfg.id) ? 'border-indigo-200 ring-1 ring-indigo-100' : 'border-gray-200'">
|
|
<!-- Nombre + estado -->
|
|
<div class="flex items-start justify-between gap-2">
|
|
<div class="min-w-0">
|
|
<p class="font-semibold text-sm truncate" x-text="cfg.Nombre || cfg.nombre || 'Sin nombre'"></p>
|
|
<p class="text-xs text-gray-400 font-mono truncate" x-text="cfg.BaseURL || cfg.base_url"></p>
|
|
</div>
|
|
<span :class="(cfg.Activo || cfg.activo) ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-400'"
|
|
class="text-xs px-2 py-0.5 rounded-full font-medium shrink-0"
|
|
x-text="(cfg.Activo || cfg.activo) ? 'Activa' : 'Inactiva'">
|
|
</span>
|
|
</div>
|
|
|
|
<!-- Indicador de instancia en uso -->
|
|
<div x-show="selectedConfigId === (cfg.ID || cfg.id)" class="text-xs text-indigo-600 font-medium flex items-center gap-1">
|
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 16 16">
|
|
<path d="M16 8A8 8 0 1 1 0 8a8 8 0 0 1 16 0zm-3.97-3.03a.75.75 0 0 0-1.08.022L7.477 9.417 5.384 7.323a.75.75 0 0 0-1.06 1.06L6.97 11.03a.75.75 0 0 0 1.079-.02l3.992-4.99a.75.75 0 0 0-.01-1.05z"/>
|
|
</svg>
|
|
En uso
|
|
</div>
|
|
|
|
<!-- Acciones -->
|
|
<div class="flex flex-wrap gap-2 pt-1 border-t border-gray-100">
|
|
<button x-show="selectedConfigId !== (cfg.ID || cfg.id)"
|
|
@click="selectedConfigId = cfg.ID || cfg.id; flash('Instancia seleccionada: ' + (cfg.Nombre || cfg.nombre))"
|
|
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 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>
|
|
Usar
|
|
</button>
|
|
<button @click="testInst(cfg.ID || cfg.id)"
|
|
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 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.52.359A.5.5 0 0 1 6 0h4a.5.5 0 0 1 .474.658L8.694 6H12.5a.5.5 0 0 1 .395.807l-7 9a.5.5 0 0 1-.873-.454L6.823 9.5H3.5a.5.5 0 0 1-.48-.641z"/></svg>
|
|
Probar
|
|
</button>
|
|
<button @click="openInstModal(cfg)"
|
|
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-blue-50 text-blue-600 hover:bg-blue-100 transition 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="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487z"/>
|
|
</svg>
|
|
Editar
|
|
</button>
|
|
<button @click="deleteInst(cfg.ID || cfg.id)"
|
|
class="flex items-center gap-1 text-xs px-2.5 py-1 rounded-lg bg-red-50 text-red-600 hover:bg-red-100 transition font-medium ml-auto">
|
|
<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="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0"/>
|
|
</svg>
|
|
Eliminar
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<p class="text-xs text-gray-400 mt-4">
|
|
La instancia activa se selecciona automáticamente. Puedes cambiarla con el selector del encabezado o con el botón "Usar".
|
|
Todas las peticiones a Coolify (apps, servidores, servicios, etc.) se envían a la instancia seleccionada.
|
|
</p>
|
|
</div>
|
|
|
|
<!-- ══════════════════════ MODAL: CREAR / EDITAR INSTANCIA ══════════════════════ -->
|
|
<div x-show="instModal" x-transition class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
|
<div @click.outside="instModal = false" class="bg-white rounded-2xl shadow-xl w-full max-w-md">
|
|
<div class="p-5 border-b flex items-center justify-between">
|
|
<h2 class="text-base font-bold" x-text="instEditId ? 'Editar instancia' : 'Nueva instancia Coolify'"></h2>
|
|
<button @click="instModal = false" class="text-gray-400 hover:text-gray-700 text-xl leading-none">×</button>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre</label>
|
|
<input x-model="instForm.nombre" type="text" placeholder="Ej: Producción, Staging..."
|
|
class="w-full text-sm border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">URL base <span class="text-red-400">*</span></label>
|
|
<input x-model="instForm.base_url" type="url" placeholder="https://coolify.tudominio.com"
|
|
class="w-full text-sm border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
|
|
<p class="text-xs text-gray-400 mt-1">Sin barra final. El sistema agrega <code>/api/v1</code> automáticamente.</p>
|
|
</div>
|
|
<div>
|
|
<label class="block text-xs font-medium text-gray-600 mb-1">API Token <span x-show="instEditId" class="text-gray-400">(dejar vacío para no cambiar)</span><span x-show="!instEditId" class="text-red-400">*</span></label>
|
|
<input x-model="instForm.api_token" type="password" placeholder="Token de Coolify → Keys & Tokens → API tokens"
|
|
class="w-full text-sm border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
|
|
</div>
|
|
<label class="flex items-center gap-2 cursor-pointer">
|
|
<input x-model="instForm.activo" type="checkbox" class="rounded">
|
|
<span class="text-sm text-gray-700">Instancia activa</span>
|
|
</label>
|
|
<div x-show="instMsg" :class="instOk ? 'text-green-600 bg-green-50' : 'text-red-600 bg-red-50'"
|
|
class="text-xs rounded-lg px-3 py-2" x-text="instMsg"></div>
|
|
</div>
|
|
<div class="p-5 border-t flex items-center justify-end gap-3">
|
|
<button @click="instModal = false" class="text-sm px-4 py-2 rounded-lg border text-gray-600 hover:bg-gray-50">Cancelar</button>
|
|
<button @click="saveInst()" :disabled="instLoading"
|
|
class="text-sm px-4 py-2 rounded-lg text-white font-medium disabled:opacity-50"
|
|
style="background-color:#6366f1"
|
|
onmouseover="this.style.backgroundColor='#4f46e5'"
|
|
onmouseout="this.style.backgroundColor='#6366f1'"
|
|
x-text="instLoading ? 'Guardando...' : (instEditId ? 'Actualizar' : 'Crear instancia')">
|
|
</button>
|
|
</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">×</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">×</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">×</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">×</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">×</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' },
|
|
{ id: 'instancias', label: 'Instancias' },
|
|
],
|
|
|
|
// ─── Multi-instancia ──────────────────────────
|
|
configs: [],
|
|
selectedConfigId: null,
|
|
|
|
// Datos por tab
|
|
apps: [],
|
|
servers: [],
|
|
services: [],
|
|
databases: [],
|
|
projects: [],
|
|
deployments: [],
|
|
teamInfo: null,
|
|
teamMembers: [],
|
|
|
|
// Filtros
|
|
appTagFilter: '',
|
|
|
|
// Action loading by uuid
|
|
actionLoading: {},
|
|
actionPhase: {},
|
|
|
|
// ─── Config modal (legado) ────────────────────
|
|
configModal: false,
|
|
cfgLoading: false,
|
|
cfgMsg: '',
|
|
cfgOk: false,
|
|
cfgForm: { nombre: '', base_url: '', api_token: '', activo: true },
|
|
|
|
// ─── Instancia modal ──────────────────────────
|
|
instModal: false,
|
|
instLoading: false,
|
|
instMsg: '',
|
|
instOk: false,
|
|
instEditId: null,
|
|
instForm: { 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.loadConfigs();
|
|
if (this.hasConfig) this.loadTab();
|
|
},
|
|
|
|
// Retorna el querystring para seleccionar instancia: ?config_id=X
|
|
configQs(sep = '?') {
|
|
return this.selectedConfigId ? `${sep}config_id=${this.selectedConfigId}` : '';
|
|
},
|
|
|
|
onInstanceChange() {
|
|
if (this.activeTab !== 'instancias') 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';
|
|
},
|
|
|
|
// ─── Instancias ───────────────────────────────────
|
|
async loadConfigs() {
|
|
try {
|
|
const r = await fetch('/app/coolify/configs');
|
|
const j = await r.json();
|
|
this.configs = j.items || [];
|
|
const active = this.configs.find(c => c.Activo || c.activo);
|
|
if (active && !this.selectedConfigId) {
|
|
this.selectedConfigId = active.ID || active.id;
|
|
}
|
|
this.hasConfig = this.configs.some(c => c.Activo || c.activo);
|
|
} catch(e) {
|
|
this.hasConfig = false;
|
|
}
|
|
},
|
|
|
|
async openInstModal(cfg = null) {
|
|
this.instMsg = '';
|
|
if (!cfg) {
|
|
this.instEditId = null;
|
|
this.instForm = { nombre: '', base_url: '', api_token: '', activo: true };
|
|
this.instModal = true;
|
|
return;
|
|
}
|
|
const id = cfg.ID || cfg.id;
|
|
this.instEditId = id;
|
|
// Valores locales como fallback mientras carga
|
|
this.instForm = { nombre: cfg.Nombre || cfg.nombre || '', base_url: cfg.BaseURL || cfg.base_url || '', api_token: '', activo: cfg.Activo ?? cfg.activo ?? true };
|
|
this.instModal = true;
|
|
// Refresco desde el server para garantizar datos actualizados
|
|
try {
|
|
const r = await fetch(`/app/coolify/configs/${id}`);
|
|
if (r.ok) {
|
|
const j = await r.json();
|
|
this.instForm = { nombre: j.nombre || '', base_url: j.base_url || '', api_token: '', activo: j.activo ?? true };
|
|
}
|
|
} catch(_) {}
|
|
},
|
|
|
|
async saveInst() {
|
|
this.instLoading = true;
|
|
this.instMsg = '';
|
|
try {
|
|
const isEdit = !!this.instEditId;
|
|
const url = isEdit ? `/app/coolify/configs/${this.instEditId}` : '/app/coolify/configs';
|
|
const meth = isEdit ? 'PUT' : 'POST';
|
|
const r = await fetch(url, {
|
|
method: meth,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(this.instForm)
|
|
});
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
this.instOk = true;
|
|
this.instMsg = isEdit ? 'Instancia actualizada.' : 'Instancia creada.';
|
|
await this.loadConfigs();
|
|
if (!isEdit && j.id) this.selectedConfigId = j.id;
|
|
setTimeout(() => { this.instModal = false; if (this.hasConfig && this.activeTab !== 'instancias') this.loadTab(); }, 1200);
|
|
} else {
|
|
this.instOk = false;
|
|
this.instMsg = j.error || 'Error al guardar.';
|
|
}
|
|
} catch(e) {
|
|
this.instOk = false;
|
|
this.instMsg = 'Error de red: ' + e.message;
|
|
} finally {
|
|
this.instLoading = false;
|
|
}
|
|
},
|
|
|
|
async deleteInst(id) {
|
|
if (!confirm('¿Eliminar esta instancia de Coolify?')) return;
|
|
const r = await fetch(`/app/coolify/configs/${id}`, { method: 'DELETE' });
|
|
const j = await r.json();
|
|
if (j.ok) {
|
|
if (this.selectedConfigId === id) this.selectedConfigId = null;
|
|
await this.loadConfigs();
|
|
this.flash('Instancia eliminada.');
|
|
} else {
|
|
this.flash(j.error || 'Error al eliminar.', false);
|
|
}
|
|
},
|
|
|
|
async testInst(id) {
|
|
const r = await fetch(`/app/coolify/configs/${id}/test`);
|
|
if (r.ok) {
|
|
this.flash('✓ Conexión exitosa con la instancia.');
|
|
} else {
|
|
const j = await r.json().catch(() => ({}));
|
|
this.flash('Error: ' + (j.error || r.status), false);
|
|
}
|
|
},
|
|
|
|
// Legado: mantiene compatibilidad con código existente
|
|
openConfigModal() { this.openInstModal(); },
|
|
async testConnection() { if (this.selectedConfigId) await this.testInst(this.selectedConfigId); },
|
|
|
|
// ─── Tab navigation ──────────────────────────────
|
|
setTab(tab) {
|
|
this.activeTab = tab;
|
|
this.loadTab();
|
|
},
|
|
|
|
loadTab() {
|
|
if (this.activeTab === 'instancias') return; // se carga en init
|
|
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 {
|
|
let qs = this.configQs();
|
|
if (this.appTagFilter) qs += (qs ? '&' : '?') + `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 || []);
|
|
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' + this.configQs());
|
|
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' + this.configQs());
|
|
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' + this.configQs());
|
|
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' + this.configQs());
|
|
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' + this.configQs());
|
|
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 qs = this.configQs();
|
|
const [rt, rm] = await Promise.all([
|
|
fetch('/app/coolify/team' + qs),
|
|
fetch('/app/coolify/team/members' + qs),
|
|
]);
|
|
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}${this.configQs()}`);
|
|
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${this.configQs()}`, { 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>
|