feat: soporte multi-instancia Coolify en panel web
This commit is contained in:
+215
-72
@@ -13,22 +13,24 @@
|
|||||||
<h1 class="text-2xl font-bold">Coolify</h1>
|
<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>
|
<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>
|
||||||
<button @click="openConfigModal()"
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
|
<!-- Selector de instancia -->
|
||||||
style="background-color:#6366f1"
|
<select x-show="configs.length > 1" x-model="selectedConfigId" @change="onInstanceChange()"
|
||||||
onmouseover="this.style.backgroundColor='#4f46e5'"
|
class="text-sm border rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-300">
|
||||||
onmouseout="this.style.backgroundColor='#6366f1'">
|
<template x-for="cfg in configs" :key="cfg.ID">
|
||||||
<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">
|
<option :value="cfg.ID" x-text="cfg.Nombre || cfg.nombre || cfg.base_url"></option>
|
||||||
<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" />
|
</template>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
</select>
|
||||||
</svg>
|
<button @click="setTab('instancias')"
|
||||||
Configurar API
|
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">
|
||||||
</button>
|
+ Nueva instancia
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Alerta sin config -->
|
<!-- 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">
|
<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 configuración activa.</strong> Haz clic en "Configurar API" para ingresar la URL base y el token de tu instancia de Coolify.
|
<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>.
|
El token lo generas en <strong>Coolify → Keys & Tokens → API tokens</strong>.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -517,6 +519,106 @@
|
|||||||
</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="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">×</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 ══════════════════════ -->
|
<!-- ══════════════════════ 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 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 @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: 'projects', label: 'Proyectos' },
|
||||||
{ id: 'deployments', label: 'Despliegues' },
|
{ id: 'deployments', label: 'Despliegues' },
|
||||||
{ id: 'team', label: 'Equipo' },
|
{ id: 'team', label: 'Equipo' },
|
||||||
|
{ id: 'instancias', label: 'Instancias' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// ─── Multi-instancia ──────────────────────────
|
||||||
|
configs: [],
|
||||||
|
selectedConfigId: null,
|
||||||
|
|
||||||
// Datos por tab
|
// Datos por tab
|
||||||
apps: [],
|
apps: [],
|
||||||
servers: [],
|
servers: [],
|
||||||
@@ -679,13 +786,21 @@ document.addEventListener('alpine:init', () => {
|
|||||||
actionLoading: {},
|
actionLoading: {},
|
||||||
actionPhase: {},
|
actionPhase: {},
|
||||||
|
|
||||||
// ─── Config modal ─────────────────────────────
|
// ─── Config modal (legado) ────────────────────
|
||||||
configModal: false,
|
configModal: false,
|
||||||
cfgLoading: false,
|
cfgLoading: false,
|
||||||
cfgMsg: '',
|
cfgMsg: '',
|
||||||
cfgOk: false,
|
cfgOk: false,
|
||||||
cfgForm: { nombre: '', base_url: '', api_token: '', activo: true },
|
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 ───────────────────────────────
|
// ─── Logs modal ───────────────────────────────
|
||||||
logsModal: false,
|
logsModal: false,
|
||||||
logsLoading: false,
|
logsLoading: false,
|
||||||
@@ -729,10 +844,19 @@ document.addEventListener('alpine:init', () => {
|
|||||||
|
|
||||||
// ═════════════════════════════════════════════
|
// ═════════════════════════════════════════════
|
||||||
async init() {
|
async init() {
|
||||||
await this.loadConfig();
|
await this.loadConfigs();
|
||||||
if (this.hasConfig) this.loadTab();
|
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 ────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────
|
||||||
projectEnvLabel(env) {
|
projectEnvLabel(env) {
|
||||||
if (!env) return '—';
|
if (!env) return '—';
|
||||||
@@ -843,72 +967,89 @@ document.addEventListener('alpine:init', () => {
|
|||||||
return 'bg-gray-100 text-gray-500';
|
return 'bg-gray-100 text-gray-500';
|
||||||
},
|
},
|
||||||
|
|
||||||
// ─── Config ──────────────────────────────────────
|
// ─── Instancias ───────────────────────────────────
|
||||||
async loadConfig() {
|
async loadConfigs() {
|
||||||
const r = await fetch('/app/coolify/config');
|
try {
|
||||||
const j = await r.json();
|
const r = await fetch('/app/coolify/configs');
|
||||||
if (j.config) {
|
const j = await r.json();
|
||||||
this.hasConfig = j.config.activo;
|
this.configs = j.items || [];
|
||||||
this.cfgForm.nombre = j.config.nombre || '';
|
const active = this.configs.find(c => c.Activo || c.activo);
|
||||||
this.cfgForm.base_url = j.config.base_url || '';
|
if (active && !this.selectedConfigId) {
|
||||||
this.cfgForm.activo = j.config.activo;
|
this.selectedConfigId = active.ID || active.id;
|
||||||
|
}
|
||||||
|
this.hasConfig = this.configs.some(c => c.Activo || c.activo);
|
||||||
|
} catch(e) {
|
||||||
|
this.hasConfig = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
openConfigModal() {
|
openInstModal(cfg = null) {
|
||||||
this.cfgMsg = '';
|
this.instMsg = '';
|
||||||
this.configModal = true;
|
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() {
|
async saveInst() {
|
||||||
this.cfgLoading = true;
|
this.instLoading = true;
|
||||||
this.cfgMsg = '';
|
this.instMsg = '';
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/app/coolify/config', {
|
const isEdit = !!this.instEditId;
|
||||||
method: 'POST',
|
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' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(this.cfgForm)
|
body: JSON.stringify(this.instForm)
|
||||||
});
|
});
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
if (j.ok) {
|
if (j.ok) {
|
||||||
this.cfgOk = true;
|
this.instOk = true;
|
||||||
this.cfgMsg = 'Configuración guardada correctamente.';
|
this.instMsg = isEdit ? 'Instancia actualizada.' : 'Instancia creada.';
|
||||||
this.hasConfig = this.cfgForm.activo;
|
await this.loadConfigs();
|
||||||
if (this.hasConfig) this.loadTab();
|
if (!isEdit && j.id) this.selectedConfigId = j.id;
|
||||||
setTimeout(() => { this.configModal = false; }, 1200);
|
setTimeout(() => { this.instModal = false; if (this.hasConfig && this.activeTab !== 'instancias') this.loadTab(); }, 1200);
|
||||||
} else {
|
} else {
|
||||||
this.cfgOk = false;
|
this.instOk = false;
|
||||||
this.cfgMsg = j.error || 'Error al guardar.';
|
this.instMsg = j.error || 'Error al guardar.';
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
this.cfgOk = false;
|
this.instOk = false;
|
||||||
this.cfgMsg = 'Error de red: ' + e.message;
|
this.instMsg = 'Error de red: ' + e.message;
|
||||||
} finally {
|
} finally {
|
||||||
this.cfgLoading = false;
|
this.instLoading = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async testConnection() {
|
async deleteInst(id) {
|
||||||
this.cfgLoading = true;
|
if (!confirm('¿Eliminar esta instancia de Coolify?')) return;
|
||||||
this.cfgMsg = '';
|
const r = await fetch(`/app/coolify/configs/${id}`, { method: 'DELETE' });
|
||||||
try {
|
const j = await r.json();
|
||||||
const r = await fetch('/app/coolify/health');
|
if (j.ok) {
|
||||||
const j = await r.json();
|
if (this.selectedConfigId === id) this.selectedConfigId = null;
|
||||||
if (r.ok) {
|
await this.loadConfigs();
|
||||||
this.cfgOk = true;
|
this.flash('Instancia eliminada.');
|
||||||
this.cfgMsg = '✓ Conexión exitosa con Coolify.';
|
} else {
|
||||||
} else {
|
this.flash(j.error || 'Error al eliminar.', false);
|
||||||
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 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 ──────────────────────────────
|
// ─── Tab navigation ──────────────────────────────
|
||||||
setTab(tab) {
|
setTab(tab) {
|
||||||
this.activeTab = tab;
|
this.activeTab = tab;
|
||||||
@@ -916,6 +1057,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadTab() {
|
loadTab() {
|
||||||
|
if (this.activeTab === 'instancias') return; // se carga en init
|
||||||
if (!this.hasConfig) return;
|
if (!this.hasConfig) return;
|
||||||
const map = {
|
const map = {
|
||||||
apps: () => this.loadApps(),
|
apps: () => this.loadApps(),
|
||||||
@@ -934,11 +1076,11 @@ document.addEventListener('alpine:init', () => {
|
|||||||
async loadApps() {
|
async loadApps() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
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 r = await fetch('/app/coolify/apps' + qs);
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
this.apps = Array.isArray(j) ? j : (j.data || []);
|
this.apps = Array.isArray(j) ? j : (j.data || []);
|
||||||
// Pre-init actionLoading para reactividad garantizada en Alpine.js
|
|
||||||
const al = {}; this.apps.forEach(a => { al[a.uuid] = false; }); this.actionLoading = al;
|
const al = {}; this.apps.forEach(a => { al[a.uuid] = false; }); this.actionLoading = al;
|
||||||
this.lastFetch = this.fmtDate(new Date().toISOString());
|
this.lastFetch = this.fmtDate(new Date().toISOString());
|
||||||
} catch(e) { this.flash('Error cargando apps: ' + e.message, false); }
|
} catch(e) { this.flash('Error cargando apps: ' + e.message, false); }
|
||||||
@@ -948,7 +1090,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
async loadServers() {
|
async loadServers() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/app/coolify/servers');
|
const r = await fetch('/app/coolify/servers' + this.configQs());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
this.servers = Array.isArray(j) ? j : (j.data || []);
|
this.servers = Array.isArray(j) ? j : (j.data || []);
|
||||||
this.lastFetch = this.fmtDate(new Date().toISOString());
|
this.lastFetch = this.fmtDate(new Date().toISOString());
|
||||||
@@ -959,7 +1101,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
async loadServices() {
|
async loadServices() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/app/coolify/services');
|
const r = await fetch('/app/coolify/services' + this.configQs());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
this.services = Array.isArray(j) ? j : (j.data || []);
|
this.services = Array.isArray(j) ? j : (j.data || []);
|
||||||
const sl = { ...this.actionLoading }; this.services.forEach(s => { if (!(s.uuid in sl)) sl[s.uuid] = false; }); this.actionLoading = sl;
|
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() {
|
async loadDatabases() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/app/coolify/databases');
|
const r = await fetch('/app/coolify/databases' + this.configQs());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
this.databases = Array.isArray(j) ? j : (j.data || []);
|
this.databases = Array.isArray(j) ? j : (j.data || []);
|
||||||
const dl = { ...this.actionLoading }; this.databases.forEach(d => { if (!(d.uuid in dl)) dl[d.uuid] = false; }); this.actionLoading = dl;
|
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() {
|
async loadProjects() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/app/coolify/projects');
|
const r = await fetch('/app/coolify/projects' + this.configQs());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
this.projects = Array.isArray(j) ? j : (j.data || []);
|
this.projects = Array.isArray(j) ? j : (j.data || []);
|
||||||
this.lastFetch = this.fmtDate(new Date().toISOString());
|
this.lastFetch = this.fmtDate(new Date().toISOString());
|
||||||
@@ -994,7 +1136,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
async loadDeployments() {
|
async loadDeployments() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
const r = await fetch('/app/coolify/deployments');
|
const r = await fetch('/app/coolify/deployments' + this.configQs());
|
||||||
const j = await r.json();
|
const j = await r.json();
|
||||||
this.deployments = Array.isArray(j) ? j
|
this.deployments = Array.isArray(j) ? j
|
||||||
: (j.deployments || j.items || j.data || j.result || j.deployments_list || []);
|
: (j.deployments || j.items || j.data || j.result || j.deployments_list || []);
|
||||||
@@ -1006,9 +1148,10 @@ document.addEventListener('alpine:init', () => {
|
|||||||
async loadTeam() {
|
async loadTeam() {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
try {
|
try {
|
||||||
|
const qs = this.configQs();
|
||||||
const [rt, rm] = await Promise.all([
|
const [rt, rm] = await Promise.all([
|
||||||
fetch('/app/coolify/team'),
|
fetch('/app/coolify/team' + qs),
|
||||||
fetch('/app/coolify/team/members'),
|
fetch('/app/coolify/team/members' + qs),
|
||||||
]);
|
]);
|
||||||
const jt = await rt.json();
|
const jt = await rt.json();
|
||||||
const jm = await rm.json();
|
const jm = await rm.json();
|
||||||
@@ -1028,7 +1171,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.actionPhase = { ...this.actionPhase, [uuid]: phase };
|
this.actionPhase = { ...this.actionPhase, [uuid]: phase };
|
||||||
let clearNow = true;
|
let clearNow = true;
|
||||||
try {
|
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();
|
const j = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
clearNow = false;
|
clearNow = false;
|
||||||
@@ -1053,7 +1196,7 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.actionPhase = { ...this.actionPhase, [uuid]: this.actionTextFor('deploy') };
|
this.actionPhase = { ...this.actionPhase, [uuid]: this.actionTextFor('deploy') };
|
||||||
let clearNow = true;
|
let clearNow = true;
|
||||||
try {
|
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();
|
const j = await r.json();
|
||||||
if (r.ok) {
|
if (r.ok) {
|
||||||
clearNow = false;
|
clearNow = false;
|
||||||
|
|||||||
@@ -113,11 +113,11 @@ func coolifyProxy(c *fiber.Ctx, method, endpoint string) error {
|
|||||||
// ─── Página principal ─────────────────────────────────────────────────────────
|
// ─── Página principal ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func CoolifyIndex(c *fiber.Ctx) error {
|
func CoolifyIndex(c *fiber.Ctx) error {
|
||||||
cfg, _ := models.GetCoolifyConfig()
|
cfgs, _ := models.GetAllCoolifyConfigs()
|
||||||
return c.Render("coolify", fiber.Map{
|
return c.Render("coolify", fiber.Map{
|
||||||
"user": c.Locals("user").(map[string]interface{}),
|
"user": c.Locals("user").(map[string]interface{}),
|
||||||
"modules": c.Locals("userModules"),
|
"modules": c.Locals("userModules"),
|
||||||
"config": cfg,
|
"configs": cfgs,
|
||||||
}, "layouts/main")
|
}, "layouts/main")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -192,6 +192,13 @@ func UserRoutes(app fiber.Router) {
|
|||||||
|
|
||||||
// ─── Coolify API ──────────────────────────────────────────────────
|
// ─── Coolify API ──────────────────────────────────────────────────
|
||||||
protected.Get("/coolify", middlewares.MenuMiddleware, controllers.CoolifyIndex)
|
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.Get("/coolify/config", controllers.CoolifyGetConfig)
|
||||||
protected.Post("/coolify/config", controllers.CoolifySaveConfig)
|
protected.Post("/coolify/config", controllers.CoolifySaveConfig)
|
||||||
protected.Get("/coolify/health", controllers.CoolifyTestConnection)
|
protected.Get("/coolify/health", controllers.CoolifyTestConnection)
|
||||||
|
|||||||
Reference in New Issue
Block a user