feat(manager): add Nexus Instance Manager for demo orchestration

- Complete Flask-based control panel for multi-tenant POS instances
- Dashboard with global stats, system health, and recent demos
- Demo provisioning in 1 click with auto-expiration tracking
- Tenant management: activate/deactivate, reset data, delete
- Health monitoring: PostgreSQL, Redis, disk, memory, systemd services
- Migration orchestration UI for running schema updates across all tenants
- JWT authentication with manager_users table
- Dark theme SPA frontend with real-time search and actions
- systemd service file included
This commit is contained in:
2026-05-17 21:01:01 +00:00
parent da362e32a6
commit be4bb8d9ad
20 changed files with 2685 additions and 0 deletions

View File

@@ -0,0 +1,663 @@
:root {
--bg-dark: #0f1117;
--bg-card: #1a1d26;
--bg-sidebar: #161920;
--bg-hover: #232631;
--border: #2a2e3b;
--text-primary: #e8eaf0;
--text-secondary: #9ca3af;
--accent: #3b82f6;
--accent-hover: #2563eb;
--success: #22c55e;
--warning: #f59e0b;
--danger: #ef4444;
--info: #06b6d4;
--purple: #8b5cf6;
--radius: 10px;
--shadow: 0 4px 6px -1px rgba(0,0,0,0.3), 0 2px 4px -1px rgba(0,0,0,0.2);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
background: var(--bg-dark);
color: var(--text-primary);
font-size: 14px;
line-height: 1.5;
overflow: hidden;
}
/* ─── Login ─────────────────────────────────────────────────────────────── */
.login-screen {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0f1117 0%, #1a1d26 100%);
}
.login-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: var(--shadow);
}
.login-logo {
text-align: center;
margin-bottom: 32px;
}
.login-logo i {
font-size: 48px;
color: var(--accent);
margin-bottom: 12px;
}
.login-logo h1 {
font-size: 24px;
font-weight: 700;
margin-bottom: 4px;
}
.login-logo p {
color: var(--text-secondary);
font-size: 14px;
}
/* ─── Layout ────────────────────────────────────────────────────────────── */
.app {
display: flex;
height: 100vh;
overflow: hidden;
}
.sidebar {
width: 260px;
background: var(--bg-sidebar);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
}
.sidebar-brand {
padding: 20px 24px;
display: flex;
align-items: center;
gap: 12px;
font-size: 18px;
font-weight: 700;
border-bottom: 1px solid var(--border);
}
.sidebar-brand i {
color: var(--accent);
font-size: 22px;
}
.sidebar-nav {
flex: 1;
padding: 16px 12px;
display: flex;
flex-direction: column;
gap: 4px;
}
.nav-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border-radius: 8px;
color: var(--text-secondary);
text-decoration: none;
transition: all 0.2s;
position: relative;
}
.nav-item:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.nav-item.active {
background: rgba(59, 130, 246, 0.15);
color: var(--accent);
font-weight: 500;
}
.nav-item .badge {
margin-left: auto;
background: var(--bg-hover);
color: var(--text-secondary);
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
}
.sidebar-footer {
padding: 16px;
border-top: 1px solid var(--border);
}
.user-info {
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-secondary);
font-size: 13px;
}
.main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.topbar {
height: 60px;
background: var(--bg-card);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 28px;
}
.topbar h2 {
font-size: 18px;
font-weight: 600;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--success);
}
.status-indicator i {
font-size: 8px;
}
.status-indicator.warning { color: var(--warning); }
.status-indicator.error { color: var(--danger); }
.content {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
}
.page { animation: fadeIn 0.2s ease; }
@keyframes fadeIn {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
/* ─── Cards & Grid ──────────────────────────────────────────────────────── */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
}
.card-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.card-header h3 {
font-size: 15px;
font-weight: 600;
display: flex;
align-items: center;
gap: 10px;
}
.card-header h3 i {
color: var(--accent);
}
.card-body {
padding: 24px;
}
.card-actions {
display: flex;
gap: 10px;
align-items: center;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px;
margin-bottom: 24px;
}
.stat-card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
display: flex;
align-items: center;
gap: 16px;
box-shadow: var(--shadow);
}
.stat-icon {
width: 48px;
height: 48px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #fff;
}
.bg-blue { background: linear-gradient(135deg, #3b82f6, #2563eb); }
.bg-green { background: linear-gradient(135deg, #22c55e, #16a34a); }
.bg-purple { background: linear-gradient(135deg, #8b5cf6, #7c3aed); }
.bg-orange { background: linear-gradient(135deg, #f59e0b, #d97706); }
.bg-red { background: linear-gradient(135deg, #ef4444, #dc2626); }
.bg-cyan { background: linear-gradient(135deg, #06b6d4, #0891b2); }
.stat-info h3 {
font-size: 24px;
font-weight: 700;
margin-bottom: 2px;
}
.stat-info p {
color: var(--text-secondary);
font-size: 13px;
}
.grid-2 {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 20px;
}
.grid-3 {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
/* ─── Tables ────────────────────────────────────────────────────────────── */
.table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.table th {
text-align: left;
padding: 12px 16px;
color: var(--text-secondary);
font-weight: 500;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.table td {
padding: 14px 16px;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.table tr:hover td {
background: rgba(255,255,255,0.02);
}
.table.compact td, .table.compact th {
padding: 10px 12px;
}
/* ─── Forms ─────────────────────────────────────────────────────────────── */
.form-group {
margin-bottom: 18px;
}
.form-group label {
display: block;
margin-bottom: 6px;
font-size: 13px;
font-weight: 500;
color: var(--text-secondary);
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 10px 14px;
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 8px;
color: var(--text-primary);
font-size: 14px;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: var(--accent);
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.input-group {
display: flex;
align-items: center;
}
.input-group input {
border-radius: 8px 0 0 8px;
border-right: none;
}
.input-suffix {
padding: 10px 14px;
background: var(--bg-hover);
border: 1px solid var(--border);
border-radius: 0 8px 8px 0;
color: var(--text-secondary);
font-size: 13px;
white-space: nowrap;
}
/* ─── Buttons ───────────────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 18px;
border-radius: 8px;
border: none;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
text-decoration: none;
}
.btn-primary {
background: var(--accent);
color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); }
.btn-secondary {
background: var(--bg-hover);
color: var(--text-primary);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--border); }
.btn-danger {
background: var(--danger);
color: #fff;
}
.btn-danger:hover { background: #dc2626; }
.btn-success {
background: var(--success);
color: #fff;
}
.btn-sm { padding: 6px 12px; font-size: 12px; }
.btn-block { width: 100%; justify-content: center; }
.btn-icon {
background: transparent;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 6px;
border-radius: 6px;
}
.btn-icon:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
/* ─── Badges & Tags ─────────────────────────────────────────────────────── */
.tag {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.3px;
}
.tag-success { background: rgba(34,197,94,0.15); color: var(--success); }
.tag-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
.tag-danger { background: rgba(239,68,68,0.15); color: var(--danger); }
.tag-info { background: rgba(6,182,212,0.15); color: var(--info); }
.tag-default { background: var(--bg-hover); color: var(--text-secondary); }
/* ─── Alerts & Boxes ────────────────────────────────────────────────────── */
.alert {
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
margin-top: 12px;
}
.alert-error {
background: rgba(239,68,68,0.1);
border: 1px solid rgba(239,68,68,0.2);
color: var(--danger);
}
.alert-success {
background: rgba(34,197,94,0.1);
border: 1px solid rgba(34,197,94,0.2);
color: var(--success);
}
.result-box {
background: rgba(34,197,94,0.05);
border: 1px solid rgba(34,197,94,0.2);
border-radius: 8px;
padding: 16px;
margin-top: 16px;
}
.result-box h4 {
margin-bottom: 8px;
color: var(--success);
}
.result-box .copy-row {
display: flex;
align-items: center;
gap: 10px;
margin: 6px 0;
font-size: 13px;
}
.result-box code {
background: var(--bg-dark);
padding: 4px 8px;
border-radius: 4px;
font-family: 'Fira Code', monospace;
font-size: 12px;
}
.log-box {
background: var(--bg-dark);
border: 1px solid var(--border);
border-radius: 8px;
padding: 16px;
margin-bottom: 16px;
font-family: 'Fira Code', monospace;
font-size: 12px;
white-space: pre-wrap;
max-height: 300px;
overflow-y: auto;
}
/* ─── Modal ─────────────────────────────────────────────────────────────── */
.modal {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-overlay {
position: absolute;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
}
.modal-content {
position: relative;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
width: 100%;
max-width: 480px;
box-shadow: var(--shadow);
animation: modalIn 0.2s ease;
}
@keyframes modalIn {
from { opacity: 0; transform: scale(0.95); }
to { opacity: 1; transform: scale(1); }
}
.modal-header {
padding: 20px 24px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.modal-body {
padding: 24px;
color: var(--text-secondary);
}
.modal-footer {
padding: 16px 24px;
border-top: 1px solid var(--border);
display: flex;
justify-content: flex-end;
gap: 10px;
}
/* ─── Toast ─────────────────────────────────────────────────────────────── */
#toast-container {
position: fixed;
top: 20px;
right: 20px;
z-index: 2000;
display: flex;
flex-direction: column;
gap: 10px;
}
.toast {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
box-shadow: var(--shadow);
display: flex;
align-items: center;
gap: 10px;
animation: toastIn 0.3s ease;
min-width: 280px;
}
.toast.success { border-left: 3px solid var(--success); }
.toast.error { border-left: 3px solid var(--danger); }
.toast.warning { border-left: 3px solid var(--warning); }
@keyframes toastIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
/* ─── Utilities ─────────────────────────────────────────────────────────── */
.loading {
color: var(--text-secondary);
font-style: italic;
padding: 20px;
text-align: center;
}
.text-muted { color: var(--text-secondary); }
.text-center { text-align: center; }
.health-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.health-item:last-child { border-bottom: none; }
.health-label { color: var(--text-secondary); font-size: 13px; }
.health-value { font-weight: 500; font-size: 13px; }
.health-bar-bg {
height: 6px;
background: var(--bg-dark);
border-radius: 3px;
margin-top: 8px;
overflow: hidden;
}
.health-bar-fill {
height: 100%;
border-radius: 3px;
transition: width 0.5s ease;
}
/* ─── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 1200px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
.grid-2, .grid-3 { grid-template-columns: 1fr; }
}
@media (max-width: 768px) {
.sidebar { width: 64px; }
.sidebar-brand span, .nav-item span, .user-info span { display: none; }
.stats-grid { grid-template-columns: 1fr; }
}

View File

@@ -0,0 +1,479 @@
/**
* 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 = `
<div class="health-item">
<span class="health-label">Disco usado</span>
<span class="health-value">${s.system.disk_percent}%</span>
</div>
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.disk_percent}%; background:${getBarColor(s.system.disk_percent)}"></div></div>
<div class="health-item" style="margin-top:12px">
<span class="health-label">Memoria usada</span>
<span class="health-value">${s.system.memory_percent}%</span>
</div>
<div class="health-bar-bg"><div class="health-bar-fill bg-blue" style="width:${s.system.memory_percent}%; background:${getBarColor(s.system.memory_percent)}"></div></div>
<div class="health-item" style="margin-top:12px">
<span class="health-label">Disco libre</span>
<span class="health-value">${s.system.disk_free_gb} GB</span>
</div>
<div class="health-item">
<span class="health-label">RAM disponible</span>
<span class="health-value">${s.system.memory_available_gb} GB</span>
</div>
`;
}
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 => `
<tr>
<td><strong>${escapeHtml(d.name)}</strong></td>
<td><code>${escapeHtml(d.subdomain)}</code></td>
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<td>${d.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
</tr>
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos activas</td></tr>`;
}
}
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 => `
<tr>
<td><strong>${escapeHtml(d.name)}</strong></td>
<td><a href="https://${escapeHtml(d.subdomain)}.nexusautoparts.com.mx/pos/login" target="_blank" style="color:var(--accent)">${escapeHtml(d.subdomain)}</a></td>
<td>${d.demo_days_left !== null ? d.demo_days_left + " días" : "N/A"}</td>
<td>
<button class="btn-icon" onclick="resetTenant(${d.id})" title="Resetear"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${d.id}, ${!d.is_active})" title="${d.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${d.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${d.id}, '${escapeHtml(d.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
</td>
</tr>
`).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay demos</td></tr>`;
}
document.getElementById("demo-form").addEventListener("submit", async (e) => {
e.preventDefault();
const btn = e.target.querySelector("button[type=submit]");
const originalText = btn.innerHTML;
btn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> 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 = `
<h4><i class="fas fa-check-circle"></i> Demo creada exitosamente</h4>
<div class="copy-row"><strong>URL:</strong> <code>${d.access_url}</code> <button class="btn-icon" onclick="copyText('${d.access_url}')"><i class="fas fa-copy"></i></button></div>
<div class="copy-row"><strong>Subdominio:</strong> <code>${d.subdomain}</code></div>
<div class="copy-row"><strong>PIN Owner:</strong> <code>${d.owner_pin}</code></div>
<div class="copy-row"><strong>Expira:</strong> ${new Date(d.expires_at).toLocaleDateString()}</div>
`;
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 => `
<tr>
<td>${t.id}</td>
<td><strong>${escapeHtml(t.name)}</strong></td>
<td><code>${escapeHtml(t.subdomain)}</code></td>
<td>${tag(t.plan || "basic", t.plan === "demo" ? "info" : "default")}</td>
<td>${t.schema_version || "v0.0"}</td>
<td>${t.is_active ? tag("Activo", "success") : tag("Inactivo", "danger")}</td>
<td>${formatDate(t.created_at)}</td>
<td>
<button class="btn-icon" onclick="resetTenant(${t.id})" title="Resetear datos"><i class="fas fa-undo"></i></button>
<button class="btn-icon" onclick="toggleTenant(${t.id}, ${!t.is_active})" title="${t.is_active ? "Desactivar" : "Activar"}"><i class="fas fa-${t.is_active ? "pause" : "play"}"></i></button>
<button class="btn-icon" onclick="confirmDelete(${t.id}, '${escapeHtml(t.name)}')" title="Eliminar"><i class="fas fa-trash" style="color:var(--danger)"></i></button>
</td>
</tr>
`).join("") || `<tr><td colspan="8" class="text-muted text-center">No hay tenants</td></tr>`;
}
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" ? `
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${pg.version}</span></div>
<div class="health-item"><span class="health-label">Master DB</span><span class="health-value">${pg.master_size_mb} MB</span></div>
` : renderError(pg.error);
// Redis
const rd = h.redis;
document.getElementById("health-redis").innerHTML = rd.status === "ok" ? `
<div class="health-item"><span class="health-label">Estado</span><span class="health-value" style="color:var(--success)">Online</span></div>
<div class="health-item"><span class="health-label">Versión</span><span class="health-value">${rd.version}</span></div>
<div class="health-item"><span class="health-label">Memoria</span><span class="health-value">${rd.used_memory_human}</span></div>
<div class="health-item"><span class="health-label">Clientes</span><span class="health-value">${rd.connected_clients}</span></div>
` : renderError(rd.error);
// Disk
const dk = h.disk;
document.getElementById("health-disk").innerHTML = dk.status === "ok" ? `
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${dk.total_gb} GB</span></div>
<div class="health-item"><span class="health-label">Usado</span><span class="health-value">${dk.used_gb} GB (${dk.percent_used}%)</span></div>
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${dk.percent_used}%; background:${getBarColor(dk.percent_used)}"></div></div>
<div class="health-item" style="margin-top:12px"><span class="health-label">Libre</span><span class="health-value">${dk.free_gb} GB</span></div>
` : renderError(dk.error);
// Memory
const mem = h.memory;
document.getElementById("health-memory").innerHTML = mem.status === "ok" ? `
<div class="health-item"><span class="health-label">Total</span><span class="health-value">${mem.total_gb} GB</span></div>
<div class="health-item"><span class="health-label">Usada</span><span class="health-value">${mem.used_gb} GB (${mem.percent_used}%)</span></div>
<div class="health-bar-bg"><div class="health-bar-fill" style="width:${mem.percent_used}%; background:${getBarColor(mem.percent_used)}"></div></div>
<div class="health-item" style="margin-top:12px"><span class="health-label">Disponible</span><span class="health-value">${mem.available_gb} GB</span></div>
` : renderError(mem.error);
// Services
const svcs = h.services || {};
document.getElementById("health-services").innerHTML = Object.entries(svcs).map(([name, s]) => `
<div class="health-item">
<span class="health-label"><i class="fas fa-${s.active ? "check-circle" : "times-circle"}" style="color:${s.active ? "var(--success)" : "var(--danger)"}; margin-right:6px"></i>${name}</span>
<span class="health-value" style="color:${s.active ? "var(--success)" : "var(--danger)"}">${s.state}</span>
</div>
`).join("");
// HTTP
const httpChecks = ["pos", "dashboard", "quart"];
document.getElementById("health-http").innerHTML = `
<div class="grid-3">
${httpChecks.map(key => {
const svc = h[key];
const ok = svc && svc.status === "ok";
return `
<div class="health-item">
<span class="health-label">${key.toUpperCase()}</span>
<span class="health-value" style="color:${ok ? "var(--success)" : "var(--danger)"}">
${ok ? `HTTP ${svc.http_status}` : (svc.error || "Offline")}
</span>
</div>
`;
}).join("")}
</div>
`;
}
function renderError(msg) {
return `<div class="text-muted" style="padding:20px; text-align:center; color:var(--danger)"><i class="fas fa-exclamation-triangle"></i> ${escapeHtml(msg)}</div>`;
}
// ─── 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 `
<tr>
<td>${escapeHtml(t.name)}</td>
<td><code>${t.db_name}</code></td>
<td>${t.version}</td>
<td>${needsUpdate ? tag("Pendiente", "warning") : tag("OK", "success")}</td>
</tr>
`;
}).join("") || `<tr><td colspan="4" class="text-muted text-center">No hay tenants</td></tr>`;
}
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 <strong>${escapeHtml(name)}</strong>? 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 = `<i class="fas fa-${type === "success" ? "check-circle" : type === "error" ? "exclamation-circle" : "info-circle"}"></i> ${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 `<span class="tag tag-${type}">${escapeHtml(text)}</span>`;
}
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);