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:
663
manager/static/css/manager.css
Normal file
663
manager/static/css/manager.css
Normal 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; }
|
||||
}
|
||||
479
manager/static/js/manager.js
Normal file
479
manager/static/js/manager.js
Normal 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);
|
||||
Reference in New Issue
Block a user