/**
* 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"));
}
// ─── Modules ───────────────────────────────────────────────────────────────
let currentModulesTenantId = null;
async function openModulesModal(tenantId, name) {
currentModulesTenantId = tenantId;
document.getElementById("modules-modal-title").textContent = `Módulos — ${escapeHtml(name)}`;
document.getElementById("modules-modal").style.display = "flex";
// Load current state
const res = await api(`/api/tenants/${tenantId}/modules`);
if (res && res.status === 200) {
const m = res.data.data;
document.getElementById("mod-whatsapp").checked = m.whatsapp !== false;
document.getElementById("mod-marketplace").checked = m.marketplace !== false;
document.getElementById("mod-meli").checked = m.meli !== false;
} else {
toast("Error al cargar módulos", "error");
}
}
function closeModulesModal() {
document.getElementById("modules-modal").style.display = "none";
currentModulesTenantId = null;
}
async function saveModules() {
if (!currentModulesTenantId) return;
const btn = document.getElementById("modules-save-btn");
const originalText = btn.innerHTML;
btn.innerHTML = ` Guardando...`;
btn.disabled = true;
const payload = {
whatsapp: document.getElementById("mod-whatsapp").checked,
marketplace: document.getElementById("mod-marketplace").checked,
meli: document.getElementById("mod-meli").checked,
};
const res = await api(`/api/tenants/${currentModulesTenantId}/modules`, {
method: "PUT",
body: payload
});
if (res && res.status === 200) {
toast("Módulos actualizados", "success");
closeModulesModal();
} else {
toast(res?.data?.error || "Error al guardar", "error");
}
btn.innerHTML = originalText;
btn.disabled = false;
}
// ─── Init ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", initAuth);