diff --git a/resources/views/coolify.html b/resources/views/coolify.html
index 6b4b38f..5f943f7 100644
--- a/resources/views/coolify.html
+++ b/resources/views/coolify.html
@@ -13,22 +13,24 @@
Gestión completa de aplicaciones, servicios, bases de datos, servidores y despliegues a través de la API de Coolify.
-
-
Sin configuración activa. Haz clic en "Configurar API" para ingresar la URL base y el token de tu instancia de Coolify.
+
+ Sin instancia activa. Haz clic en "+ Nueva instancia" para agregar tu primera instancia de Coolify.
El token lo generas en Coolify → Keys & Tokens → API tokens.
@@ -517,6 +519,106 @@
+
+
@@ -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;
diff --git a/rest/controllers/coolify_controller.go b/rest/controllers/coolify_controller.go
index d80a82d..82d7398 100644
--- a/rest/controllers/coolify_controller.go
+++ b/rest/controllers/coolify_controller.go
@@ -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")
}
diff --git a/rest/routes/user.go b/rest/routes/user.go
index 5e95b76..2381d49 100755
--- a/rest/routes/user.go
+++ b/rest/routes/user.go
@@ -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)