/** * Nexus Instance Manager — Frontend SPA */ const API_BASE = ""; let currentToken = localStorage.getItem("manager_token") || ""; // ─── Router ──────────────────────────────────────────────────────────────── const routes = { "#dashboard": "dashboard", "#demos": "demos", "#tenants": "tenants", "#health": "health", "#migrations": "migrations" }; function navigate() { const hash = window.location.hash || "#dashboard"; const page = routes[hash] || "dashboard"; document.querySelectorAll(".page").forEach(p => p.style.display = "none"); document.getElementById(`page-${page}`).style.display = "block"; document.querySelectorAll(".nav-item").forEach(n => n.classList.remove("active")); const nav = document.querySelector(`.nav-item[data-page="${page}"]`); if (nav) nav.classList.add("active"); const titles = { dashboard: "Dashboard", demos: "Crear Demos", tenants: "Tenants", health: "Salud del Sistema", migrations: "Migraciones" }; document.getElementById("page-title").textContent = titles[page] || "Dashboard"; // Load page data if (page === "dashboard") loadDashboard(); if (page === "demos") loadDemos(); if (page === "tenants") loadTenants(); if (page === "health") loadHealth(); if (page === "migrations") loadMigrations(); } window.addEventListener("hashchange", navigate); // ─── Auth ────────────────────────────────────────────────────────────────── async function api(url, opts = {}) { const options = { headers: { "Content-Type": "application/json", "Authorization": `Bearer ${currentToken}` }, ...opts }; if (opts.body && typeof opts.body !== "string") { options.body = JSON.stringify(opts.body); } const res = await fetch(`${API_BASE}${url}`, options); if (res.status === 401) { logout(); return null; } const data = await res.json().catch(() => ({})); return { status: res.status, data }; } function showLogin() { document.getElementById("login-screen").style.display = "flex"; document.getElementById("app").style.display = "none"; } function showApp() { document.getElementById("login-screen").style.display = "none"; document.getElementById("app").style.display = "flex"; navigate(); } async function initAuth() { if (!currentToken) { showLogin(); return; } const res = await api("/api/auth/me"); if (res && res.status === 200) { document.getElementById("user-email").textContent = res.data.user.email; showApp(); } else { showLogin(); } } document.getElementById("login-form").addEventListener("submit", async (e) => { e.preventDefault(); const email = document.getElementById("login-email").value; const password = document.getElementById("login-password").value; const errEl = document.getElementById("login-error"); errEl.style.display = "none"; const res = await fetch(`${API_BASE}/api/auth/login`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ email, password }) }); const data = await res.json(); if (res.ok) { currentToken = data.access_token; localStorage.setItem("manager_token", currentToken); document.getElementById("user-email").textContent = data.user.email; showApp(); } else { errEl.textContent = data.error || "Error de autenticación"; errEl.style.display = "block"; } }); function logout() { currentToken = ""; localStorage.removeItem("manager_token"); showLogin(); } // ─── Dashboard ───────────────────────────────────────────────────────────── async function loadDashboard() { const statsRes = await api("/api/admin/stats"); if (statsRes && statsRes.status === 200) { const s = statsRes.data; document.getElementById("stat-total").textContent = s.tenants.total; document.getElementById("stat-active").textContent = s.tenants.active; document.getElementById("stat-demos").textContent = s.tenants.demos; document.getElementById("stat-expiring").textContent = s.tenants.expiring_soon; const healthEl = document.getElementById("system-health-summary"); healthEl.innerHTML = `
Disco usado ${s.system.disk_percent}%
Memoria usada ${s.system.memory_percent}%
Disco libre ${s.system.disk_free_gb} GB
RAM disponible ${s.system.memory_available_gb} GB
`; } const tenantsRes = await api("/api/demos"); if (tenantsRes && tenantsRes.status === 200) { const tbody = document.getElementById("recent-demos-table"); const demos = tenantsRes.data.data.slice(0, 5); tbody.innerHTML = demos.map(d => ` ${escapeHtml(d.name)} ${escapeHtml(d.subdomain)} ${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"} ${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")} `).join("") || `No hay demos activas`; } } function getBarColor(pct) { if (pct < 60) return "var(--success)"; if (pct < 85) return "var(--warning)"; return "var(--danger)"; } // ─── Demos ───────────────────────────────────────────────────────────────── async function loadDemos() { const res = await api("/api/demos"); if (!res || res.status !== 200) return; const tbody = document.getElementById("demos-table"); const demos = res.data.data; tbody.innerHTML = demos.map(d => ` ${escapeHtml(d.name)} ${escapeHtml(d.subdomain)} ${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"} `).join("") || `No hay demos`; } document.getElementById("demo-form").addEventListener("submit", async (e) => { e.preventDefault(); const btn = e.target.querySelector("button[type=submit]"); const originalText = btn.innerHTML; btn.innerHTML = ` Creando...`; btn.disabled = true; const payload = { name: document.getElementById("demo-name").value, email: document.getElementById("demo-email").value, days: parseInt(document.getElementById("demo-days").value), pin: document.getElementById("demo-pin").value, subdomain: document.getElementById("demo-subdomain").value || undefined }; const res = await api("/api/demos", { method: "POST", body: payload }); const resultBox = document.getElementById("demo-result"); if (res && res.status === 201) { const d = res.data.data; resultBox.innerHTML = `

Demo creada exitosamente

URL: ${d.access_url}
Subdominio: ${d.subdomain}
PIN Owner: ${d.owner_pin}
Expira: ${new Date(d.expires_at).toLocaleDateString()}
`; resultBox.style.display = "block"; toast("Demo creada correctamente", "success"); document.getElementById("demo-form").reset(); loadDemos(); } else { toast(res?.data?.error || "Error al crear demo", "error"); } btn.innerHTML = originalText; btn.disabled = false; }); // ─── Tenants ─────────────────────────────────────────────────────────────── async function loadTenants(withStats = false) { const res = await api(`/api/tenants?stats=${withStats}`); if (!res || res.status !== 200) return; const tbody = document.getElementById("tenants-table"); const tenants = res.data.data; document.getElementById("tenant-count").textContent = tenants.length; tbody.innerHTML = tenants.map(t => ` ${t.id} ${escapeHtml(t.name)} ${escapeHtml(t.subdomain)} ${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")} ${t.schema_version || "v0.0"} ${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")} ${formatDate(t.created_at)} `).join("") || `No hay tenants`; } document.getElementById("tenant-search")?.addEventListener("input", (e) => { const term = e.target.value.toLowerCase(); document.querySelectorAll("#tenants-table tr").forEach(row => { row.style.display = row.textContent.toLowerCase().includes(term) ? "" : "none"; }); }); // ─── Health ──────────────────────────────────────────────────────────────── async function loadHealth() { const res = await api("/api/health"); if (!res || res.status !== 200) return; const h = res.data; // PostgreSQL const pg = h.postgresql; document.getElementById("health-postgresql").innerHTML = pg.status === "ok" ? `
EstadoOnline
Versión${pg.version}
Master DB${pg.master_size_mb} MB
` : renderError(pg.error); // Redis const rd = h.redis; document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
EstadoOnline
Versión${rd.version}
Memoria${rd.used_memory_human}
Clientes${rd.connected_clients}
` : renderError(rd.error); // Disk const dk = h.disk; document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
Total${dk.total_gb} GB
Usado${dk.used_gb} GB (${dk.percent_used}%)
Libre${dk.free_gb} GB
` : renderError(dk.error); // Memory const mem = h.memory; document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
Total${mem.total_gb} GB
Usada${mem.used_gb} GB (${mem.percent_used}%)
Disponible${mem.available_gb} GB
` : renderError(mem.error); // Services const svcs = h.services || {}; document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
${name} ${s.state}
`).join(""); // HTTP const httpChecks = ["pos", "dashboard", "quart"]; document.getElementById("health-http").innerHTML = `
${httpChecks.map(key => { const svc = h[key]; const ok = svc && svc.status === "ok"; return `
${key.toUpperCase()} ${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
`; }).join("")}
`; } function renderError(msg) { return `
${escapeHtml(msg)}
`; } // ─── Migrations ──────────────────────────────────────────────────────────── async function loadMigrations() { const res = await api("/api/admin/migrations"); if (!res || res.status !== 200) return; const tbody = document.getElementById("migrations-table"); const tenants = res.data.tenants || []; tbody.innerHTML = tenants.map(t => { const needsUpdate = t.version !== (res.data.migrations.slice(-1)[0]?.version || t.version); return ` ${escapeHtml(t.name)} ${t.db_name} ${t.version} ${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")} `; }).join("") || `No hay tenants`; } async function runAllMigrations() { if (!confirm("¿Ejecutar todas las migraciones pendientes en TODOS los tenants?")) return; const logBox = document.getElementById("migration-log"); logBox.style.display = "block"; logBox.textContent = "Ejecutando migraciones..."; const res = await api("/api/admin/migrations/run-all", { method: "POST" }); if (res && res.status === 200) { logBox.textContent = res.data.log || "Completado"; toast("Migraciones ejecutadas", "success"); loadMigrations(); } else { logBox.textContent = "Error: " + (res?.data?.error || "Unknown"); toast("Error en migraciones", "error"); } } // ─── Actions ─────────────────────────────────────────────────────────────── async function toggleTenant(id, active) { const res = await api(`/api/tenants/${id}/toggle`, { method: "POST", body: { active } }); if (res && res.status === 200) { toast(active ? "Tenant activado" : "Tenant desactivado", "success"); loadTenants(); loadDemos(); } else { toast(res?.data?.error || "Error", "error"); } } async function resetTenant(id) { if (!confirm("¿Resetear TODOS los datos de negocio de este tenant? Se conservan empleados y configuración.")) return; const res = await api(`/api/tenants/${id}/reset`, { method: "POST" }); if (res && res.status === 200) { toast("Tenant reseteado", "success"); } else { toast(res?.data?.error || "Error al resetear", "error"); } } function confirmDelete(id, name) { openModal( "Eliminar Tenant", `¿Eliminar permanentemente ${escapeHtml(name)}? Esta acción no se puede deshacer. Se borrará la base de datos completa.`, async () => { const res = await api(`/api/tenants/${id}`, { method: "DELETE" }); if (res && res.status === 200) { toast("Tenant eliminado", "success"); loadTenants(); loadDemos(); } else { toast(res?.data?.error || "Error al eliminar", "error"); } closeModal(); } ); } // ─── Modal ───────────────────────────────────────────────────────────────── function openModal(title, body, onConfirm) { document.getElementById("modal-title").textContent = title; document.getElementById("modal-body").innerHTML = body; const btn = document.getElementById("modal-confirm-btn"); btn.onclick = onConfirm; document.getElementById("modal").style.display = "flex"; } function closeModal() { document.getElementById("modal").style.display = "none"; } // ─── Toast ───────────────────────────────────────────────────────────────── function toast(message, type = "info") { const container = document.getElementById("toast-container"); const el = document.createElement("div"); el.className = `toast ${type}`; el.innerHTML = ` ${escapeHtml(message)}`; container.appendChild(el); setTimeout(() => { el.style.opacity = "0"; el.style.transform = "translateX(100%)"; setTimeout(() => el.remove(), 300); }, 4000); } // ─── Utilities ───────────────────────────────────────────────────────────── function escapeHtml(text) { if (!text) return ""; const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } function tag(text, type) { return `${escapeHtml(text)}`; } function formatDate(iso) { if (!iso) return "-"; const d = new Date(iso); return d.toLocaleDateString("es-MX"); } function copyText(text) { navigator.clipboard.writeText(text).then(() => toast("Copiado al portapapeles", "success")); } // ─── Init ────────────────────────────────────────────────────────────────── document.addEventListener("DOMContentLoaded", initAuth);