feat: soporte multi-instancia Coolify en panel web

This commit is contained in:
Lizandro GD
2026-07-22 02:07:25 +00:00
parent d9933a754b
commit ed19eb3fd6
3 changed files with 224 additions and 74 deletions
+215 -72
View File
@@ -13,22 +13,24 @@
<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 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" 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.
<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>
@@ -517,6 +519,106 @@
</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="space-y-3">
<template x-for="cfg in configs" :key="cfg.ID || cfg.id">
<div class="flex items-center justify-between bg-gray-50 border rounded-xl px-4 py-3">
<div class="flex items-center gap-3">
<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"
x-text="(cfg.Activo || cfg.activo) ? 'Activa' : 'Inactiva'">
</span>
<div>
<p class="text-sm font-semibold text-gray-800" x-text="cfg.Nombre || cfg.nombre || 'Sin nombre'"></p>
<p class="text-xs text-gray-400 font-mono" x-text="cfg.BaseURL || cfg.base_url"></p>
</div>
</div>
<div class="flex items-center gap-2">
<button @click="selectedConfigId = cfg.ID || cfg.id; flash('Instancia seleccionada: ' + (cfg.Nombre || cfg.nombre))"
:class="selectedConfigId === (cfg.ID || cfg.id) ? 'bg-indigo-100 text-indigo-700 border-indigo-200' : 'bg-white text-gray-600 border-gray-200'"
class="text-xs px-3 py-1.5 rounded-lg border font-medium hover:bg-indigo-50 transition">
Usar
</button>
<button @click="testInst(cfg.ID || cfg.id)"
class="text-xs px-3 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-100 transition">
Probar
</button>
<button @click="openInstModal(cfg)"
class="text-xs px-3 py-1.5 rounded-lg border border-gray-200 bg-white text-gray-600 hover:bg-gray-100 transition">
Editar
</button>
<button @click="deleteInst(cfg.ID || cfg.id)"
class="text-xs px-3 py-1.5 rounded-lg border border-red-100 bg-white text-red-500 hover:bg-red-50 transition">
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">&times;</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">
@@ -660,8 +762,13 @@ document.addEventListener('alpine:init', () => {
{ 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: [],
@@ -679,13 +786,21 @@ document.addEventListener('alpine:init', () => {
actionLoading: {},
actionPhase: {},
// ─── Config modal ─────────────────────────────
// ─── 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,
@@ -729,10 +844,19 @@ document.addEventListener('alpine:init', () => {
// ═════════════════════════════════════════════
async init() {
await this.loadConfig();
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 '—';
@@ -843,72 +967,89 @@ document.addEventListener('alpine:init', () => {
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;
// ─── 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;
}
},
openConfigModal() {
this.cfgMsg = '';
this.configModal = true;
openInstModal(cfg = null) {
this.instMsg = '';
this.instEditId = cfg ? (cfg.ID || cfg.id) : null;
this.instForm = cfg
? { nombre: cfg.Nombre || cfg.nombre || '', base_url: cfg.BaseURL || cfg.base_url || '', api_token: '', activo: cfg.Activo ?? cfg.activo ?? true }
: { nombre: '', base_url: '', api_token: '', activo: true };
this.instModal = true;
},
async saveConfig() {
this.cfgLoading = true;
this.cfgMsg = '';
async saveInst() {
this.instLoading = true;
this.instMsg = '';
try {
const r = await fetch('/app/coolify/config', {
method: 'POST',
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.cfgForm)
body: JSON.stringify(this.instForm)
});
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);
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.cfgOk = false;
this.cfgMsg = j.error || 'Error al guardar.';
this.instOk = false;
this.instMsg = j.error || 'Error al guardar.';
}
} catch(e) {
this.cfgOk = false;
this.cfgMsg = 'Error de red: ' + e.message;
this.instOk = false;
this.instMsg = 'Error de red: ' + e.message;
} finally {
this.cfgLoading = false;
this.instLoading = 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;
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;
@@ -916,6 +1057,7 @@ document.addEventListener('alpine:init', () => {
},
loadTab() {
if (this.activeTab === 'instancias') return; // se carga en init
if (!this.hasConfig) return;
const map = {
apps: () => this.loadApps(),
@@ -934,11 +1076,11 @@ document.addEventListener('alpine:init', () => {
async loadApps() {
this.loading = true;
try {
const qs = this.appTagFilter ? `?tag=${encodeURIComponent(this.appTagFilter)}` : '';
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 || []);
// 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); }
@@ -948,7 +1090,7 @@ document.addEventListener('alpine:init', () => {
async loadServers() {
this.loading = true;
try {
const r = await fetch('/app/coolify/servers');
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());
@@ -959,7 +1101,7 @@ document.addEventListener('alpine:init', () => {
async loadServices() {
this.loading = true;
try {
const r = await fetch('/app/coolify/services');
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;
@@ -971,7 +1113,7 @@ document.addEventListener('alpine:init', () => {
async loadDatabases() {
this.loading = true;
try {
const r = await fetch('/app/coolify/databases');
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;
@@ -983,7 +1125,7 @@ document.addEventListener('alpine:init', () => {
async loadProjects() {
this.loading = true;
try {
const r = await fetch('/app/coolify/projects');
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());
@@ -994,7 +1136,7 @@ document.addEventListener('alpine:init', () => {
async loadDeployments() {
this.loading = true;
try {
const r = await fetch('/app/coolify/deployments');
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 || []);
@@ -1006,9 +1148,10 @@ document.addEventListener('alpine:init', () => {
async loadTeam() {
this.loading = true;
try {
const qs = this.configQs();
const [rt, rm] = await Promise.all([
fetch('/app/coolify/team'),
fetch('/app/coolify/team/members'),
fetch('/app/coolify/team' + qs),
fetch('/app/coolify/team/members' + qs),
]);
const jt = await rt.json();
const jm = await rm.json();
@@ -1028,7 +1171,7 @@ document.addEventListener('alpine:init', () => {
this.actionPhase = { ...this.actionPhase, [uuid]: phase };
let clearNow = true;
try {
const r = await fetch(`/app/coolify/apps/${uuid}/${action}`);
const r = await fetch(`/app/coolify/apps/${uuid}/${action}${this.configQs()}`);
const j = await r.json();
if (r.ok) {
clearNow = false;
@@ -1053,7 +1196,7 @@ document.addEventListener('alpine:init', () => {
this.actionPhase = { ...this.actionPhase, [uuid]: this.actionTextFor('deploy') };
let clearNow = true;
try {
const r = await fetch(`/app/coolify/apps/${uuid}/deploy`, { method: 'POST' });
const r = await fetch(`/app/coolify/apps/${uuid}/deploy${this.configQs()}`, { method: 'POST' });
const j = await r.json();
if (r.ok) {
clearNow = false;
+2 -2
View File
@@ -113,11 +113,11 @@ func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
// ─── Página principal ─────────────────────────────────────────────────────────
func CoolifyIndex(c *fiber.Ctx) error {
cfg, _ := models.GetCoolifyConfig()
cfgs, _ := models.GetAllCoolifyConfigs()
return c.Render("coolify", fiber.Map{
"user": c.Locals("user").(map[string]interface{}),
"modules": c.Locals("userModules"),
"config": cfg,
"configs": cfgs,
}, "layouts/main")
}
+7
View File
@@ -192,6 +192,13 @@ func UserRoutes(app fiber.Router) {
// ─── Coolify API ──────────────────────────────────────────────────
protected.Get("/coolify", middlewares.MenuMiddleware, controllers.CoolifyIndex)
// Gestión de instancias Coolify (multi-instancia)
protected.Get("/coolify/configs", controllers.CoolifyListConfigs)
protected.Post("/coolify/configs", controllers.CoolifyCreateConfig)
protected.Put("/coolify/configs/:id", controllers.CoolifyUpdateConfig)
protected.Delete("/coolify/configs/:id", controllers.CoolifyDeleteConfig)
protected.Get("/coolify/configs/:id/test", controllers.CoolifyTestConfig)
// Compat legado (upsert sobre primer registro)
protected.Get("/coolify/config", controllers.CoolifyGetConfig)
protected.Post("/coolify/config", controllers.CoolifySaveConfig)
protected.Get("/coolify/health", controllers.CoolifyTestConnection)