diff --git a/panel/_branding.php b/panel/_branding.php
new file mode 100644
index 0000000..0521840
--- /dev/null
+++ b/panel/_branding.php
@@ -0,0 +1,80 @@
+ before JS loads.
+ * Reads session cookie → looks up user's reseller → returns branding row.
+ */
+function novacpx_get_branding(): array {
+ static $cache = null;
+ if ($cache !== null) return $cache;
+ $cfg = @parse_ini_file('/etc/novacpx/config.ini', true);
+ if (!$cfg) return $cache = [];
+ try {
+ $dbPath = $cfg['database']['path'] ?? '/var/lib/novacpx/panel.db';
+ $pdo = new PDO(
+ "sqlite:{$dbPath}", null, null,
+ [PDO::ATTR_ERRMODE => PDO::ERRMODE_SILENT, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
+ );
+ $token = $_COOKIE['ncpx_session'] ?? '';
+ if (!$token || strlen($token) < 32) return $cache = [];
+
+ $stmt = $pdo->prepare("SELECT user_id FROM sessions WHERE id = ? AND expires_at > datetime('now') LIMIT 1");
+ $stmt->execute([substr($token, 0, 128)]);
+ $uid = (int)($stmt->fetchColumn() ?: 0);
+ if (!$uid) return $cache = [];
+
+ $stmt = $pdo->prepare("SELECT role, reseller_id FROM users WHERE id = ? LIMIT 1");
+ $stmt->execute([$uid]);
+ $u = $stmt->fetch();
+ if (!$u) return $cache = [];
+
+ $resellerId = ($u['role'] === 'reseller') ? $uid : (int)($u['reseller_id'] ?? 0);
+ if (!$resellerId) return $cache = [];
+
+ $stmt = $pdo->prepare("SELECT * FROM reseller_branding WHERE user_id = ? LIMIT 1");
+ $stmt->execute([$resellerId]);
+ $row = $stmt->fetch();
+ return $cache = ($row ?: []);
+ } catch (Throwable $e) {
+ return $cache = [];
+ }
+}
+
+function novacpx_branding_head(): void {
+ $b = novacpx_get_branding();
+ if (!$b) return;
+ $pc = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['primary_color'] ?? '') ? $b['primary_color'] : null;
+ $ac = preg_match('/^#[0-9a-fA-F]{3,6}$/', $b['accent_color'] ?? '') ? $b['accent_color'] : null;
+ $css = $b['custom_css'] ?? '';
+ echo ' tags
+ echo preg_replace('/<\s*\/\s*style/i', '', $css) . "\n";
+ echo '' . "\n";
+ if ($b['favicon_url'] ?? '') {
+ $fav = htmlspecialchars($b['favicon_url']);
+ echo "\n";
+ }
+}
+
+function novacpx_panel_name(string $default): string {
+ $b = novacpx_get_branding();
+ return htmlspecialchars($b['panel_name'] ?? $default);
+}
+
+function novacpx_logo_html(string $default_svg): string {
+ $b = novacpx_get_branding();
+ if (!empty($b['logo_url'])) {
+ $url = htmlspecialchars($b['logo_url']);
+ $name = htmlspecialchars($b['panel_name'] ?? 'Panel');
+ return "
";
+ }
+ return $default_svg;
+}
+
+function novacpx_powered_by(): bool {
+ $b = novacpx_get_branding();
+ return empty($b['hide_powered_by']);
+}
diff --git a/panel/assets/css/nova.css b/panel/assets/css/nova.css
new file mode 100644
index 0000000..3bceb03
--- /dev/null
+++ b/panel/assets/css/nova.css
@@ -0,0 +1,387 @@
+/* NovaCPX Design System */
+:root {
+ --bg: #0d0f17;
+ --bg2: #131520;
+ --bg3: #1a1d2e;
+ --border: #252840;
+ --text: #e2e4f0;
+ --text-muted: #7c7f9a;
+ --primary: #6366f1;
+ --primary-h: #4f52e8;
+ --sky: #0ea5e9;
+ --green: #10b981;
+ --yellow: #f59e0b;
+ --red: #ef4444;
+ --radius: 10px;
+ --shadow: 0 4px 24px rgba(0,0,0,.4);
+ --font: 'Inter', system-ui, -apple-system, sans-serif;
+}
+
+*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+
+html { font-size: 15px; }
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: var(--font);
+ line-height: 1.6;
+ min-height: 100vh;
+}
+
+/* ── Login Page ─────────────────────────────────────────────────────────────── */
+.login-page {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ background: radial-gradient(ellipse at 30% 20%, rgba(99,102,241,.15) 0%, transparent 60%),
+ radial-gradient(ellipse at 80% 80%, rgba(14,165,233,.1) 0%, transparent 60%),
+ var(--bg);
+}
+
+.login-wrap { width: 100%; max-width: 420px; padding: 1.5rem; }
+
+.login-brand {
+ display: flex; align-items: center; gap: .75rem;
+ justify-content: center; margin-bottom: 2rem;
+}
+.logo-icon { width: 42px; height: 42px; }
+.logo-text { font-size: 1.8rem; font-weight: 300; letter-spacing: -.5px; }
+.logo-text strong { font-weight: 700; background: linear-gradient(135deg, #6366f1, #0ea5e9); -webkit-background-clip: text; -webkit-text-fill-color: transparent; }
+
+.login-card {
+ background: var(--bg2);
+ border: 1px solid var(--border);
+ border-radius: 16px;
+ padding: 2rem;
+ box-shadow: var(--shadow);
+}
+.login-card h1 { font-size: 1.4rem; margin-bottom: .25rem; }
+.login-sub { color: var(--text-muted); font-size: .875rem; margin-bottom: 1.5rem; }
+
+.login-footer {
+ text-align: center; margin-top: 1.25rem;
+ font-size: .8rem; color: var(--text-muted);
+}
+.login-footer a { color: var(--primary); text-decoration: none; }
+
+/* ── Forms ──────────────────────────────────────────────────────────────────── */
+.form-group { margin-bottom: 1rem; }
+.form-group label { display: block; font-size: .85rem; font-weight: 500; margin-bottom: .4rem; color: var(--text-muted); }
+
+input[type="text"], input[type="password"], input[type="email"],
+input[type="number"], input[type="url"], input[type="search"],
+input[type="date"], input[type="time"], input[type="tel"],
+input:not([type]), .form-control, select, textarea {
+ width: 100%; padding: .65rem .9rem;
+ background: var(--bg3); border: 1px solid var(--border);
+ border-radius: var(--radius); color: var(--text);
+ font-family: var(--font); font-size: .9rem;
+ transition: border-color .15s;
+ outline: none;
+ box-sizing: border-box;
+}
+input:focus, select:focus, textarea:focus, .form-control:focus { border-color: var(--primary); }
+input[type="date"]::-webkit-calendar-picker-indicator { filter: invert(1) opacity(.5); }
+
+.input-with-icon { position: relative; }
+.input-with-icon input { padding-right: 2.5rem; }
+.eye-toggle {
+ position: absolute; right: .75rem; top: 50%; transform: translateY(-50%);
+ background: none; border: none; cursor: pointer; color: var(--text-muted);
+ padding: 0; display: flex; align-items: center;
+}
+.eye-toggle svg { width: 18px; height: 18px; }
+
+/* ── Buttons ─────────────────────────────────────────────────────────────────── */
+.btn {
+ display: inline-flex; align-items: center; gap: .4rem;
+ padding: .6rem 1.25rem; border: none; border-radius: var(--radius);
+ font-family: var(--font); font-size: .9rem; font-weight: 500;
+ cursor: pointer; transition: all .15s; text-decoration: none;
+}
+.btn-primary { background: var(--primary); color: #fff; }
+.btn-primary:hover { background: var(--primary-h); }
+.btn-sky { background: var(--sky); color: #fff; }
+.btn-green { background: var(--green); color: #fff; }
+.btn-red { background: var(--red); color: #fff; }
+.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
+.btn-ghost:hover { border-color: var(--primary); color: var(--primary); }
+.btn-full { width: 100%; justify-content: center; padding: .75rem; }
+.btn:disabled { opacity: .6; cursor: not-allowed; }
+.btn-sm { padding: .35rem .8rem; font-size: .82rem; }
+.btn-icon { padding: .5rem; border-radius: 8px; }
+
+/* ── Alerts ──────────────────────────────────────────────────────────────────── */
+.alert { padding: .75rem 1rem; border-radius: var(--radius); font-size: .875rem; margin-bottom: 1rem; }
+.alert-error { background: rgba(239,68,68,.12); border: 1px solid rgba(239,68,68,.3); color: #fca5a5; }
+.alert-success { background: rgba(16,185,129,.12); border: 1px solid rgba(16,185,129,.3); color: #6ee7b7; }
+.alert-warning { background: rgba(245,158,11,.12); border: 1px solid rgba(245,158,11,.3); color: #fcd34d; }
+.alert-info { background: rgba(99,102,241,.12); border: 1px solid rgba(99,102,241,.3); color: #a5b4fc; }
+
+/* ── Panel Layout ────────────────────────────────────────────────────────────── */
+.panel-layout { display: flex; min-height: 100vh; }
+
+.sidebar {
+ width: 240px; min-width: 240px; background: var(--bg2);
+ border-right: 1px solid var(--border);
+ display: flex; flex-direction: column;
+ position: fixed; height: 100vh; overflow-y: auto; z-index: 100;
+}
+.sidebar-brand {
+ display: flex; align-items: center; gap: .6rem;
+ padding: 1.25rem 1.25rem 1rem;
+ border-bottom: 1px solid var(--border);
+}
+.sidebar-brand .logo-text { font-size: 1.1rem; }
+.sidebar-brand .logo-icon { width: 28px; height: 28px; }
+
+.sidebar-section { padding: .75rem 0; }
+.sidebar-section-label {
+ font-size: .7rem; font-weight: 700; letter-spacing: .08em;
+ text-transform: uppercase; color: var(--text-muted);
+ padding: .25rem 1.25rem .5rem;
+}
+.sidebar-link {
+ display: flex; align-items: center; gap: .75rem;
+ padding: .55rem 1.25rem; text-decoration: none;
+ color: var(--text-muted); font-size: .88rem;
+ border-left: 3px solid transparent;
+ transition: all .12s;
+}
+.sidebar-link:hover { color: var(--text); background: var(--bg3); }
+.sidebar-link.active { color: var(--primary); background: rgba(99,102,241,.1); border-left-color: var(--primary); }
+.sidebar-link svg { width: 18px; height: 18px; flex-shrink: 0; }
+
+.sidebar-user {
+ margin-top: auto; padding: 1rem 1.25rem;
+ border-top: 1px solid var(--border);
+}
+.sidebar-user-info { display: flex; align-items: center; gap: .75rem; }
+.avatar {
+ width: 36px; height: 36px; border-radius: 50%;
+ background: linear-gradient(135deg, var(--primary), var(--sky));
+ display: flex; align-items: center; justify-content: center;
+ font-weight: 700; font-size: .9rem; flex-shrink: 0;
+}
+.user-name { font-size: .88rem; font-weight: 600; }
+.user-role { font-size: .75rem; color: var(--text-muted); text-transform: capitalize; }
+
+.main-content { margin-left: 240px; flex: 1; display: flex; flex-direction: column; }
+
+.topbar {
+ background: var(--bg2); border-bottom: 1px solid var(--border);
+ padding: .75rem 1.5rem; display: flex; align-items: center;
+ gap: 1rem; position: sticky; top: 0; z-index: 50;
+}
+.topbar-title { font-size: 1rem; font-weight: 600; flex: 1; }
+.topbar-actions { display: flex; align-items: center; gap: .5rem; }
+
+.page-content { padding: 1.5rem; flex: 1; }
+
+/* ── Cards ───────────────────────────────────────────────────────────────────── */
+.card {
+ background: var(--bg2); border: 1px solid var(--border);
+ border-radius: var(--radius); overflow: hidden;
+}
+.card-header {
+ padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; gap: .75rem;
+}
+.card-title { font-size: .95rem; font-weight: 600; flex: 1; }
+.card-body { padding: 1.25rem; }
+
+/* ── Stats Cards ─────────────────────────────────────────────────────────────── */
+.stats-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
+.stat-card {
+ background: var(--bg2); border: 1px solid var(--border);
+ border-radius: var(--radius); padding: 1.25rem;
+}
+.stat-label { font-size: .78rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); margin-bottom: .5rem; }
+.stat-value { font-size: 1.8rem; font-weight: 700; line-height: 1; }
+.stat-sub { font-size: .78rem; color: var(--text-muted); margin-top: .3rem; }
+.stat-green { color: var(--green); }
+.stat-red { color: var(--red); }
+.stat-yellow{ color: var(--yellow); }
+.stat-blue { color: var(--sky); }
+
+/* ── Progress bar ────────────────────────────────────────────────────────────── */
+.progress { background: var(--bg3); border-radius: 999px; height: 6px; overflow: hidden; }
+.progress-bar { height: 100%; border-radius: 999px; transition: width .3s; }
+.progress-bar.green { background: var(--green); }
+.progress-bar.yellow { background: var(--yellow); }
+.progress-bar.red { background: var(--red); }
+
+/* ── Tables ──────────────────────────────────────────────────────────────────── */
+.table-wrap { overflow-x: auto; }
+table { width: 100%; border-collapse: collapse; font-size: .88rem; }
+th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em;
+ color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); }
+td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
+tr:last-child td { border-bottom: none; }
+tr:hover td { background: var(--bg3); }
+
+/* ── Badges ──────────────────────────────────────────────────────────────────── */
+.badge { display: inline-block; padding: .2rem .55rem; border-radius: 999px; font-size: .72rem; font-weight: 600; }
+.badge-green { background: rgba(16,185,129,.15); color: #6ee7b7; }
+.badge-red { background: rgba(239,68,68,.15); color: #fca5a5; }
+.badge-yellow { background: rgba(245,158,11,.15); color: #fcd34d; }
+.badge-blue { background: rgba(99,102,241,.15); color: #a5b4fc; }
+.badge-sky { background: rgba(14,165,233,.15); color: #7dd3fc; }
+.badge-gray { background: rgba(148,163,184,.15); color: #94a3b8; }
+
+/* ── Modal ───────────────────────────────────────────────────────────────────── */
+.modal-overlay {
+ display: none; position: fixed; inset: 0;
+ background: rgba(0,0,0,.7); z-index: 1000;
+ align-items: center; justify-content: center;
+}
+.modal-overlay.open { display: flex; }
+.modal {
+ background: var(--bg2); border: 1px solid var(--border);
+ border-radius: 14px; width: 100%; max-width: 500px;
+ max-height: 90vh; overflow-y: auto;
+ box-shadow: 0 20px 60px rgba(0,0,0,.6);
+}
+.modal-header {
+ padding: 1.25rem 1.5rem; border-bottom: 1px solid var(--border);
+ display: flex; align-items: center;
+}
+.modal-title { font-size: 1rem; font-weight: 600; flex: 1; }
+.modal-close { background: none; border: none; cursor: pointer; color: var(--text-muted); font-size: 1.25rem; }
+.modal-body { padding: 1.5rem; }
+.modal-footer { padding: 1rem 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: .5rem; }
+
+/* ── Tabs ────────────────────────────────────────────────────────────────────── */
+.tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); margin-bottom: 1.5rem; }
+.tab-btn {
+ padding: .65rem 1.25rem; border: none; background: none;
+ color: var(--text-muted); font-size: .88rem; cursor: pointer;
+ border-bottom: 2px solid transparent; margin-bottom: -1px;
+ transition: color .12s;
+}
+.tab-btn:hover { color: var(--text); }
+.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
+.tab-pane { display: none; }
+.tab-pane.active { display: block; }
+
+/* ── Grid helpers ────────────────────────────────────────────────────────────── */
+.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
+.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1rem; }
+.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1rem; }
+@media (max-width: 768px) {
+ .sidebar { transform: translateX(-100%); transition: transform .2s; }
+ .sidebar.open { transform: translateX(0); }
+ .main-content { margin-left: 0; }
+ .grid-2,.grid-3,.grid-4 { grid-template-columns: 1fr; }
+}
+
+/* ── Services status ─────────────────────────────────────────────────────────── */
+.service-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
+.service-dot.active { background: var(--green); box-shadow: 0 0 6px var(--green); }
+.service-dot.inactive { background: var(--red); }
+.service-dot.unknown { background: var(--text-muted); }
+
+/* ── Code / Terminal ─────────────────────────────────────────────────────────── */
+code { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: .85em; background: var(--bg3); padding: .15em .4em; border-radius: 4px; }
+.terminal {
+ background: #050508; border: 1px solid var(--border); border-radius: var(--radius);
+ padding: 1rem; font-family: monospace; font-size: .82rem; line-height: 1.7;
+ color: #a6e22e; max-height: 300px; overflow-y: auto;
+}
+
+/* ── Scrollbar ───────────────────────────────────────────────────────────────── */
+::-webkit-scrollbar { width: 5px; }
+::-webkit-scrollbar-track { background: var(--bg); }
+::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
+
+/* ── Utility ─────────────────────────────────────────────────────────────────── */
+.flex { display: flex; } .items-center { align-items: center; } .justify-between { justify-content: space-between; }
+.gap-1 { gap: .5rem; } .gap-2 { gap: 1rem; } .gap-3 { gap: 1.5rem; }
+.mb-1 { margin-bottom: .5rem; } .mb-2 { margin-bottom: 1rem; } .mb-3 { margin-bottom: 1.5rem; }
+.mt-1 { margin-top: .5rem; } .mt-2 { margin-top: 1rem; }
+.text-muted { color: var(--text-muted); } .text-sm { font-size: .82rem; }
+.text-right { text-align: right; } .font-bold { font-weight: 700; }
+.w-full { width: 100%; } .hidden { display: none; }
+
+/* ── #26 Mobile Responsive Additions ────────────────────────────────────────── */
+#sidebar-toggle { display: none; }
+
+.sidebar-overlay {
+ display: none; position: fixed; inset: 0;
+ background: rgba(0,0,0,.5); z-index: 99;
+}
+.sidebar-overlay.open { display: block; }
+
+/* page-header layout */
+.page-header {
+ display: flex; align-items: center; justify-content: space-between;
+ margin-bottom: 1.5rem; flex-wrap: wrap; gap: .75rem;
+}
+.page-title { font-size: 1.2rem; font-weight: 700; }
+.page-actions { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; }
+
+/* panel utility */
+.panel {
+ background: var(--bg2); border: 1px solid var(--border);
+ border-radius: var(--radius); margin-bottom: 1.5rem;
+}
+.panel-header {
+ padding: 1rem 1.25rem; border-bottom: 1px solid var(--border);
+ display: flex; align-items: center; justify-content: space-between;
+ flex-wrap: wrap; gap: .5rem;
+}
+.panel-title { font-size: .95rem; font-weight: 600; }
+.panel-body { padding: 1.25rem; }
+
+/* table alias */
+.table { width: 100%; border-collapse: collapse; font-size: .88rem; }
+.table th { text-align: left; font-size: .75rem; text-transform: uppercase; letter-spacing: .05em; color: var(--text-muted); padding: .65rem 1rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
+.table td { padding: .75rem 1rem; border-bottom: 1px solid var(--border); }
+.table tr:last-child td { border-bottom: none; }
+.table tr:hover td { background: var(--bg3); }
+
+/* btn variants */
+.btn-success { background: var(--green); color: #fff; }
+.btn-success:hover { background: #0da271; }
+.btn-warning { background: var(--yellow); color: #000; }
+.btn-warning:hover { background: #d97706; }
+.btn-danger { background: var(--red); color: #fff; }
+.btn-danger:hover { background: #dc2626; }
+.btn-secondary { background: var(--bg3); border: 1px solid var(--border); color: var(--text); }
+.btn-secondary:hover { border-color: var(--primary); color: var(--primary); }
+.btn-xs { padding: .2rem .55rem; font-size: .75rem; border-radius: 6px; }
+
+/* badge alias */
+.badge-muted { background: rgba(148,163,184,.15); color: #94a3b8; }
+
+@media (max-width: 768px) {
+ #sidebar-toggle { display: flex; }
+
+ .sidebar {
+ transform: translateX(-100%);
+ transition: transform .25s ease;
+ z-index: 200;
+ }
+ .sidebar.open { transform: translateX(0); }
+
+ .main-content { margin-left: 0; }
+
+ .page-header { flex-direction: column; align-items: flex-start; }
+ .page-actions { width: 100%; }
+
+ .stats-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
+
+ .modal { max-width: calc(100vw - 2rem); margin: 1rem; }
+
+ .panel-header { flex-direction: column; align-items: flex-start; }
+
+ .topbar { padding: .65rem 1rem; }
+
+ /* hide non-essential table columns on mobile */
+ .table th:nth-child(n+4),
+ .table td:nth-child(n+4) { display: none; }
+
+ .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
+}
diff --git a/panel/assets/img/nova-favicon.svg b/panel/assets/img/nova-favicon.svg
new file mode 100644
index 0000000..4476ee0
--- /dev/null
+++ b/panel/assets/img/nova-favicon.svg
@@ -0,0 +1,9 @@
+
diff --git a/panel/assets/img/nova-icons.svg b/panel/assets/img/nova-icons.svg
new file mode 100644
index 0000000..b00b95e
--- /dev/null
+++ b/panel/assets/img/nova-icons.svg
@@ -0,0 +1,329 @@
+
+
diff --git a/panel/assets/img/nova-logo.svg b/panel/assets/img/nova-logo.svg
new file mode 100644
index 0000000..bbe4464
--- /dev/null
+++ b/panel/assets/img/nova-logo.svg
@@ -0,0 +1,26 @@
+
diff --git a/panel/assets/img/nova-mark.svg b/panel/assets/img/nova-mark.svg
new file mode 100644
index 0000000..1ee7a38
--- /dev/null
+++ b/panel/assets/img/nova-mark.svg
@@ -0,0 +1,15 @@
+
diff --git a/panel/assets/js/admin.js b/panel/assets/js/admin.js
index 2249b14..3cd46b4 100644
--- a/panel/assets/js/admin.js
+++ b/panel/assets/js/admin.js
@@ -1006,7 +1006,7 @@
const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
Nova.loadingDone();
if (res?.success) {
- window.location.href = res.data?.portal_url || location.origin + "/";
+ window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
} else {
Nova.toast(res?.message || 'Impersonation failed', 'error');
}
diff --git a/panel/assets/js/features.js b/panel/assets/js/features.js
new file mode 100644
index 0000000..fe2da07
--- /dev/null
+++ b/panel/assets/js/features.js
@@ -0,0 +1,133 @@
+/**
+ * NovaCPX Feature Manager
+ * Loaded by admin panel's features page
+ */
+window.FeaturesManager = {
+ async load() {
+ const res = await Nova.api('features', 'list');
+ if (!res?.success) return '
Failed to load features.
';
+ const grouped = res.data;
+
+ const categoryIcons = {
+ 'Web Server':'🌐', 'PHP':'⚙️', 'Database':'🗄️', 'Email':'📧',
+ 'DNS':'🔍', 'FTP':'📁', 'SSL':'🔒', 'Security':'🛡️', 'Containers':'🐳',
+ 'IP Management':'🌍', 'Monitoring':'📊', 'Backup':'💾', 'CDN & Performance':'⚡',
+ 'Development':'👨💻', 'One-Click Apps':'🚀', 'Applications':'📦',
+ 'Billing':'💳', 'Reseller':'🏪', 'Notifications':'🔔', 'Compliance':'✅',
+ };
+
+ return `
+
+
Feature Manager
+
+
+
+
+
+
+
+ ${Object.entries(grouped).map(([cat, feats]) => `
+
+
+ ${categoryIcons[cat] || '🔧'}
+
${cat}
+ ${feats.length}
+
+
+ ${feats.map(f => `
+
+
+
+
+
${f.name}
+
${f.description}
+ ${f.min_ram_mb > 0 ? `
Requires ${f.min_ram_mb}MB RAM
` : ''}
+
+
+ ${f.installed
+ ? ``
+ : ``
+ }
+ ${f.installed ? `Installed` : `Not installed`}
+
+
+
+
`).join('')}
+
+
`).join('')}
+
+
+`;
+ },
+
+ async toggle(id, enable) {
+ const res = await Nova.api('features', 'toggle', { method: 'POST', body: { id, enable } });
+ if (res?.data?.action === 'install_required') {
+ Nova.confirm(`"${res.data.feature.name}" must be installed first. Install now?`, () => this.install(id));
+ return;
+ }
+ Nova.toast(res?.message || 'Updated', res?.success ? 'success' : 'error');
+ },
+
+ async install(id) {
+ const res = await Nova.api('features', 'install', { method: 'POST', body: { id } });
+ if (!res?.success) { Nova.toast(res?.message || 'Install failed', 'error'); return; }
+
+ const logDiv = `Starting installation…
`;
+ const ov = Nova.modal('Installing Feature', logDiv);
+ const poll = setInterval(async () => {
+ const logRes = await Nova.api('features', 'install-log', { params: { id } });
+ const logEl = document.getElementById(`install-log-${id}`);
+ if (logEl) logEl.innerHTML = (logRes?.data?.log || '').split('\n').map(l => `${l}
`).join('');
+ if (!logRes?.data?.running) {
+ clearInterval(poll);
+ if (logRes?.data?.installed) {
+ Nova.toast('Feature installed successfully', 'success');
+ ov.remove();
+ document.getElementById('page-content').innerHTML = await this.load();
+ this.bindSearch();
+ }
+ }
+ }, 2000);
+ },
+
+ bindSearch() {
+ const search = document.getElementById('feat-search');
+ const catFilter = document.getElementById('feat-cat-filter');
+ if (!search || !catFilter) return;
+
+ const filter = () => {
+ const q = search.value.toLowerCase();
+ const cat = catFilter.value;
+ document.querySelectorAll('.feat-card').forEach(c => {
+ const match = (!q || c.dataset.name.includes(q)) && (!cat || c.dataset.cat === cat);
+ c.closest('div').style.display = match ? '' : 'none';
+ });
+ document.querySelectorAll('.feat-category').forEach(sec => {
+ const visible = [...sec.querySelectorAll('.feat-card')].some(c => c.closest('div').style.display !== 'none');
+ sec.style.display = visible ? '' : 'none';
+ });
+ };
+ search.addEventListener('input', filter);
+ catFilter.addEventListener('change', filter);
+ },
+};
diff --git a/panel/assets/js/nova.js b/panel/assets/js/nova.js
new file mode 100644
index 0000000..c3551bf
--- /dev/null
+++ b/panel/assets/js/nova.js
@@ -0,0 +1,234 @@
+/**
+ * NovaCPX — Shared JS utilities
+ */
+
+window.Nova = (() => {
+ // ── Activity bar (thin top-of-page progress stripe for every API call) ────
+ let _barEl = null, _barPct = 0, _barTimer = null, _barActive = 0;
+ function _barShow() {
+ _barActive++;
+ if (!_barEl) {
+ _barEl = document.createElement('div');
+ _barEl.style.cssText = [
+ 'position:fixed;top:0;left:0;z-index:999999',
+ 'height:3px;width:0%;background:var(--primary,#6366f1)',
+ 'transition:width .2s ease,opacity .3s ease',
+ 'box-shadow:0 0 8px var(--primary,#6366f1)',
+ 'pointer-events:none',
+ ].join(';');
+ document.body.appendChild(_barEl);
+ }
+ _barEl.style.opacity = '1';
+ _barPct = 10;
+ _barEl.style.width = _barPct + '%';
+ clearInterval(_barTimer);
+ _barTimer = setInterval(() => {
+ if (_barEl && _barPct < 85) { _barPct += (_barPct < 50 ? 8 : _barPct < 70 ? 4 : 1); _barEl.style.width = _barPct + '%'; }
+ }, 200);
+ }
+ function _barDone() {
+ _barActive = Math.max(0, _barActive - 1);
+ if (_barActive > 0) return;
+ clearInterval(_barTimer);
+ if (_barEl) {
+ _barEl.style.width = '100%';
+ setTimeout(() => { if (_barEl) { _barEl.style.opacity = '0'; setTimeout(() => { _barEl?.remove(); _barEl = null; }, 300); } }, 200);
+ }
+ }
+
+ // ── API ───────────────────────────────────────────────────────────────────
+ async function api(endpoint, action, opts = {}) {
+ const { method = 'GET', body, params } = opts;
+ let url = `/api/${endpoint}/${action}`;
+ if (params) url += '?' + new URLSearchParams(params);
+ _barShow();
+ let res;
+ try {
+ res = await fetch(url, {
+ method,
+ credentials: 'include',
+ headers: body ? { 'Content-Type': 'application/json' } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ } catch (e) {
+ _barDone();
+ console.error(`Nova.api network error [${endpoint}/${action}]:`, e);
+ return { success: false, message: 'Network error — check your connection' };
+ }
+ _barDone();
+ if (res.status === 401) { return { success: false, message: 'Session expired — please log in again' }; }
+ if (res.status === 429) {
+ const reset = res.headers.get('X-RateLimit-Reset');
+ const wait = reset ? Math.max(0, Math.ceil(Number(reset) - Date.now() / 1000)) : 60;
+ return { success: false, message: `Rate limited — try again in ${wait}s` };
+ }
+ const text = await res.text();
+ try {
+ return JSON.parse(text);
+ } catch {
+ console.error(`Nova.api non-JSON from [${endpoint}/${action}] (HTTP ${res.status}):`, text.slice(0, 500));
+ return { success: false, message: `Server error (HTTP ${res.status}) — see browser console` };
+ }
+ }
+
+ // ── Toast ─────────────────────────────────────────────────────────────────
+ let toastEl = null;
+ function toast(msg, type = 'info', duration = 3500) {
+ if (!toastEl) {
+ toastEl = document.createElement('div');
+ toastEl.style.cssText = 'position:fixed;bottom:1.5rem;right:1.5rem;z-index:9999;display:flex;flex-direction:column;gap:.5rem;max-width:380px';
+ document.body.appendChild(toastEl);
+ }
+ const el = document.createElement('div');
+ el.className = `alert alert-${type}`;
+ el.style.cssText = 'animation:fadeIn .2s;cursor:pointer;box-shadow:var(--shadow)';
+ el.textContent = msg;
+ el.addEventListener('click', () => el.remove());
+ toastEl.appendChild(el);
+ setTimeout(() => el.remove(), duration);
+ }
+
+ // ── Modal ─────────────────────────────────────────────────────────────────
+ function modal(title, bodyHtml, footerHtml = '') {
+ const ov = document.createElement('div');
+ ov.className = 'modal-overlay open';
+ ov.innerHTML = `
+
+
${bodyHtml}
+ ${footerHtml ? `` : ''}
+
`;
+ ov.addEventListener('click', e => { if (e.target === ov) ov.remove(); });
+ document.body.appendChild(ov);
+ return ov;
+ }
+
+ // ── Confirm dialog ────────────────────────────────────────────────────────
+ function confirm(msg, onYes, danger = false) {
+ const ov = modal('Confirm', `${msg}
`,
+ `
+ `
+ );
+ ov.querySelector('#confirm-yes').onclick = () => { ov.remove(); onYes(); };
+ }
+
+ // ── Sidebar navigation ────────────────────────────────────────────────────
+ function initNav(pages) {
+ document.querySelectorAll('[data-page]').forEach(link => {
+ link.addEventListener('click', e => {
+ e.preventDefault();
+ const page = link.dataset.page;
+ document.querySelectorAll('[data-page]').forEach(l => l.classList.remove('active'));
+ link.classList.add('active');
+ const titleEl = document.getElementById('page-title');
+ if (titleEl) titleEl.textContent = link.textContent.trim();
+ loadPage(page, pages);
+ });
+ });
+ }
+
+ function loadPage(page, pages) {
+ const content = document.getElementById('page-content');
+ if (!content) return;
+ const fn = pages[page];
+ if (fn) {
+ content.innerHTML = 'Loading…
';
+ Promise.resolve(fn()).then(html => { if (html) content.innerHTML = html; });
+ } else {
+ content.innerHTML = `Page "${page}" coming soon.
`;
+ }
+ }
+
+ // ── Progress bar helper ───────────────────────────────────────────────────
+ function progressBar(pct) {
+ const color = pct >= 90 ? 'red' : pct >= 70 ? 'yellow' : 'green';
+ return ``;
+ }
+
+ // ── Format helpers ────────────────────────────────────────────────────────
+ function bytes(n) {
+ if (n >= 1073741824) return (n / 1073741824).toFixed(1) + ' GB';
+ if (n >= 1048576) return (n / 1048576).toFixed(1) + ' MB';
+ if (n >= 1024) return (n / 1024).toFixed(1) + ' KB';
+ return n + ' B';
+ }
+ function relTime(dateStr) {
+ const diff = (Date.now() - new Date(dateStr)) / 1000;
+ if (diff < 60) return 'just now';
+ if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
+ if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
+ return Math.floor(diff / 86400) + 'd ago';
+ }
+ function badge(text, type = 'blue') {
+ return `${text}`;
+ }
+ function serviceDot(status) {
+ const cls = status === 'active' ? 'active' : status === 'inactive' ? 'inactive' : 'unknown';
+ return ``;
+ }
+
+ function escHtml(str) {
+ return String(str ?? '').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,''');
+ }
+
+ // ── Loading overlay ───────────────────────────────────────────────────────
+ let _loadingEl = null;
+ let _loadingCount = 0;
+ function loading(msg = 'Working…') {
+ _loadingCount++;
+ if (!_loadingEl) {
+ _loadingEl = document.createElement('div');
+ _loadingEl.id = 'nova-loading-overlay';
+ _loadingEl.style.cssText = [
+ 'position:fixed;inset:0;z-index:99999',
+ 'background:rgba(0,0,0,.55)',
+ 'display:flex;flex-direction:column;align-items:center;justify-content:center',
+ 'gap:1rem;animation:fadeIn .15s',
+ ].join(';');
+ _loadingEl.innerHTML = `
+
+ ${escHtml(msg)}
`;
+ document.body.appendChild(_loadingEl);
+ } else {
+ document.getElementById('nova-loading-msg').textContent = msg;
+ }
+ }
+ function loadingDone() {
+ _loadingCount = Math.max(0, _loadingCount - 1);
+ if (_loadingCount === 0 && _loadingEl) {
+ _loadingEl.remove();
+ _loadingEl = null;
+ }
+ }
+
+ // Inject global CSS animations
+ const style = document.createElement('style');
+ style.textContent = [
+ '@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}',
+ '@keyframes ncpxSpin{to{transform:rotate(360deg)}}',
+ ].join('');
+ document.head.appendChild(style);
+
+ return { api, toast, modal, confirm, initNav, loadPage, progressBar, bytes, relTime, badge, serviceDot, escHtml, loading, loadingDone };
+})();
+
+// #26 Mobile sidebar toggle — shared across all panels
+document.addEventListener('DOMContentLoaded', () => {
+ const toggle = document.getElementById('sidebar-toggle');
+ const sidebar = document.getElementById('sidebar');
+ const overlay = document.getElementById('sidebar-overlay');
+ if (!toggle || !sidebar) return;
+
+ const open = () => { sidebar.classList.add('open'); overlay?.classList.add('open'); document.body.style.overflow = 'hidden'; };
+ const close = () => { sidebar.classList.remove('open'); overlay?.classList.remove('open'); document.body.style.overflow = ''; };
+
+ toggle.addEventListener('click', () => sidebar.classList.contains('open') ? close() : open());
+ overlay?.addEventListener('click', close);
+
+ // Close when a nav link is clicked on mobile
+ sidebar.querySelectorAll('.sidebar-link').forEach(link =>
+ link.addEventListener('click', () => { if (window.innerWidth <= 768) close(); })
+ );
+});
diff --git a/panel/assets/js/reseller.js b/panel/assets/js/reseller.js
new file mode 100644
index 0000000..8c8de93
--- /dev/null
+++ b/panel/assets/js/reseller.js
@@ -0,0 +1,700 @@
+/**
+ * NovaCPX Reseller Panel JS
+ */
+
+let _rUser = null;
+
+async function initReseller() {
+ const res = await Nova.api('auth', 'me');
+ if (!res?.success || !['admin','reseller'].includes(res.data?.role)) {
+ document.getElementById('auth-check').innerHTML = renderLogin();
+ document.getElementById('main-layout').style.display = 'none';
+ return false;
+ }
+ _rUser = res.data;
+ document.getElementById('user-name').textContent = _rUser.username || 'Reseller';
+ document.getElementById('auth-check').style.display = 'none';
+ document.getElementById('main-layout').style.display = '';
+ return true;
+}
+
+function renderLogin() {
+ return `
+
+
+

+
Reseller Portal · Port 8881
+
+
+
+
+
+
+
`;
+}
+
+async function doLogin() {
+ Nova.loading('Signing in…');
+ const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: document.getElementById('li-user')?.value, password: document.getElementById('li-pass')?.value }});
+ Nova.loadingDone();
+ if (res?.success) {
+ if (res.data?.portal_url && !res.data.portal_url.includes(':8881')) location.href = res.data.portal_url;
+ else location.reload();
+ } else {
+ const err = document.getElementById('li-err');
+ if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
+ }
+}
+window.doLogin = doLogin;
+
+/* ── Pages ─────────────────────────────────────────────────────────────── */
+
+async function rDashboard(el) {
+ el.innerHTML = `
+
+ `;
+
+ const res = await Nova.api('accounts', 'list', { params:{ limit:5 }});
+ const accts = res?.data || [];
+
+ document.getElementById('r-stats').innerHTML = [
+ { label: 'Total Accounts', val: res?.meta?.total || accts.length, icon: 'ni-accounts' },
+ { label: 'Active', val: accts.filter(a=>a.status==='active').length, icon: 'ni-stats' },
+ { label: 'Suspended', val: accts.filter(a=>a.status==='suspended').length, icon: 'ni-suspend' },
+ ].map(s => ``).join('');
+
+ document.getElementById('r-recent').innerHTML = accts.length
+ ? `| Username | Domain | Package | Status |
+ ${accts.map(a => `
+ | ${a.username} | ${a.domain} | ${a.package_name||'—'} |
+ ${Nova.badge(a.status, a.status==='active'?'green':'yellow')} |
+
`).join('')}
+
`
+ : 'No accounts yet.
';
+}
+
+async function rAccounts(el) {
+ el.innerHTML = `
+ `;
+ loadRAccounts();
+}
+
+async function loadRAccounts(search = '') {
+ const el = document.getElementById('r-accounts-list');
+ if (!el) return;
+ const res = await Nova.api('accounts', 'list', { params: search ? { search } : {}});
+ const acctRows = res?.data || [];
+ if (!res?.success || !acctRows.length) { el.innerHTML = 'No accounts found.
'; return; }
+ el.innerHTML = `| Username | Domain | Package | Disk | Status | Actions |
+ ${acctRows.map(a => `
+ | ${Nova.escHtml(a.username)} |
+ ${Nova.escHtml(a.domain)} |
+ ${a.package_name ? Nova.escHtml(a.package_name) : '—'} |
+ ${a.disk_usage_mb || 0} MB |
+ ${Nova.badge(a.status, a.status==='active'?'green':a.status==='suspended'?'yellow':'red')} |
+
+
+ ${a.status === 'active'
+ ? ``
+ : ``}
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadRAccounts = loadRAccounts;
+window.rSearchAccounts = (v) => loadRAccounts(v);
+
+window.rLoginAs = async (userId, username) => {
+ Nova.confirm(`Login as ${username}? You'll be taken to their panel. Use the banner to return.`, async () => {
+ Nova.loading(`Switching to ${username}…`);
+ const res = await Nova.api('auth', 'impersonate', { method: 'POST', body: { user_id: userId } });
+ Nova.loadingDone();
+ if (res?.success) {
+ window.location.href = res.data?.portal_url || 'https://' + location.hostname + ':8880/';
+ } else {
+ Nova.toast(res?.message || 'Impersonation failed', 'error');
+ }
+ });
+};
+
+window.rSuspend = async (id, user) => {
+ Nova.confirm(`Suspend account ${user}? Their website will show a suspension page.`, async () => {
+ const res = await Nova.api('accounts', 'suspend', { method:'POST', body:{ account_id: id }});
+ if (res?.success) { Nova.toast('Account suspended','success'); loadRAccounts(); }
+ else Nova.toast(res?.message,'error');
+ });
+};
+window.rUnsuspend = async (id, user) => {
+ const res = await Nova.api('accounts', 'unsuspend', { method:'POST', body:{ account_id: id }});
+ if (res?.success) { Nova.toast('Account unsuspended','success'); loadRAccounts(); }
+ else Nova.toast(res?.message,'error');
+};
+window.rTerminate = (id, user) => {
+ Nova.confirm(`TERMINATE ${user}? This permanently deletes all files, databases, DNS, and email. THIS CANNOT BE UNDONE.`, async () => {
+ const res = await Nova.api('accounts', 'terminate', { method:'POST', body:{ account_id: id }});
+ if (res?.success) { Nova.toast('Account terminated','success'); loadRAccounts(); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+window.rChangePass = (id, user) => {
+ Nova.modal(`Change Password — ${user}`, ``,
+ ``);
+};
+
+async function rCreateAccount(el) {
+ el.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ Nova.api('packages', 'list').then(res => {
+ const sel = document.getElementById('ca-pkg');
+ if (sel && res?.success) {
+ sel.innerHTML = res.data.map(p => ``).join('');
+ }
+ });
+}
+
+window.submitCreateAccount = async () => {
+ const btn = document.querySelector('#ca-result');
+ if (btn) btn.textContent = '';
+ Nova.loading('Creating hosting account…');
+ const res = await Nova.api('accounts', 'create', { method:'POST', body:{
+ username: document.getElementById('ca-user')?.value,
+ password: document.getElementById('ca-pass')?.value,
+ email: document.getElementById('ca-email')?.value,
+ domain: document.getElementById('ca-domain')?.value,
+ package_id: document.getElementById('ca-pkg')?.value,
+ }});
+ Nova.loadingDone();
+ if (res?.success) {
+ Nova.toast('Account created successfully!','success');
+ if (btn) btn.innerHTML = ``;
+ } else {
+ Nova.toast(res?.message || 'Failed to create account','error');
+ if (btn) btn.innerHTML = `${res?.message || 'Error'}
`;
+ }
+};
+
+async function rPackages(el) {
+ el.innerHTML = `
+ `;
+
+ const res = await Nova.api('packages', 'list');
+ const plist = document.getElementById('pkg-list');
+ if (!res?.success || !res.data.length) { plist.innerHTML = 'No packages yet.
'; return; }
+ plist.innerHTML = `| Name | Disk | BW | DBs | Emails | Domains | Price | Actions |
+ ${res.data.map(p => `
+ | ${p.name} |
+ ${p.disk_mb > 0 ? p.disk_mb+'MB' : '∞'} |
+ ${p.bandwidth_mb > 0 ? p.bandwidth_mb+'MB' : '∞'} |
+ ${p.databases || '∞'} |
+ ${p.email_accounts || '∞'} |
+ ${p.addon_domains || '∞'} |
+ ${p.price ? '$'+p.price : 'Free'} |
+
+
+
+ |
+
`).join('')}
+
`;
+}
+
+window.rAddPackage = () => showPackageModal();
+window.rEditPackage = async (id) => {
+ const res = await Nova.api('packages', 'get', { params:{ id }});
+ if (res?.success) showPackageModal(res.data);
+};
+function showPackageModal(pkg = null) {
+ const p = pkg || {};
+ Nova.modal(pkg ? 'Edit Package' : 'Add Package', `
+ `,
+ ``);
+}
+window.submitPackage = async (id) => {
+ const body = { name:document.getElementById('pk-name')?.value, disk_mb:parseInt(document.getElementById('pk-disk')?.value), bandwidth_mb:parseInt(document.getElementById('pk-bw')?.value), databases:parseInt(document.getElementById('pk-db')?.value), email_accounts:parseInt(document.getElementById('pk-email')?.value), addon_domains:parseInt(document.getElementById('pk-adom')?.value), subdomains:parseInt(document.getElementById('pk-sub')?.value), ftp_accounts:parseInt(document.getElementById('pk-ftp')?.value), price:parseFloat(document.getElementById('pk-price')?.value) };
+ const res = id ? await Nova.api('packages','update',{method:'POST',body:{...body,id}}) : await Nova.api('packages','create',{method:'POST',body});
+ if (res?.success) { Nova.toast(id ? 'Package updated' : 'Package created','success'); document.querySelector('.modal-overlay')?.remove(); rPackages(document.getElementById('page-content')); }
+ else Nova.toast(res?.message,'error');
+};
+window.rDeletePackage = (id, name) => {
+ Nova.confirm(`Delete package "${name}"? Cannot delete if accounts are using it.`, async () => {
+ const res = await Nova.api('packages','delete',{method:'POST',body:{id}});
+ if (res?.success) { Nova.toast('Deleted','success'); rPackages(document.getElementById('page-content')); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+
+async function rDNS(el) {
+ el.innerHTML = `
+ `;
+ const res = await Nova.api('dns', 'zones');
+ const list = document.getElementById('r-dns-list');
+ if (!res?.success || !res.data.length) { list.innerHTML = 'No DNS zones.
'; return; }
+ list.innerHTML = `| Domain | Account | Records | Actions |
+ ${res.data.map(z => `
+ | ${z.domain} |
+ ${z.username||'—'} |
+ ${z.record_count||0} |
+ |
+
`).join('')}
+
`;
+}
+
+window.rViewZone = async (zoneId, domain) => {
+ const res = await Nova.api('dns', 'records', { params:{ zone_id: zoneId }});
+ if (!res?.success) { Nova.toast('Failed to load records','error'); return; }
+ const rows = res.data.map(r => `
+ | ${r.name} | ${Nova.badge(r.type,'default')} | ${r.value} | ${r.ttl} |
+ |
+
`).join('');
+ Nova.modal(`DNS Records — ${domain}`,
+ `
+ `);
+};
+window.rAddRecord = (zoneId, domain) => {
+ Nova.modal('Add DNS Record', `
+
+
+
+
+ `,
+ ``);
+};
+window.rDeleteRecord = async (id, zoneId, domain) => {
+ Nova.confirm('Delete this DNS record?', async () => {
+ const res = await Nova.api('dns', 'delete-record', { method:'POST', body:{id, zone_id: zoneId }});
+ if (res?.success) { Nova.toast('Deleted','success'); document.querySelector('.modal-overlay')?.remove(); rViewZone(zoneId, domain); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+
+/* ── Nav ────────────────────────────────────────────────────────────────── */
+const rNavGroups = [
+ { label: 'Overview', items: [
+ { id: 'dashboard', label: 'Dashboard',
+ svg: '' },
+ ]},
+ { label: 'Accounts', items: [
+ { id: 'accounts', label: 'All Accounts',
+ svg: '' },
+ { id: 'createAccount', label: 'New Account',
+ svg: '' },
+ { id: 'packages', label: 'Packages',
+ svg: '' },
+ ]},
+ { label: 'DNS', items: [
+ { id: 'dns', label: 'DNS Zones',
+ svg: '' },
+ ]},
+ { label: 'Tools', items: [
+ { id: 'docker', label: 'Docker',
+ svg: '' },
+ { id: 'whitelabel', label: 'White Label',
+ svg: '' },
+ ]},
+];
+const rPages = { dashboard: rDashboard, accounts: rAccounts, createAccount: rCreateAccount, packages: rPackages, dns: rDNS, docker: rDocker, whitelabel: rWhiteLabel };
+
+let _rActivePage = 'dashboard';
+
+function renderRNav() {
+ const nav = document.getElementById('sidebar-nav');
+ if (!nav) return;
+ nav.innerHTML = rNavGroups.map(g => `
+ `).join('');
+
+ nav.querySelectorAll('[data-page]').forEach(link => {
+ link.addEventListener('click', e => {
+ e.preventDefault();
+ if (window.innerWidth <= 768) {
+ document.getElementById('sidebar')?.classList.remove('open');
+ document.getElementById('sidebar-overlay')?.classList.remove('open');
+ document.body.style.overflow = '';
+ }
+ resellerNav(link.dataset.page);
+ });
+ });
+}
+
+window.resellerNav = (page) => {
+ _rActivePage = page;
+ renderRNav();
+ const allItems = rNavGroups.flatMap(g => g.items);
+ const item = allItems.find(n => n.id === page);
+ const titleEl = document.getElementById('page-title');
+ if (titleEl && item) titleEl.textContent = item.label;
+ const content = document.getElementById('page-content');
+ if (!content) return;
+ content.innerHTML = 'Loading…
';
+ if (rPages[page]) rPages[page](content);
+};
+
+document.addEventListener('DOMContentLoaded', async () => {
+ const ok = await initReseller();
+ if (!ok) return;
+ document.getElementById('logout-btn')?.addEventListener('click', async e => {
+ e.preventDefault();
+ await Nova.api('auth', 'logout', { method: 'POST' });
+ location.href = '/';
+ });
+ renderRNav();
+ window.resellerNav('dashboard');
+});
+
+/* ── Docker (Reseller #33) ────────────────────────────────────────────────── */
+async function rDocker(el) {
+ el.innerHTML = 'Loading…
';
+ const [stRes, acctRes] = await Promise.all([
+ Nova.api('docker', 'stacks'),
+ Nova.api('accounts', 'list', { params: { limit: 200 } }),
+ ]);
+ const stacks = stRes?.data?.stacks || [];
+ const accts = acctRes?.data || [];
+
+ el.innerHTML = `
+
+Manage Docker containers and quotas for your customers. Contact the server admin to change your own Docker allocation.
+
+
+
+
+
+
+`;
+
+ window._rDockerAccts = accts;
+ window._rDockerTab = window._rDockerTab || 'containers';
+
+ window.rDockerTab = async (tab) => {
+ window._rDockerTab = tab;
+ document.querySelectorAll('[onclick^="rDockerTab"]').forEach(b => {
+ const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
+ b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
+ });
+ await rDockerLoadTab(tab);
+ };
+
+ await rDockerLoadTab(window._rDockerTab);
+}
+
+window._rDockerTab = 'containers';
+
+async function rDockerLoadTab(tab) {
+ const tc = document.getElementById('rdocker-content');
+ if (!tc) return;
+ tc.innerHTML = 'Loading…
';
+
+ if (tab === 'containers') {
+ const r = await Nova.api('docker', 'containers');
+ const rows = r?.data?.containers || [];
+ tc.innerHTML = rows.length === 0
+ ? 'No containers for your accounts
'
+ : `| Name | Image | Status | Account | Actions |
+${rows.map(c=>`
+ | ${Nova.escHtml(c.name)} |
+ ${Nova.escHtml(c.image)} |
+ ${Nova.badge(c.status,c.status==='running'?'green':'red')} |
+ ${c.account_id||'—'} |
+
+ ${c.status==='running'
+ ? ``
+ : ``}
+
+ |
+
`).join('')}
+
`;
+
+ } else if (tab === 'quotas') {
+ const accts = window._rDockerAccts || [];
+ tc.innerHTML = accts.length === 0
+ ? 'No accounts
'
+ : `Set Docker limits for each of your customers.
+| Username | Max Containers | Max Memory | Max CPUs | Actions |
+${accts.map(u=>`
+ | ${Nova.escHtml(u.username)} |
+ 2 | 512 MB | 1.0 |
+ |
+
`).join('')}
+
`;
+
+ } else if (tab === 'catalog') {
+ const r = await Nova.api('docker', 'catalog');
+ const catalog = r?.data?.catalog || {};
+ const accts = window._rDockerAccts || [];
+ tc.innerHTML = `
+Pre-install app stacks for your customers.
+
+${Object.entries(catalog).map(([key,app])=>`
+
+
+
${Nova.escHtml(app.icon)}
+
${Nova.escHtml(app.name)}
+
${Nova.escHtml(app.description)}
+
+
+
`).join('')}
+
`;
+ }
+}
+
+window.rDockerAct = async (cid, action) => {
+ Nova.loading(`${action.charAt(0).toUpperCase()+action.slice(1)}ing container…`);
+ const r = await Nova.api('docker', 'container-action', { method: 'POST', body: { container_id: cid, action } });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? `Container ${action}ed` : (r?.message||'Failed'), r?.success?'success':'error');
+ if (r?.success) rDockerLoadTab('containers');
+};
+
+window.rDockerLogs = async (cid, name) => {
+ const r = await Nova.api('docker', 'container-logs', { params: { container_id: cid, lines: 100 } });
+ Nova.modal(`Logs: ${name}`, `${Nova.escHtml(r?.data?.logs||'')}`);
+};
+
+window.rDockerQuotaModal = (userId, username) => {
+ const ov = Nova.modal(`Docker Quota: ${username}`,
+ `
+
+ `,
+ `
+ `
+ );
+ window.rDockerQuotaSubmit = async (uid) => {
+ ov.remove();
+ const r = await Nova.api('docker', 'quota-set', { method:'POST', body:{
+ user_id: uid,
+ max_containers: parseInt(document.getElementById('rdq-cnt').value)||2,
+ max_memory_mb: parseInt(document.getElementById('rdq-mem').value)||512,
+ max_cpus: parseFloat(document.getElementById('rdq-cpus').value)||1.0,
+ }});
+ Nova.toast(r?.success?'Quota saved':(r?.message||'Failed'),r?.success?'success':'error');
+ };
+};
+
+window.rDockerLaunchModal = async (appKey, appName) => {
+ const catRes = await Nova.api('docker', 'catalog');
+ const app = catRes?.data?.catalog?.[appKey];
+ if (!app) return;
+ const accts = window._rDockerAccts || [];
+ const acctOpts = accts.map(a=>``).join('');
+ const paramFields = (app.params||[]).map(p=>`
+
+
`).join('');
+ const ov = Nova.modal(`Deploy ${appName}`,
+ `${paramFields}`,
+ `
+ `
+ );
+ window.rDockerLaunchSubmit = async (key) => {
+ const acctId = parseInt(document.getElementById('rl-acct').value)||0;
+ if (!acctId) { Nova.toast('Select an account','error'); return; }
+ const params = {};
+ (app.params||[]).forEach(p => { params[p.key] = document.getElementById('rl-'+p.key)?.value||''; });
+ ov.remove();
+ Nova.loading(`Deploying ${appName}… this may take a minute`);
+ const r = await Nova.api('docker', 'launch', { method:'POST', body:{ account_id: acctId, app_key: key, params }});
+ Nova.loadingDone();
+ Nova.toast(r?.success?`${appName} deployed!`:(r?.message||'Deploy failed'), r?.success?'success':'error');
+ if (r?.success) rDockerLoadTab('containers');
+ };
+};
+
+// ── White Label / Branding (#18) ────────────────────────────────────────────
+async function rWhiteLabel(el) {
+ el.innerHTML = 'Loading…
';
+ const res = await Nova.api('branding', 'get');
+ const b = res?.data || {};
+
+ el.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+
+ // Sync color pickers ↔ hex inputs ↔ preview
+ ['primary','accent'].forEach(k => {
+ const picker = document.getElementById('wl-'+k);
+ const hex = document.getElementById('wl-'+k+'-hex');
+ const sync = () => {
+ if (picker) hex.value = picker.value;
+ rWlUpdatePreview();
+ };
+ const syncBack = () => {
+ if (/^#[0-9a-fA-F]{6}$/.test(hex.value)) { picker.value = hex.value; rWlUpdatePreview(); }
+ };
+ picker?.addEventListener('input', sync);
+ hex?.addEventListener('input', syncBack);
+ });
+}
+
+function rWlUpdatePreview() {
+ const p = document.getElementById('wl-primary-hex')?.value || '#6366f1';
+ const a = document.getElementById('wl-accent-hex')?.value || '#0ea5e9';
+ const el = document.getElementById('wl-color-preview');
+ if (el) el.style.background = `linear-gradient(135deg,${p},${a})`;
+ // Live-preview CSS vars
+ const style = document.getElementById('reseller-branding') || (() => {
+ const s = document.createElement('style'); s.id = 'reseller-branding'; document.head.appendChild(s); return s;
+ })();
+ style.textContent = `:root { --primary: ${p}; --primary-dark: ${p}; --accent: ${a}; }`;
+}
+
+window.rWlUploadLogo = async () => {
+ const file = document.getElementById('wl-logo-file')?.files?.[0];
+ if (!file) return;
+ if (file.size > 512 * 1024) { Nova.toast('Logo must be under 512 KB', 'error'); return; }
+ const fd = new FormData();
+ fd.append('logo', file);
+ Nova.toast('Uploading…', 'info', 5000);
+ try {
+ const res = await fetch('/api/branding/upload-logo', {
+ method: 'POST', credentials: 'include', body: fd
+ });
+ const data = await res.json();
+ Nova.toast(data?.success ? 'Logo uploaded' : (data?.message || 'Upload failed'),
+ data?.success ? 'success' : 'error');
+ if (data?.success) rWhiteLabel(document.getElementById('page-content'));
+ } catch (e) { Nova.toast('Upload failed', 'error'); }
+};
+
+window.rWlDeleteLogo = async () => {
+ const r = await Nova.api('branding', 'delete-logo', { method: 'POST' });
+ Nova.toast(r?.success ? 'Logo removed' : (r?.message || 'Failed'), r?.success ? 'success' : 'error');
+ if (r?.success) rWhiteLabel(document.getElementById('page-content'));
+};
+
+window.rWlSave = async () => {
+ const body = {
+ panel_name: document.getElementById('wl-name')?.value?.trim() || 'NovaCPX',
+ primary_color: document.getElementById('wl-primary-hex')?.value || '#6366f1',
+ accent_color: document.getElementById('wl-accent-hex')?.value || '#0ea5e9',
+ support_email: document.getElementById('wl-email')?.value?.trim() || '',
+ support_url: document.getElementById('wl-url')?.value?.trim() || '',
+ hide_powered_by: document.getElementById('wl-hide-powered')?.checked ? 1 : 0,
+ custom_css: document.getElementById('wl-css')?.value || '',
+ };
+ Nova.loading('Saving branding…');
+ const r = await Nova.api('branding', 'save', { method: 'POST', body });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? 'Branding saved — reload to see changes' : (r?.message || 'Save failed'),
+ r?.success ? 'success' : 'error');
+};
diff --git a/panel/assets/js/user.js b/panel/assets/js/user.js
new file mode 100644
index 0000000..29266a0
--- /dev/null
+++ b/panel/assets/js/user.js
@@ -0,0 +1,1276 @@
+/**
+ * NovaCPX User Panel JS — all pages
+ */
+
+/* ── Auth guard ──────────────────────────────────────────────────────────── */
+let _user = null;
+
+async function initUser() {
+ const res = await Nova.api('auth', 'me');
+ if (!res || !res.success) {
+ document.getElementById('auth-check').innerHTML = renderLogin();
+ document.getElementById('main-layout').style.display = 'none';
+ return false;
+ }
+ _user = res.data;
+ document.getElementById('user-name').textContent = _user.username || 'User';
+ document.getElementById('auth-check').style.display = 'none';
+ document.getElementById('main-layout').style.display = '';
+
+ // Show impersonation banner if an admin/reseller is acting as this user
+ if (_user.impersonated_by) {
+ const imp = _user.impersonated_by;
+ const returnUrl = imp.role === 'reseller'
+ ? location.href.replace(/:\d+/, ':8881')
+ : location.href.replace(/:\d+/, ':8882');
+ const banner = document.createElement('div');
+ banner.id = 'impersonation-banner';
+ banner.style.cssText = [
+ 'position:fixed;top:0;left:0;right:0;z-index:99998',
+ 'background:linear-gradient(135deg,#f59e0b,#d97706)',
+ 'color:#fff;font-size:.82rem;font-weight:600',
+ 'display:flex;align-items:center;justify-content:center;gap:1rem',
+ 'padding:.45rem 1rem',
+ 'box-shadow:0 2px 8px rgba(0,0,0,.25)',
+ ].join(';');
+ banner.innerHTML = `
+
+ Acting as ${Nova.escHtml(_user.username)} — logged in as ${Nova.escHtml(imp.username)} (${imp.role})
+ `;
+ document.body.prepend(banner);
+ // Push content down so the fixed banner doesn't overlap
+ const layout = document.getElementById('main-layout');
+ if (layout) layout.style.marginTop = '36px';
+ }
+
+ return true;
+}
+
+window.exitImpersonation = async () => {
+ Nova.loading('Returning…');
+ const res = await Nova.api('auth', 'unimpersonate', { method: 'POST' });
+ Nova.loadingDone();
+ if (res?.success && res.data?.portal_url) {
+ window.location.href = res.data.portal_url;
+ } else {
+ Nova.toast(res?.message || 'Could not return', 'error');
+ }
+};
+
+function renderLogin() {
+ return `
+
+
+

+
User Portal · Port 8880
+
+
+
+
+
+
+
+
+
+
+
+
+
`;
+}
+
+async function doLogin() {
+ const u = document.getElementById('li-user')?.value;
+ const p = document.getElementById('li-pass')?.value;
+ const err = document.getElementById('li-err');
+ Nova.loading('Signing in…');
+ const res = await Nova.api('auth', 'login', { method: 'POST', body: { username: u, password: p } });
+ Nova.loadingDone();
+ if (res?.success) {
+ if (res.data?.portal_url && !res.data.portal_url.includes(':8880')) {
+ location.href = res.data.portal_url;
+ } else {
+ location.reload();
+ }
+ } else {
+ if (err) { err.textContent = res?.message || 'Login failed'; err.style.display = 'block'; }
+ }
+}
+window.doLogin = doLogin;
+
+/* ── Pages ───────────────────────────────────────────────────────────────── */
+
+const userPages = {
+ dashboard,
+ domains,
+ email,
+ databases,
+ ftp,
+ ssl,
+ php: phpPage,
+ cron,
+ files,
+ stats: statsPage,
+ backups,
+ docker: dockerPage,
+ 'change-password': changePasswordPage,
+};
+
+/* ── Dashboard ───────────────────────────────────────────────────────────── */
+const _quickIcons = {
+ domains: '',
+ email: '',
+ databases: '',
+ ftp: '',
+ ssl: '',
+ php: '',
+ cron: '',
+ files: '',
+};
+
+async function dashboard(el) {
+ el.innerHTML = `
+
+ ${['Disk','Databases','Email Accts','FTP Accts'].map(l => `
`).join('')}
+
+
+
+
+ ${[
+ ['domains','Domains'],['email','Email'],['databases','Databases'],['ftp','FTP'],
+ ['ssl','SSL'],['php','PHP'],['cron','Cron Jobs'],['files','File Manager'],
+ ].map(([page, label]) => `
+ `).join('')}
+
+
`;
+
+ const res = await Nova.api('stats', 'account');
+ if (res?.success) {
+ const d = res.data;
+ const rings = document.getElementById('dash-rings');
+ rings.innerHTML = [
+ { label: 'Disk', used: d.disk_mb, limit: d.disk_limit, unit: 'MB' },
+ { label: 'Databases', used: d.databases, limit: d.db_limit, unit: '' },
+ { label: 'Email Accts', used: d.emails, limit: d.email_limit, unit: '' },
+ { label: 'FTP Accts', used: d.ftp, limit: d.ftp_limit, unit: '' },
+ ].map(item => {
+ const pct = item.limit > 0 ? Math.min(100, Math.round(item.used / item.limit * 100)) : 0;
+ const r = 26, circ = 2 * Math.PI * r;
+ const dash = circ - (pct / 100) * circ;
+ const color = pct > 85 ? 'var(--red)' : pct > 65 ? 'var(--yellow)' : 'var(--primary)';
+ return `
+
+
${item.label}
+
${item.used}${item.unit} / ${item.limit > 0 ? item.limit + item.unit : '∞'}
+
`;
+ }).join('');
+ }
+}
+
+/* ── Domains ────────────────────────────────────────────────────────────── */
+async function domains(el) {
+ el.innerHTML = `
+ `;
+
+ await loadDomainsList();
+}
+
+async function loadDomainsList() {
+ const el = document.getElementById('domains-list');
+ if (!el) return;
+ const res = await Nova.api('domains', 'list');
+ if (!res?.success) { el.innerHTML = 'No domains
'; return; }
+ const rows = res.data;
+ el.innerHTML = `| Domain | Type | SSL | Actions |
+ ${rows.map(d => `
+ | ${d.domain} |
+ ${Nova.badge(d.type, d.is_primary ? 'primary' : 'default')} |
+ ${d.ssl_enabled ? Nova.badge('SSL','green') : ``} |
+
+ ${!d.is_primary ? `` : ''}
+ |
+
`).join('')}
+
`;
+}
+window.loadDomainsList = loadDomainsList;
+
+window.addDomain = (type) => {
+ const fields = type === 'subdomain'
+ ? ``
+ : ``;
+ Nova.modal(`Add ${type.charAt(0).toUpperCase()+type.slice(1)}`, `
+ ${fields}
`,
+ ``
+ );
+};
+
+window.submitAddDomain = async (type) => {
+ let body = { type };
+ if (type === 'subdomain') body.subdomain = document.getElementById('md-sub')?.value;
+ else body.domain = document.getElementById('md-domain')?.value;
+
+ const action = type === 'subdomain' ? 'add-subdomain' : type === 'alias' ? 'add-alias' : 'add-addon';
+ const res = await Nova.api('domains', action, { method: 'POST', body });
+ if (res?.success) { Nova.toast(res.message,'success'); document.querySelector('.modal-overlay')?.remove(); loadDomainsList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+
+window.removeDomain = (id, domain) => {
+ Nova.confirm(`Remove domain ${domain}? This deletes the vhost and DNS zone.`, async () => {
+ const res = await Nova.api('domains', 'remove', { method: 'POST', body: { id } });
+ if (res?.success) { Nova.toast('Domain removed','success'); loadDomainsList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+ }, true);
+};
+
+function _sslStream(params, onSuccess) {
+ const termId = 'ssl-term-' + Date.now();
+ Nova.modal(`SSL: ${params.domain}`, `
+
+ Requesting certificate…\n
+
`,
+ ``);
+ const term = document.getElementById(termId);
+ const append = t => { term.textContent += t; term.scrollTop = term.scrollHeight; };
+ fetch('/api/ssl/issue', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(params),
+ credentials: 'same-origin',
+ }).then(resp => {
+ if (!resp.ok) { append(`\nHTTP error ${resp.status}`); return; }
+ const reader = resp.body.getReader();
+ const dec = new TextDecoder();
+ let buf = '';
+ const read = () => reader.read().then(({ done, value }) => {
+ if (done) { append('\n[done]'); return; }
+ buf += dec.decode(value, { stream: true });
+ const parts = buf.split('\n\n');
+ buf = parts.pop();
+ for (const part of parts) {
+ const m = part.match(/^data: (.+)$/m);
+ if (!m) continue;
+ try {
+ const obj = JSON.parse(m[1]);
+ if (obj.line) { append(obj.line); }
+ else if (obj.done) {
+ const btn = document.getElementById('ssl-term-close');
+ if (btn) {
+ btn.textContent = obj.success ? 'Done ✓' : 'Close';
+ btn.className = obj.success ? 'btn btn-primary' : 'btn btn-ghost';
+ if (obj.success) btn.onclick = () => { document.querySelector('.modal-overlay')?.remove(); if (onSuccess) onSuccess(); };
+ }
+ }
+ } catch(e) {}
+ }
+ read();
+ }).catch(err => append(`\n[error: ${err.message}]`));
+ read();
+ }).catch(err => append(`\n[error: ${err.message}]`));
+}
+
+window.issueSSL = (domainId, domain) => _sslStream({ domain }, () => loadDomainsList());
+window.issueSSL = window.issueSSL;
+
+/* ── Email ──────────────────────────────────────────────────────────────── */
+async function email(el) {
+ el.innerHTML = `
+
+
+ `;
+
+ loadEmailList();
+ loadForwarderList();
+}
+
+async function loadEmailList() {
+ const el = document.getElementById('email-list');
+ if (!el) return;
+ const res = await Nova.api('email', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No email accounts yet.
'; return; }
+ el.innerHTML = `| Email | Quota | Status | Actions |
+ ${res.data.map(a => `
+ | ${a.email} |
+ ${a.quota_mb > 0 ? a.quota_mb + 'MB' : 'Unlimited'} |
+ ${Nova.badge(a.status, a.status === 'active' ? 'green' : 'yellow')} |
+
+ Webmail
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadEmailList = loadEmailList;
+
+window.addEmailAccount = async () => {
+ const dr = await Nova.api('domains', 'list');
+ const domains = (dr?.data || []).map(d => d.domain).filter(Boolean);
+ const domainOpts = domains.length
+ ? domains.map(d => ``).join('')
+ : '';
+ Nova.modal('Add Email Account', `
+
+
+ `,
+ ``
+ );
+};
+
+window.submitAddEmail = async () => {
+ const local = (document.getElementById('em-local')?.value || '').trim();
+ const domain = document.getElementById('em-domain')?.value || '';
+ if (!local) { Nova.toast('Enter a username', 'error'); return; }
+ if (!domain) { Nova.toast('Select a domain', 'error'); return; }
+ const res = await Nova.api('email', 'create', { method: 'POST', body: {
+ email: `${local}@${domain}`,
+ password: document.getElementById('em-pass')?.value,
+ quota_mb: parseInt(document.getElementById('em-quota')?.value || '0'),
+ }});
+ if (res?.success) { Nova.toast('Email account created','success'); document.querySelector('.modal-overlay')?.remove(); loadEmailList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+
+window.changeEmailPass = (id) => {
+ Nova.modal('Change Email Password', ``,
+ ``);
+};
+window.submitEmailPass = async (id) => {
+ const res = await Nova.api('email', 'change-password', { method: 'POST', body: { id, password: document.getElementById('ep-pass')?.value }});
+ if (res?.success) { Nova.toast('Password updated','success'); document.querySelector('.modal-overlay')?.remove(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+
+window.deleteEmail = (id, addr) => {
+ Nova.confirm(`Delete ${addr}?`, async () => {
+ const res = await Nova.api('email', 'delete', { method: 'POST', body: { id }});
+ if (res?.success) { Nova.toast('Email deleted','success'); loadEmailList(); }
+ }, true);
+};
+
+window.openWebmail = (email) => {
+ Nova.api('webmail', 'url').then(res => {
+ if (res?.success) window.open(res.data.url, '_blank');
+ });
+};
+
+async function loadForwarderList() {
+ const el = document.getElementById('forwarder-list');
+ if (!el) return;
+ const res = await Nova.api('email', 'forwarders');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No forwarders yet.
'; return; }
+ el.innerHTML = `| From | To | |
+ ${res.data.map(f => `| ${f.source} | ${f.destination} |
+ |
`).join('')}
+
`;
+}
+
+window.addForwarder = () => {
+ Nova.modal('Add Forwarder', `
+
+ `,
+ ``);
+};
+window.submitFwd = async () => {
+ const res = await Nova.api('email', 'add-forwarder', { method: 'POST', body: { source: document.getElementById('fw-from')?.value, destination: document.getElementById('fw-to')?.value }});
+ if (res?.success) { Nova.toast('Forwarder added','success'); document.querySelector('.modal-overlay')?.remove(); loadForwarderList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+window.deleteFwd = async (id) => {
+ const res = await Nova.api('email', 'delete-forwarder', { method: 'POST', body: { id }});
+ if (res?.success) { Nova.toast('Deleted','success'); loadForwarderList(); }
+};
+
+/* ── Databases ──────────────────────────────────────────────────────────── */
+async function databases(el) {
+ el.innerHTML = `
+ `;
+ loadDBList();
+}
+
+async function loadDBList() {
+ const el = document.getElementById('db-list');
+ if (!el) return;
+ const res = await Nova.api('databases', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No databases yet.
'; return; }
+ el.innerHTML = `| Database | User | Type | Size | Actions |
+ ${res.data.map(d => `
+ | ${d.db_name} |
+ ${d.db_user} |
+ ${Nova.badge(d.db_type,'default')} |
+ ${d.size || '—'} |
+
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadDBList = loadDBList;
+
+window.addDB = (type) => {
+ Nova.modal(`Create ${type.toUpperCase()} Database`, `
+
+
+ `,
+ ``);
+};
+window.submitAddDB = async (type) => {
+ const res = await Nova.api('databases', 'create', { method:'POST', body: { db_type: type, db_name: document.getElementById('dbn-name')?.value, db_user: document.getElementById('dbn-user')?.value, db_pass: document.getElementById('dbn-pass')?.value }});
+ if (res?.success) { Nova.toast('Database created','success'); document.querySelector('.modal-overlay')?.remove(); loadDBList(); }
+ else Nova.toast(res?.message || 'Failed','error');
+};
+window.changeDBPass = (id) => {
+ Nova.modal('Change DB Password', ``,
+ ``);
+};
+window.submitDBPass = async (id) => {
+ const res = await Nova.api('databases', 'change-password', { method:'POST', body:{ id, password: document.getElementById('dbp-pass')?.value }});
+ if (res?.success) { Nova.toast('Password updated','success'); document.querySelector('.modal-overlay')?.remove(); }
+ else Nova.toast(res?.message,'error');
+};
+window.dropDB = (id, name) => {
+ Nova.confirm(`Drop database ${name}? All data will be permanently deleted.`, async () => {
+ const res = await Nova.api('databases', 'drop', { method:'POST', body:{ id }});
+ if (res?.success) { Nova.toast('Database dropped','success'); loadDBList(); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+
+/* ── FTP ────────────────────────────────────────────────────────────────── */
+async function ftp(el) {
+ el.innerHTML = `
+ `;
+ loadFTPList();
+}
+
+async function loadFTPList() {
+ const el = document.getElementById('ftp-list');
+ if (!el) return;
+ const res = await Nova.api('ftp', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No FTP accounts yet.
'; return; }
+ el.innerHTML = `| Username | Directory | Quota | Actions |
+ ${res.data.map(f => `
+ | ${f.username} |
+ ${f.home_dir} |
+ ${f.quota_mb > 0 ? f.quota_mb+'MB' : 'Unlimited'} |
+
+
+
+ |
+
`).join('')}
+
`;
+}
+window.loadFTPList = loadFTPList;
+
+window.addFTP = () => {
+ Nova.modal('Add FTP Account', `
+
+
+ `,
+ ``);
+};
+window.submitAddFTP = async () => {
+ const res = await Nova.api('ftp', 'create', { method:'POST', body:{ username: document.getElementById('ftp-user')?.value, password: document.getElementById('ftp-pass')?.value, home_dir: document.getElementById('ftp-dir')?.value || null }});
+ if (res?.success) { Nova.toast('FTP account created','success'); document.querySelector('.modal-overlay')?.remove(); loadFTPList(); }
+ else Nova.toast(res?.message||'Failed','error');
+};
+window.changeFTPPass = (id) => {
+ Nova.modal('Change FTP Password', ``,
+ ``);
+};
+window.deleteFTP = (id, user) => {
+ Nova.confirm(`Delete FTP account ${user}?`, async () => {
+ const res = await Nova.api('ftp', 'delete', { method:'POST', body:{id}});
+ if (res?.success) { Nova.toast('Deleted','success'); loadFTPList(); }
+ }, true);
+};
+
+/* ── SSL ────────────────────────────────────────────────────────────────── */
+async function ssl(el) {
+ el.innerHTML = `
+ `;
+ loadSSLList();
+}
+
+async function loadSSLList() {
+ const el = document.getElementById('ssl-list');
+ if (!el) return;
+ const res = await Nova.api('ssl', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No SSL certificates yet.
'; return; }
+ el.innerHTML = `| Domain | Type | Expires | Status | Actions |
+ ${res.data.map(c => {
+ const days = c.days_remaining;
+ const status = !days ? 'unknown' : days < 7 ? 'critical' : days < 30 ? 'warning' : 'ok';
+ const badge = days !== null ? `${days}d` : c.status;
+ const badgeType = status === 'critical' ? 'red' : status === 'warning' ? 'yellow' : 'green';
+ return `
+ | ${c.domain} |
+ ${Nova.badge(c.type,'default')} |
+ ${c.expires_at || '—'} |
+ ${Nova.badge(badge, badgeType)} |
+
+
+
+ |
+
`;
+ }).join('')}
+
`;
+}
+window.loadSSLList = loadSSLList;
+
+window.issueNewSSL = () => {
+ Nova.api('domains','list').then(res => {
+ const opts = (res?.data || []).map(d => ``).join('');
+ Nova.modal("Issue Let's Encrypt SSL", `
+
+ `,
+ ``);
+ });
+};
+window.submitIssueSSL = () => {
+ const domain = document.getElementById('ssl-dom')?.value;
+ const email = document.getElementById('ssl-email')?.value;
+ document.querySelector('.modal-overlay')?.remove();
+ _sslStream({ domain, email }, () => loadSSLList());
+};
+window.renewCert = async (id) => {
+ Nova.toast('Renewing…','info');
+ const res = await Nova.api('ssl', 'renew', { method:'POST', body:{cert_id:id}});
+ if (res?.success) { Nova.toast('Renewed','success'); loadSSLList(); }
+ else Nova.toast(res?.message,'error');
+};
+window.deleteCert = (id, domain) => {
+ Nova.confirm(`Remove SSL cert for ${domain}?`, async () => {
+ const res = await Nova.api('ssl', 'delete', { method:'POST', body:{cert_id:id}});
+ if (res?.success) { Nova.toast('Removed','success'); loadSSLList(); }
+ }, true);
+};
+
+/* ── PHP Manager ────────────────────────────────────────────────────────── */
+async function phpPage(el) {
+ el.innerHTML = `
+ `;
+
+ const [versRes, cfgRes] = await Promise.all([
+ Nova.api('php', 'versions'),
+ Nova.api('php', 'config'),
+ ]);
+
+ if (versRes?.success) {
+ document.getElementById('php-versions').innerHTML = (versRes.data?.versions || []).map(v => `
+
+
+ PHP ${v.version}
+ ${v.is_default ? Nova.badge('default','primary') : ''}
+ ${!v.installed ? Nova.badge('not installed','muted') : ''}
+
+ ${v.installed ? `
` : ''}
+
`).join('');
+ }
+
+ if (cfgRes?.success) {
+ const c = cfgRes.data;
+ document.getElementById('php-settings').innerHTML = `
+
+
+
+
+ `;
+ }
+}
+
+window.switchPHP = async (ver) => {
+ Nova.loading(`Switching to PHP ${ver}…`);
+ const res = await Nova.api('php', 'switch-version', { method:'POST', body:{ version: ver }});
+ Nova.loadingDone();
+ if (res?.success) { Nova.toast(`Switched to PHP ${ver}`,'success'); phpPage(document.getElementById('page-content')); }
+ else Nova.toast(res?.message,'error');
+};
+window.savePHPSettings = async () => {
+ Nova.loading('Saving PHP settings…');
+ const res = await Nova.api('php', 'update-config', { method:'POST', body:{
+ memory_limit: document.getElementById('php-mem')?.value,
+ max_execution_time: document.getElementById('php-exec')?.value,
+ upload_max_filesize: document.getElementById('php-upload')?.value,
+ post_max_size: document.getElementById('php-post')?.value,
+ }});
+ Nova.loadingDone();
+ if (res?.success) Nova.toast('PHP settings saved','success');
+ else Nova.toast(res?.message,'error');
+};
+
+/* ── Cron Jobs ──────────────────────────────────────────────────────────── */
+async function cron(el) {
+ el.innerHTML = `
+ `;
+ loadCronList();
+}
+
+async function loadCronList() {
+ const el = document.getElementById('cron-list');
+ if (!el) return;
+ const res = await Nova.api('cron', 'list');
+ if (!res?.success || !res.data.length) { el.innerHTML = 'No cron jobs yet.
'; return; }
+ el.innerHTML = ``;
+}
+window.loadCronList = loadCronList;
+
+window.addCron = () => {
+ Nova.modal('Add Cron Job', `
+
+
+ ${['minute','hour','day','month','weekday'].map(f => `
`).join('')}
+
+ * = every | */5 = every 5 | 0 = midnight/Jan/Mon
`,
+ ``);
+};
+window.submitCron = async () => {
+ const res = await Nova.api('cron', 'create', { method:'POST', body:{
+ command: document.getElementById('cr-cmd')?.value,
+ minute: document.getElementById('cr-minute')?.value || '*',
+ hour: document.getElementById('cr-hour')?.value || '*',
+ day: document.getElementById('cr-day')?.value || '*',
+ month: document.getElementById('cr-month')?.value || '*',
+ weekday: document.getElementById('cr-weekday')?.value|| '*',
+ }});
+ if (res?.success) { Nova.toast('Cron job added','success'); document.querySelector('.modal-overlay')?.remove(); loadCronList(); }
+ else Nova.toast(res?.message,'error');
+};
+window.toggleCron = async (id) => {
+ await Nova.api('cron', 'toggle', { method:'POST', body:{id}});
+ loadCronList();
+};
+window.deleteCron = (id) => {
+ Nova.confirm('Delete this cron job?', async () => {
+ const res = await Nova.api('cron', 'delete', { method:'POST', body:{id}});
+ if (res?.success) { Nova.toast('Deleted','success'); loadCronList(); }
+ }, true);
+};
+
+/* ── File Manager ───────────────────────────────────────────────────────── */
+let _fmPath = '/public_html';
+
+async function files(el) {
+ el.innerHTML = `
+
+
+
+ ${_fmPath}
+
+
+
+ `;
+
+ loadFMList(_fmPath);
+}
+
+async function loadFMList(path) {
+ _fmPath = path;
+ const pathEl = document.getElementById('fm-path');
+ if (pathEl) pathEl.textContent = path;
+ const el = document.getElementById('fm-list');
+ if (!el) return;
+ const res = await Nova.api('files', 'list', { params: { path }});
+ if (!res?.success) { el.innerHTML = `${res?.message || 'Error loading directory'}
`; return; }
+
+ const parentPath = path.includes('/') ? path.replace(/\/[^/]+$/, '') || '/' : '/';
+ el.innerHTML = `| Name | Size | Perms | Modified | Actions |
+ ${path !== '/' && path !== '/public_html' ? `| ← .. |
` : ''}
+ ${res.data.items.map(f => `
+ |
+ ${f.type === 'dir'
+ ? `📁 ${f.name}`
+ : `📄 ${f.name}`}
+ |
+ ${f.size || '—'} |
+ ${f.perms} |
+ ${f.modified} |
+
+ ${f.type === 'file' ? `` : ''}
+
+
+
+ |
+
`).join('')}
+
`;
+}
+window.fmNav = (p) => loadFMList(p);
+
+window.fmEdit = async (path, name) => {
+ const res = await Nova.api('files', 'read', { params: { path }});
+ if (!res?.success) { Nova.toast(res?.message || 'Cannot read file','error'); return; }
+ const edEl = document.getElementById('fm-editor');
+ edEl.style.display = 'block';
+ edEl.innerHTML = `
+
+
`;
+};
+window.fmSave = async (path) => {
+ const content = document.getElementById('fm-code')?.value || '';
+ const res = await Nova.api('files', 'write', { method:'POST', body:{ path, content }});
+ if (res?.success) Nova.toast('Saved','success');
+ else Nova.toast(res?.message || 'Save failed','error');
+};
+window.fmDelete = (path, name) => {
+ Nova.confirm(`Delete ${name}?`, async () => {
+ const res = await Nova.api('files', 'delete', { method:'POST', body:{ path }});
+ if (res?.success) { Nova.toast('Deleted','success'); loadFMList(_fmPath); }
+ else Nova.toast(res?.message,'error');
+ }, true);
+};
+window.fmMkdir = () => {
+ Nova.modal('New Folder', ``,
+ ``);
+};
+window.fmRename = (path, name) => {
+ const dir = path.replace(/\/[^/]+$/, '');
+ Nova.modal('Rename', ``,
+ ``);
+};
+window.fmChmod = (path, current) => {
+ Nova.modal('Change Permissions', ``,
+ ``);
+};
+window.fmUpload = () => {
+ Nova.modal('Upload File', `
+ `,
+ ``);
+};
+window.submitFMUpload = async () => {
+ const fileInput = document.getElementById('fm-upfile');
+ if (!fileInput?.files[0]) return;
+ const fd = new FormData();
+ fd.append('file', fileInput.files[0]);
+ fd.append('path', _fmPath);
+ const res = await fetch(`/api/files/upload?path=${encodeURIComponent(_fmPath)}`, { method:'POST', credentials:'include', body: fd }).then(r => r.json());
+ if (res?.success) { Nova.toast('Uploaded','success'); document.querySelector('.modal-overlay')?.remove(); loadFMList(_fmPath); }
+ else Nova.toast(res?.message || 'Upload failed','error');
+};
+
+/* ── Stats ──────────────────────────────────────────────────────────────── */
+async function statsPage(el) {
+ el.innerHTML = `
+ `;
+
+ const res = await Nova.api('stats', 'account');
+ if (!res?.success) return;
+ const d = res.data;
+ document.getElementById('stats-grid').innerHTML = [
+ { label: 'Disk Used', val: d.disk_mb + ' MB', limit: d.disk_limit > 0 ? `/ ${d.disk_limit} MB` : '', pct: d.disk_limit > 0 ? Math.min(100,(d.disk_mb/d.disk_limit*100)) : 0 },
+ { label: 'Databases', val: d.databases, limit: d.db_limit > 0 ? `/ ${d.db_limit}` : '', pct: d.db_limit > 0 ? Math.min(100,d.databases/d.db_limit*100) : 0 },
+ { label: 'Email Accounts', val: d.emails, limit: d.email_limit > 0 ? `/ ${d.email_limit}` : '', pct: d.email_limit > 0 ? Math.min(100,d.emails/d.email_limit*100) : 0 },
+ { label: 'FTP Accounts', val: d.ftp, limit: d.ftp_limit > 0 ? `/ ${d.ftp_limit}` : '', pct: d.ftp_limit > 0 ? Math.min(100,d.ftp/d.ftp_limit*100) : 0 },
+ { label: 'Domains', val: d.domains, limit: '', pct: 0 },
+ { label: 'Inodes', val: d.inodes.toLocaleString(), limit: '', pct: 0 },
+ ].map(item => `
+
${item.label}
+
${item.val} ${item.limit}
+ ${item.pct > 0 ? `
${Nova.progressBar(Math.round(item.pct))}
` : ''}
+
`).join('');
+}
+
+/* ── Backups ────────────────────────────────────────────────────────────── */
+async function backups(el) {
+ el.innerHTML = `
+
+`;
+ await loadBackupList();
+}
+
+async function loadBackupList() {
+ const el = document.getElementById('backup-list');
+ if (!el) return;
+ const res = await Nova.api('backup', 'list');
+ const list = res?.data?.backups || [];
+ if (!list.length) {
+ el.innerHTML = `
+
+
No backups yet.
+
Click + Create Backup to create your first backup.
+
`;
+ return;
+ }
+ el.innerHTML = `
+ | Date | Type | Size | Status | Actions |
+
+ ${list.map(b => `
+ | ${Nova.relTime(b.created_at)} |
+ ${Nova.badge(b.type, 'blue')} |
+ ${b.size ? Nova.bytes(parseInt(b.size)) : '—'} |
+ ${Nova.badge(b.status, b.status==='complete'?'green':b.status==='running'?'yellow':'red')} |
+
+ ${b.status === 'complete'
+ ? `Download`
+ : ''}
+ |
+
`).join('')}
+
+
`;
+}
+
+window.createBackup = () => {
+ Nova.modal('Create Backup',
+ `
+
+
+
+ Backups run on the server and may take a few minutes for large accounts.
`,
+ `
+ `
+ );
+};
+
+window.submitCreateBackup = async () => {
+ const type = document.getElementById('bk-type')?.value || 'full';
+ document.querySelector('.modal-overlay')?.remove();
+ Nova.loading('Creating backup… this may take a few minutes');
+ const res = await Nova.api('backup', 'create', { method: 'POST', body: { type } });
+ Nova.loadingDone();
+ if (res?.success) {
+ Nova.toast('Backup created successfully', 'success');
+ loadBackupList();
+ } else {
+ Nova.toast(res?.message || 'Backup failed', 'error');
+ }
+};
+
+/* ── Navigation ─────────────────────────────────────────────────────────── */
+const navGroups = [
+ { label: 'Overview', items: [
+ { id: 'dashboard', label: 'Dashboard',
+ svg: '' },
+ ]},
+ { label: 'Hosting', items: [
+ { id: 'domains', label: 'Domains',
+ svg: '' },
+ { id: 'email', label: 'Email',
+ svg: '' },
+ { id: 'databases', label: 'Databases',
+ svg: '' },
+ { id: 'ftp', label: 'FTP',
+ svg: '' },
+ { id: 'ssl', label: 'SSL / TLS',
+ svg: '' },
+ ]},
+ { label: 'Management', items: [
+ { id: 'php', label: 'PHP',
+ svg: '' },
+ { id: 'cron', label: 'Cron Jobs',
+ svg: '' },
+ { id: 'files', label: 'File Manager',
+ svg: '' },
+ { id: 'stats', label: 'Statistics',
+ svg: '' },
+ ]},
+ { label: 'Tools', items: [
+ { id: 'backups', label: 'Backups',
+ svg: '' },
+ { id: 'docker', label: 'Docker',
+ svg: '' },
+ ]},
+ { label: 'Account', items: [
+ { id: 'change-password', label: 'Change Password',
+ svg: '' },
+ ]},
+];
+
+let _activePage = 'dashboard';
+
+function renderNav() {
+ const nav = document.getElementById('sidebar-nav');
+ if (!nav) return;
+ nav.innerHTML = navGroups.map(g => `
+ `).join('');
+
+ nav.querySelectorAll('[data-page]').forEach(link => {
+ link.addEventListener('click', e => {
+ e.preventDefault();
+ if (window.innerWidth <= 768) {
+ document.getElementById('sidebar')?.classList.remove('open');
+ document.getElementById('sidebar-overlay')?.classList.remove('open');
+ document.body.style.overflow = '';
+ }
+ userNav(link.dataset.page);
+ });
+ });
+}
+
+window.userNav = (page) => {
+ _activePage = page;
+ renderNav();
+ const allItems = navGroups.flatMap(g => g.items);
+ const item = allItems.find(n => n.id === page);
+ const titleEl = document.getElementById('page-title');
+ if (titleEl && item) titleEl.textContent = item.label;
+ const content = document.getElementById('page-content');
+ if (!content) return;
+ content.innerHTML = 'Loading…
';
+ if (userPages[page]) userPages[page](content);
+};
+
+/* ── Change Password ─────────────────────────────────────────────────────── */
+async function changePasswordPage(el) {
+ el.innerHTML = `
+
+`;
+}
+
+window.submitChangePassword = async () => {
+ const current = document.getElementById('cp-current')?.value;
+ const newPass = document.getElementById('cp-new')?.value;
+ const confirm = document.getElementById('cp-confirm')?.value;
+ if (!current || !newPass || !confirm) { Nova.toast('All fields required', 'error'); return; }
+ if (newPass !== confirm) { Nova.toast('New passwords do not match', 'error'); return; }
+ const res = await Nova.api('auth', 'change-password', {
+ method: 'POST',
+ body: { current_password: current, new_password: newPass, confirm_password: confirm },
+ });
+ if (res?.success) {
+ Nova.toast('Password updated successfully', 'success');
+ document.getElementById('cp-current').value = '';
+ document.getElementById('cp-new').value = '';
+ document.getElementById('cp-confirm').value = '';
+ } else {
+ Nova.toast(res?.message || 'Failed to update password', 'error');
+ }
+};
+
+/* ── Docker (#34) ────────────────────────────────────────────────────────── */
+async function dockerPage(el) {
+ el.innerHTML = 'Loading Docker…
';
+ const [stackRes, quotaRes, catRes] = await Promise.all([
+ Nova.api('docker', 'stacks'),
+ Nova.api('docker', 'quota-get'),
+ Nova.api('docker', 'catalog'),
+ ]);
+
+ const stacks = stackRes?.data?.stacks || [];
+ const quota = quotaRes?.data?.quota || { max_containers: 2, max_memory_mb: 512, max_cpus: 1.0 };
+ const catalog = catRes?.data?.catalog || {};
+
+ el.innerHTML = `
+
+
+
Apps Deployed
${stacks.length} / ${quota.max_containers}
${Nova.progressBar(Math.round(stacks.length/Math.max(quota.max_containers,1)*100))}
+
Max Memory / App
${quota.max_memory_mb} MB
+
Max CPUs / App
${quota.max_cpus}
+
+
+
+
+
+
+
+`;
+
+ window._uDockerStacks = stacks;
+ window._uDockerQuota = quota;
+ window._uDockerCatalog = catalog;
+ window._uDockerTab = window._uDockerTab || 'my-apps';
+
+ window.uDockerTab = async (tab) => {
+ window._uDockerTab = tab;
+ document.querySelectorAll('[onclick^="uDockerTab"]').forEach(b => {
+ const t = b.getAttribute('onclick').match(/'([^']+)'/)?.[1];
+ b.className = 'btn btn-sm ' + (t === tab ? 'btn-primary' : 'btn-ghost');
+ });
+ if (tab === 'my-apps') await uDockerReloadStacks();
+ else uDockerLoadTab(tab);
+ };
+
+ if (window._uDockerTab === 'my-apps') await uDockerReloadStacks();
+ else uDockerLoadTab(window._uDockerTab);
+}
+
+window._uDockerTab = 'my-apps';
+
+window.uDockerUninstallAll = () => Nova.confirm(
+ 'Remove ALL your Docker apps? This will stop and delete every container and stack you own. Your hosting account and websites are not affected.',
+ async () => {
+ Nova.loading('Removing all your Docker apps…');
+ const r = await Nova.api('docker', 'uninstall-account', { method: 'POST', body: {} });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? 'All Docker apps removed' : (r?.error || r?.message || 'Failed'), r?.success ? 'success' : 'error');
+ if (r?.success) await uDockerReloadStacks();
+ },
+ true
+);
+
+async function uDockerReloadStacks() {
+ const r = await Nova.api('docker', 'stacks');
+ window._uDockerStacks = r?.data?.stacks || [];
+ uDockerLoadTab('my-apps');
+}
+
+function uDockerLoadTab(tab) {
+ const tc = document.getElementById('udocker-content');
+ if (!tc) return;
+ const stacks = window._uDockerStacks || [];
+ const catalog = window._uDockerCatalog || {};
+ const quota = window._uDockerQuota || {};
+
+ if (tab === 'my-apps') {
+ const statusColor = s => s==='running'?'green':s==='starting'?'yellow':s==='stopped'?'red':'yellow';
+ tc.innerHTML = `
+
+
${stacks.length} app${stacks.length===1?'':'s'}
+
+
+
+
+
+${stacks.length === 0
+ ? `
+
🐳
+
No apps yet. Launch one from the catalog!
+
+
`
+ : `| App | Status | Created | Actions |
+${stacks.map(s=>`
+ | ${Nova.escHtml(s.name)} |
+ ${Nova.badge(s.status, statusColor(s.status))} |
+ ${Nova.relTime(s.created_at)} |
+
+ ${s.status==='running'
+ ? ``
+ : ``}
+
+
+
+ |
+
`).join('')}
+
`}`;
+
+ } else if (tab === 'catalog') {
+ tc.innerHTML = `
+One-click app deployment. Each app runs as an isolated Docker container.
+
+${Object.entries(catalog).map(([key,app])=>`
+
+
+
${Nova.escHtml(app.icon)}
+
${Nova.escHtml(app.name)}
+
${Nova.escHtml(app.description)}
+
+
+
`).join('')}
+
`;
+ }
+}
+
+window.uStackAct = async (stackId, action) => {
+ const label = action === 'up' ? 'Starting' : 'Stopping';
+ Nova.loading(`${label} app…`);
+ const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: stackId, action } });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? `App ${action==='up'?'started':'stopped'}` : (r?.message||'Failed'), r?.success?'success':'error');
+ if (r?.success) await uDockerReloadStacks();
+};
+
+window.uStackLogs = async (stackId, name) => {
+ Nova.loading('Fetching logs…');
+ const r = await Nova.api('docker', 'stack-action', { method: 'POST', body: { stack_id: stackId, action: 'logs' } });
+ Nova.loadingDone();
+ Nova.modal(`Logs: ${name}`, `${Nova.escHtml(r?.data?.output||'No logs available')}`);
+};
+
+window.uStackReinstall = (stackId, name) => Nova.confirm(
+ `Reinstall "${name}"? This will pull the latest images, restart all containers, and reset to a fresh state. Your data volumes will be preserved.`,
+ async () => {
+ Nova.loading(`Reinstalling ${name}…`);
+ const r = await Nova.api('docker', 'stack-reinstall', { method: 'POST', body: { stack_id: stackId } });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? `${name} reinstalled` : (r?.message || 'Reinstall failed'), r?.success ? 'success' : 'error');
+ if (r?.success) await uDockerReloadStacks();
+ },
+ true
+);
+
+window.uStackRemove = async (stackId, name) => {
+ if (!confirm(`Remove app "${name}"? This will stop and delete its containers and data.`)) return;
+ Nova.loading('Removing app…');
+ const r = await Nova.api('docker', 'stack-remove', { method: 'POST', body: { stack_id: stackId } });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? 'App removed' : (r?.message||'Failed'), r?.success?'success':'error');
+ if (r?.success) await uDockerReloadStacks();
+};
+
+window.uDockerLaunchModal = () => uDockerLaunchApp(null);
+
+window.uDockerLaunchApp = async (preselect) => {
+ const catalog = window._uDockerCatalog || {};
+ const entries = Object.entries(catalog);
+ const appOpts = entries.map(([k,a])=>``).join('');
+
+ window.uDockerUpdateParams = (key) => {
+ const app = catalog[key];
+ if (!app) return;
+ const tc = document.getElementById('ul-params');
+ if (!tc) return;
+ tc.innerHTML = (app.params||[]).map(p=>`
+
+
`).join('');
+ };
+
+ const ov = Nova.modal('Launch App',
+ `
+
+ `,
+ `
+ `
+ );
+
+ const initialKey = preselect || entries[0]?.[0];
+ if (initialKey) uDockerUpdateParams(initialKey);
+
+ window.uDockerLaunchSubmit = async () => {
+ const key = document.getElementById('ul-app')?.value;
+ const app = catalog[key];
+ if (!app) return;
+ const params = {};
+ (app.params||[]).forEach(p => { params[p.key] = document.getElementById('ul-'+p.key)?.value||''; });
+ const missing = (app.params||[]).filter(p=>p.required && !params[p.key]);
+ if (missing.length) { Nova.toast(`Required: ${missing.map(p=>p.label).join(', ')}`, 'error'); return; }
+ ov.remove();
+ Nova.loading(`Launching ${app.name}… this may take a minute`);
+ const r = await Nova.api('docker', 'launch', { method: 'POST', body: { app_key: key, params } });
+ Nova.loadingDone();
+ Nova.toast(r?.success ? `${app.name} launching — refresh in a moment to see status` : (r?.message||'Launch failed'), r?.success?'success':'error');
+ if (r?.success) {
+ await uDockerTab('my-apps');
+ }
+ };
+};
+
+/* ── Boot ────────────────────────────────────────────────────────────────── */
+document.addEventListener('DOMContentLoaded', async () => {
+ const ok = await initUser();
+ if (!ok) return;
+ document.getElementById('logout-btn')?.addEventListener('click', async e => {
+ e.preventDefault();
+ await Nova.api('auth', 'logout', { method: 'POST' });
+ location.href = '/';
+ });
+ renderNav();
+ window.userNav('dashboard');
+});
diff --git a/panel/errors/404.php b/panel/errors/404.php
new file mode 100644
index 0000000..7ffc2c8
--- /dev/null
+++ b/panel/errors/404.php
@@ -0,0 +1,39 @@
+
+
+
+
+
+404 — Page Not Found · NovaCPX
+
+
+
+
+
+
404
+
Page Not Found
+
The page you're looking for doesn't exist or has been moved.
+
+
+ Go Back
+
+
+
+
diff --git a/panel/errors/500.php b/panel/errors/500.php
new file mode 100644
index 0000000..ce2d702
--- /dev/null
+++ b/panel/errors/500.php
@@ -0,0 +1,39 @@
+
+
+
+
+
+500 — Server Error · NovaCPX
+
+
+
+
+
+
500
+
Internal Server Error
+
Something went wrong on our end. The issue has been logged. Please try again in a moment.
+
+
+ Go Back
+
+
+
+
diff --git a/panel/index.php b/panel/index.php
new file mode 100644
index 0000000..941756d
--- /dev/null
+++ b/panel/index.php
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+NovaCPX — Login
+
+
+
+
+
+
+
+
+
NovaCPX
+
+
+
+
Sign In
+
Linux Web Hosting Control Panel
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/panel/reseller/index.php b/panel/reseller/index.php
new file mode 100644
index 0000000..e6d2b05
--- /dev/null
+++ b/panel/reseller/index.php
@@ -0,0 +1,94 @@
+ '?v=' . @filemtime(dirname(__DIR__) . $f);
+require_once dirname(__DIR__) . '/_branding.php';
+$_pname = novacpx_panel_name('NovaCPX');
+?>
+
+
+
+
+
+
+
+
+= $_pname ?> — Reseller
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?>
+
Reseller Panel
+
+
+
+
+
+
+
+
+
+
+
diff --git a/panel/user/index.php b/panel/user/index.php
new file mode 100644
index 0000000..475a786
--- /dev/null
+++ b/panel/user/index.php
@@ -0,0 +1,155 @@
+ '?v=' . @filemtime(dirname(__DIR__) . $f);
+require_once dirname(__DIR__) . '/_branding.php';
+$_pname = novacpx_panel_name('NovaCPX');
+?>
+
+
+
+
+
+
+
+
+= $_pname ?> — My Hosting
+
+
+
+
+
+
+
+
+
+
+
+
+
+
= htmlspecialchars($_pname, ENT_QUOTES, 'UTF-8') ?>
+
My Hosting
+
+
+
+
+
+
+
+
+
+
+